奇怪的GCD

jzbb7438 6年前發布 | 41K 次閱讀 RunLoop

多線程一直是我相當感興趣的技術知識之一,個人尤其喜愛 GCD 這個輕量級的多線程解決方案,為了了解其實現,不厭其煩的翻閱 libdispatch 的源碼。甚至因為太喜歡了,本來想要寫這相應的源碼解析系列文章,但害怕寫的不好,于是除了開篇的類型介紹,也是草草了事,沒了下文

恰好這幾天好友出了幾道有關 GCD 的題目,運行結果出于意料,仔細摸索后,發現蘋果基于 libdispatch 做了一些有趣的修改工作,于是想將這兩道題目分享出來。由于朋友提供的運行代碼為 Swift 書寫,在此我轉換成等效的 OC 代碼進行講述。你如果了解了下面兩個概念,會讓后續的閱讀更加容易:

  • 同步與異步的概念
  • 隊列與線程的區別

被誤解的概念

對于主線程和主隊列,我們可能會有這么一個理解

主線程只會執行主隊列的任務。同樣,主隊列只會在主線程上被執行

主線程只會執行主隊列的任務

首先是主線程只會執行主隊列的任務。在 iOS 中,只有主線程才擁有權限向渲染服務提交打包的圖層樹信息,完成圖形的顯示工作。而我們在 work queue 中提交的 UI 更新總是無效的,甚至導致崩潰發生。而由于主隊列只有一條,其他的隊列全部都是 work queue ,因此可以得出 主線程只會執行主隊列的任務 這一結論。但是,有下面這么一段代碼:

dispatch_queue_t mainQueue = dispatch_get_main_queue();
dispatch_queue_t globalQueue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_HIGH, 0);

dispatch_queue_set_specific(mainQueue, "key", "main", NULL);
dispatch_sync(globalQueue, ^{
    BOOL res1 = [NSThread isMainThread];
    BOOL res2 = dispatch_get_specific("key") != NULL;

    NSLog(@"is main thread: %zd --- is main queue: %zd", res1, res2);
});

根據正常邏輯的理解來說,這里的兩個判斷結果應該都是 NO ,但運行后,第一個判斷為 YES ,后者為 NO ,輸出說明了主線程此時執行了 work queue 的任務

dispatch_sync

上面的代碼在換成 async 之后就會得到預期的判斷結果,但在同步執行的情況下就會導致這個問題。在查找原因之前,借用 bestswifter 文章中的代碼一用,首先 sync 的調用棧以及大致源碼如下:

dispatch_sync  
    └──dispatch_sync_f
        └──_dispatch_sync_f2
            └──_dispatch_sync_f_slow


static void _dispatch_sync_f_slow(dispatch_queue_t dq, void *ctxt, dispatch_function_t func) {  
    _dispatch_thread_semaphore_t sema = _dispatch_get_thread_semaphore();
    struct dispatch_sync_slow_s {
        DISPATCH_CONTINUATION_HEADER(sync_slow);
    } dss = {
        .do_vtable = (void*)DISPATCH_OBJ_SYNC_SLOW_BIT,
        .dc_ctxt = (void*)sema,
    };
    _dispatch_queue_push(dq, (void *)&dss);

    _dispatch_thread_semaphore_wait(sema);
    _dispatch_put_thread_semaphore(sema);
    // ...
}

可以看到對于 libdispatch 對于同步任務的處理是采用 sema 信號量的方式堵塞調用線程直到任務被處理完成,這也是為什么 sync 嵌套使用是一個死鎖問題。根據源碼可以得到執行的流程圖:

但實際運行后, block 是執行在主線程上的,代碼真正流程是這樣的:

因此可以做一個猜想:

由于 sync 函數本身會堵塞當前執行線程直到任務執行。為了減少線程切換的開銷,以及避免線程被堵塞的資源浪費,于是對 sync 函數進行了改進:在大多數情況下,直接在當前線程執行同步任務

