多執行緒一直是我相當感興趣的技術知識之一,個人尤其喜愛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
- 同時只有一條執行緒能夠執行序列佇列的任務
同樣的,朋友給出了另一份程式碼:
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();
複製程式碼
執行之後,輸出結果分別為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
定時的去檢測執行緒:
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 queue
的runloop
就能輕易的推翻這個結論,那麼是否可能只有第一次啟動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
將會退出
因此為了探討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
原始碼又想對這部分的邏輯有所瞭解的朋友可以看下面的連結文章