奇怪的GCD
多線程一直是我相當感興趣的技術知識之一,個人尤其喜愛 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 源碼又想對這部分的邏輯有所了解的朋友可以看下面的鏈接文章
擴展閱讀
來自:http://sindrilin.com/note/2018/03/03/weird_thread/