既然有了猜想,就需要驗證。之所以說是大多數情況,是因為目前 主隊列只在主線程上被執行 還是有效的,因此我們排除 global -sync-> main 這種條件。因此為了驗證效果,需要創建一個串行線程:

dispatch_queue_t serialQueue = dispatch_queue_create("serial.queue", DISPATCH_QUEUE_SERIAL);
dispatch_queue_t globalQueue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_HIGH, 0);

dispatch_sync(globalQueue, ^{
    BOOL res1 = [NSThread isMainThread];
    BOOL res2 = dispatch_get_specific("key") != NULL;

    NSLog(@"is main thread: %zd --- is main queue: %zd", res1, res2);
});

dispatch_async(globalQueue, ^{
    NSThread *globalThread = [NSThread currentThread];
    dispatch_sync(serialQueue, ^{
        BOOL res = [NSThread currentThread] == globalThread;
        NSLog(@"is same thread: %zd", res);
    });
});

運行后,兩次判斷的結果都是 YES ,結果足以驗證猜想,可以確定蘋果為了提高性能,已經對 sync 做了修改。另外 global -sync-> main 測試結果發現 sync 的調用過程不會被優化

主隊列只會在主線程上執行

上面說過,只有主線程才有權限提交渲染任務。同樣的,出于下面兩個設定,這個理解應當是成立的:

  • 主隊列總是可以調用 UIKit 的接口 api
  • 同時只有一條線程能夠執行串行隊列的任務

同樣的,朋友給出了另一份代碼,但由于代碼中存在一個 Swift 的關鍵函數,因此直接展示原代碼:

let key = DispatchSpecificKey<String>()
DispatchQueue.main.setSpecific(key: key, value: "main")

func log() {
    debugPrint("main thread: \(Thread.isMainThread)")
    let value = DispatchQueue.getSpecific(key: key)
    debugPrint("main queue: \(value != nil)")
}

DispatchQueue.global().async {
    DispatchQueue.main.async(execute: log)
}

dispatchMain()

運行之后,輸出結果分別為 NO 和 YES ,也就是說此時主隊列的任務并沒有在主線程上執行。要弄清楚這個問題的原因顯然難度要比上一個問題難度大得多,因為如果子線程可以執行主隊列的任務,那么此時是無法提交打包圖層信息到渲染服務的

同樣的,我們可以先猜測原因。不同于正常的項目啟動代碼,這個 Swift 文件的運行更像是腳本運行,因為缺少了一段啟動代碼:

@autoreleasepool
{
    return UIApplicationMain(argc, argv, nil, NSStringFromClass([AppDelegate class]));
}

為了找到答案,首先需要對問題 主線程只會執行主隊列的任務 的代碼進行改造一下。另外由于第二個問題涉及到 執行任務所在的線程 , mach_thread_self 函數會返回當前線程的 id ,可以用來判斷兩個線程是否相同:

thread_t threadId = mach_thread_self();

dispatch_queue_t mainQueue = dispatch_get_main_queue();
dispatch_queue_t serialQueue = dispatch_queue_create("serial.queue", DISPATCH_QUEUE_SERIAL);
dispatch_queue_t globalQueue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_HIGH, 0);

dispatch_async(globalQueue, ^{
    dispatch_async(mainQueue, ^{
        NSLog(@"%zd --- %zd", threadId == mach_thread_self(), [NSThread isMainThread]);
    });
});

@autoreleasepool
{
    return UIApplicationMain(argc, argv, nil, NSStringFromClass([AppDelegate class]));
}

這段代碼的運行結果都是 YES ,說明在 UIApplicationMain 函數前后主隊列任務執行的線程 id 是相同的,因此可以得出兩個條件:

  • 主隊列的任務總是在同一個線程上執行
  • 在 UIApplicationMain 函數調用后, isMainThread 返回了正確結果

結合這兩個條件,可以做出猜想:在 UIApplicationMain 中存在某個操作使得原本執行主隊列任務的線程變成了 主線程 ,其猜想圖如下:

