奇怪的GCD

騎著jm的hi發表於2018-03-03

原文地址

多執行緒一直是我相當感興趣的技術知識之一,個人尤其喜愛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巢狀使用是一個死鎖問題。根據原始碼可以得到執行的流程圖:

image

但實際執行後,block是執行在主執行緒上的,程式碼真正流程是這樣的:

image

因此可以做一個猜想:

由於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
  • 同時只有一條執行緒能夠執行序列佇列的任務

同樣的,朋友給出了另一份程式碼:

dispatch_queue_set_specific(mainQueue, "key", "main", NULL);

dispatch_block_t log = ^{
    printf("main thread: %zd", [NSThread isMainThread]);
    void *value = dispatch_get_specific("key");
    printf("main queue: %zd", value != NULL);
}

dispatch_async(globalQueue, ^{
    dispatch_async(dispatch_get_main_queue(), log);
});

dispatch_main();
複製程式碼

執行之後,輸出結果分別為NOYES,也就是說此時主佇列的任務並沒有在主執行緒上執行。要弄清楚這個問題的原因顯然難度要比上一個問題難度大得多,因為如果子執行緒可以執行主佇列的任務,那麼此時是無法提交打包圖層資訊到渲染服務的

奇怪的GCD

同樣的,我們可以先猜測原因。不同於正常的專案啟動程式碼,這個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中存在某個操作使得原本執行主佇列任務的執行緒變成了主執行緒,其猜想圖如下:

奇怪的GCD

由於UIApplicationMain是個私有api,我們沒有其實現程式碼,但是我們都知道在這個函式呼叫之後,主執行緒的runloop會被啟動,那麼這個執行緒的變動是不是跟runloop的啟動有關呢?為了驗證這個判斷,在手動啟動runloop定時的去檢測執行緒:

dispatch_block_t log = ^{
    printf("is main thread: %zd\n", [NSThread isMainThread]);
    dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(1.0 * NSEC_PER_SEC)), dispatch_get_main_queue(), log);
}

dispatch_async(globalQueue, ^{
    dispatch_async(dispatch_get_main_queue(), log);
});

[[NSRunLoop currentRunLoop] run];
複製程式碼

runloop啟動後,所有的檢測結果都是YES

// console log
"is main thread: 1"
"is main thread: 1"
"is main thread: 1"
"is main thread: 1"
"is main thread: 1"
"is main thread: 1"
"is main thread: 1"
"is main thread: 1"
"is main thread: 1"
"is main thread: 1"
"is main thread: 1"
"is main thread: 1"
"is main thread: 1"
"is main thread: 1"
複製程式碼

程式碼的執行結果驗證了這個猜想,但結論就變成了:

thread -> runloop -> main thread

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

dispatch_queue_t serialQueue = dispatch_queue_create("serial.queue", DISPATCH_QUEUE_SERIAL);

dispatch_block_t logSerial = ^{
    printf("is main thread: %zd\n", [NSThread isMainThread]);
    dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(1.0 * NSEC_PER_SEC)), serialQueue, log);
}

dispatch_async(serialQueue, ^{
    [[NSRunLoop currentRunLoop] run];
});
dispatch_async(globalQueue, ^{
    dispatch_async(serialQueue, logSerial);
});

dispatch_main();
複製程式碼

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

主佇列不總是在同一個執行緒上執行

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

thread_t threadId = mach_thread_self();
dispatch_queue_t serialQueue = dispatch_queue_create("serial.queue", DISPATCH_QUEUE_SERIAL);
printf("current thread id is: %d\n", threadId);

dispatch_block_t logMain = ^{
    printf("=====main queue======> thread id is: %d\n", mach_thread_self());
    dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(1.0 * NSEC_PER_SEC)), dispatch_get_main_queue(), logMain);
}

dispatch_block_t logSerial = ^{
    printf("serial queue thread id is: %d\n", mach_thread_self());
    dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(1.0 * NSEC_PER_SEC)), serialQueue, logSerial);
}

dispatch_async(globalQueue, ^{
    dispatch_async(serialQueue, logSerial);
    dispatch_async(dispatch_get_main_queue(), logMain);
});

dispatch_main();
複製程式碼

在測試程式碼中增加子佇列定時做對比,發現不管是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是否一致就可以了:

thread_t threadId = mach_thread_self();
printf("current thread id is: %d\n", threadId);

dispatch_block_t logMain = ^{
    printf("=====main queue======> thread id is: %d\n", mach_thread_self());
    dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(1.0 * NSEC_PER_SEC)), dispatch_get_main_queue(), logMain);
}

dispatch_block_t logSerial = ^{
    printf("serial queue thread id is: %d\n", mach_thread_self());
    dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(1.0 * NSEC_PER_SEC)), serialQueue, logSerial);
}

dispatch_async(globalQueue, ^{
    dispatch_async(serialQueue, logSerial);
    dispatch_async(dispatch_get_main_queue(), logMain);
});

[[NSRunLoop currentRunLoop] 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的原始碼。

有趣的runloop喚醒機制

如果你看過runloop相關的部落格或者文件,那麼應該會它是一個不斷處理訊息、事件的死迴圈,但死迴圈是會消耗大量的cpu資源的(自旋鎖就是死迴圈空轉)。runloop為了提高執行緒的使用效率以及減少不必要的損耗,在沒有事件處理的時候,假如此時存在timer、port、source任一一種,那麼進入休眠狀態;假如不存在三者其中之一,那麼runloop將會退出

奇怪的GCD

因此為了探討runloop的喚醒,我們可以通過新增一個空埠來維持runloop的運轉:

CFRunLoopRef runloop = NULL;
NSThread *thread = [[NSThread alloc] initWithBlock: ^{
    runloop = [NSRunLoop currentRunLoop].getCFRunLoop;
    [[NSRunLoop currentRunLoop] addPort: [NSMachPort new] forMode: NSRunLoopCommonModes];
    [[NSRunLoop currentRunLoop] run];
}];
複製程式碼

這裡主要討論的是倉鼠大佬的第五題,原問題可以直接到最下面翻連結。主要要說明的是問題中提到的兩個api,用於新增任務到這個runloop中:

CFRunLoopPerformBlock(runloop, NSRunLoopCommonModes, ^{
    NSLog(@"runloop perform block 1");
});

[NSObject performSelector: @selector(log) onThread: thread withObject: obj waitUntilDone: NO];

CFRunLoopPerformBlock(runloop, NSRunLoopCommonModes, ^{
    NSLog(@"runloop perform block 2");
});
複製程式碼

上面的程式碼如果去掉了第二個perform呼叫,那麼第一個呼叫不會輸出,反之就會都輸出。從名字上看,兩個呼叫都是往所在的執行緒裡面新增執行任務,區別在於後者的呼叫實際上並不是直接插入任務block,而是將任務包裝成一個timer事件來新增,這個事件會喚醒runloop。當然,前提是runloop處在休眠中。

CFRunLoopPerformBlock提供了往runloop中新增任務的功能,但又不會喚醒runloop,在事件很少的情況下,這個api能有效的減少執行緒狀態切換的開銷

其他

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

關於使用程式碼

由於Swift語法上和OC始終存在差異,第二段程式碼並不能很好的還原,如果對此感興趣的朋友可以關注下方倉鼠大佬的部落格連結,大佬放話後續會放出原始碼。另外如果不想閱讀libdispatch原始碼又想對這部分的邏輯有所瞭解的朋友可以看下面的連結文章

擴充套件閱讀

倉鼠大佬

深入瞭解GCD

關注我的公眾號獲取更新資訊