由于 UIApplicationMain 是個私有 api ,我們沒有其實現代碼,但是我們都知道在這個函數調用之后,主線程的 runloop 會被啟動,那么這個線程的變動是不是跟 runloop 的啟動有關呢?為了驗證這個判斷,在手動啟動 runloop 定時的去檢測線程:

func log() {
    debugPrint("is main thread: \(Thread.isMainThread)")
DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + .seconds(1), execute: log)
}

DispatchQueue.global().async {
    DispatchQueue.main.async(execute: log)
}

RunLoop.current.run()

在 runloop 啟動后,所有的檢測結果都是 YES :

// console log
"is main thread: true"
"is main thread: true"
"is main thread: true"
"is main thread: true"
"is main thread: true"
"is main thread: true"
"is main thread: true"
"is main thread: true"
"is main thread: true"
"is main thread: true"
"is main thread: true"
"is main thread: true"
"is main thread: true"
"is main thread: true"

代碼的運行結果驗證了這個猜想,但結論就變成了:

thread -> runloop -> main thread

這樣的結論,隨便啟動一個 work queue 的 runloop 就能輕易的推翻這個結論,那么是否可能只有第一次啟動 runloop 的線程才有可能變成主線程?為了驗證這個猜想,繼續改造代碼:

let serialQueue = DispatchQueue(label: "serial.queue")

func logSerial() {
    debugPrint("is main thread: \(Thread.isMainThread)")
serialQueue.asyncAfter(deadline: DispatchTime.now() + .seconds(1), execute: logSerial)
}

serialQueue.async {
    RunLoop.current.run()
}

DispatchQueue.global().async {
    serialQueue.async(execute: logSerial)
}

dispatchMain()

在保證了子線程的 runloop 是第一個被啟動的情況下,所有運行的輸出結果都是 false ,也就是說因為 runloop 修改了線程的 priority 的猜想是不成立的,那么基于 UIApplicationMain 測試代碼的兩個條件無法解釋 主隊列為什么沒有運行在主線程上

主隊列不總是在同一個線程上執行

經過來回推敲,我發現 主隊列總是在同一個線程上執行 這個條件限制了進一步擴大猜想的可能性,為了驗證這個條件,通過定時輸出主隊列任務所在的 threadId 來檢測這個條件是否成立:

let threadId =  mach_thread_self()
let serialQueue = DispatchQueue(label: "serial.queue")
debugPrint("current thread id is: \(threadId)")

func logMain() {
    debugPrint("=====main queue======> thread id is: \(mach_thread_self())")

    DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + .seconds(1), execute: logMain)
}

func logSerial() {
    debugPrint("serial queue thread id is: \(mach_thread_self())")
    serialQueue.asyncAfter(deadline: DispatchTime.now() + .seconds(1), execute: logSerial)
}

DispatchQueue.global().async {
    serialQueue.async(execute: logSerial)
    DispatchQueue.main.async(execute: logMain)
}

dispatchMain()

在測試代碼中增加子隊列定時做對比,發現不管是 serial queue 還是 main queue ,都有可能運行在不同的線程上面。但是如果去掉了子隊列作為對比, main queue 只會執行在一條線程上,但該線程的 threadId 總是不等同于我們保存下來的數值:

// console log
current thread id is: 775
"serial queue thread id is: 6403"
"=====main queue======> thread id is: 7171"
"serial queue thread id is: 6403"
"=====main queue======> thread id is: 6403"
"serial queue thread id is: 6403"
"=====main queue======> thread id is: 6403"
"serial queue thread id is: 6403"
"=====main queue======> thread id is: 6403"
"serial queue thread id is: 6403"
"=====main queue======> thread id is: 1547"
"serial queue thread id is: 1547"
"=====main queue======> thread id is: 6403"
"serial queue thread id is: 6403"
"=====main queue======> thread id is: 1547"
"serial queue thread id is: 1547"
"=====main queue======> thread id is: 6403"
"=====main queue======> thread id is: 1547"
"serial queue thread id is: 6403"
"serial queue thread id is: 4355"
"=====main queue======> thread id is: 6403"
"serial queue thread id is: 6403"
"=====main queue======> thread id is: 4355"
"=====main queue======> thread id is: 6403"
"serial queue thread id is: 4355"
"=====main queue======> thread id is: 4355"
"serial queue thread id is: 6403"
"serial queue thread id is: 1547"
"=====main queue======> thread id is: 6403"
"serial queue thread id is: 6403"
"=====main queue======> thread id is: 1547"
"=====main queue======> thread id is: 6403"
"serial queue thread id is: 1547"
"serial queue thread id is: 6403"
"=====main queue======> thread id is: 1547"
"serial queue thread id is: 1547"

發現了這一個新的現象后,結合之前的信息來看,可以得出一個新的猜想:

有一個專用啟動線程用于啟動主線程的 runloop ,啟動前主隊列會被這個線程執行

要測試這個猜想也很簡單,只要對比 runloop 前后的 threadId 是否一致就可以了:

let threadId =  mach_thread_self()
debugPrint("current thread id is: \(threadId)")

func logMain() {
    debugPrint("=====main queue======> thread id is: \(mach_thread_self())")

    DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + .seconds(1), execute: logMain)
}

DispatchQueue.global().async {
    DispatchQueue.main.async(execute: logMain)
}

RunLoop.current.run()

// console log
current thread id is: 775
"=====main queue======> thread id is: 775"
"=====main queue======> thread id is: 775"
"=====main queue======> thread id is: 775"
"=====main queue======> thread id is: 775"
"=====main queue======> thread id is: 775"
"=====main queue======> thread id is: 775"
"=====main queue======> thread id is: 775"
"=====main queue======> thread id is: 775"
"=====main queue======> thread id is: 775"

運行結果說明了并不存在什么 啟動線程 ,一旦 runloop 啟動后,主隊列就會一直執行在同一個線程上,而這個線程就是主線程。由于 runloop 本身是一個不斷循環處理事件的死循環,這才是它啟動后主隊列一直運行在一個主線程上的原因。最后為了測試啟動 runloop 對串行隊列的影響,單獨啟動子隊列和一起啟動后,發現另一個現象:

  • 主隊列的 runloop 一旦啟動,就只會被該線程執行任務
  • 子隊列的 runloop 無法綁定隊列和線程的執行關系

由于在源碼中 async 調用對于主隊列和子隊列的表現不同,后者會直接啟用一個線程來執行子隊列的任務,這就是導致了 runloop 在主隊列和子隊列上差異化的原因,也能說明蘋果并沒有大肆修改 libdispatch 的源碼。

其他

過了一個漫長的春節假期之后,感覺急需一個節假日來休息,可惜這只是奢望。由于節后綜合征,在這周重新返工的狀態感覺一般,也偶爾會提不起神來,希望自己盡快恢復過來。另外隨著不斷的積累,一些自以為熟悉的奇怪問題又總能帶來新的認知和收獲,我想這就是學習最大的快樂了

關于使用代碼

由于 Swift 語法上和 OC 始終存在差異,第二段代碼并不能很好的還原,如果對此感興趣的朋友可以關注下方 倉鼠大佬 的博客鏈接,大佬放話后續會放出源碼。另外如果不想閱讀 libdispatch 源碼又想對這部分的邏輯有所了解的朋友可以看下面的鏈接文章

擴展閱讀

倉鼠大佬

深入了解GCD

 

來自:http://sindrilin.com/note/2018/03/03/weird_thread/

 

 本文由用戶 jzbb7438 自行上傳分享,僅供網友學習交流。所有權歸原作者,若您的權利被侵害,請聯系管理員。
 轉載本站原創文章,請注明出處,并保留原始鏈接、圖片水印。
 本站是一個以用戶分享為主的開源技術平臺,歡迎各類分享!