從最初開始學習 iOS 的時候,我們就被告知 UI 操作一定要放在主執行緒進行。這是因為 UIKit 的方法不是執行緒安全的,保證執行緒安全需要極大的開銷。那麼問題來了,在主執行緒中進行 UI 操作一定是安全的麼?
顯然,答案是否定的!
在蘋果的 MapKit
框架中,有一個叫做 addOverlay
的方法,它在底層實現的時候,不僅僅要求程式碼執行在主執行緒上,還要求執行在 GCD 的主佇列上。這是一個極罕見的問題,但已經有人在使用 ReactiveCocoa 時踩到了坑,並提交了 issue。
蘋果的 Developer Technology Support 承認這是一個 bug。不管這是 bug 還是歷史遺留設計,也不管是不是在鑽牛角尖,為了避免再次掉進同樣的坑,我認為都有必要分析一下問題發生的原因和解決方案。
GCD 知識複習
在 GCD 中,使用 dispatch_get_main_queue()
函式可以獲取主佇列。呼叫 dispatch_sync()
方法會把任務同步提交到指定的佇列。
注意一下佇列和執行緒的區別,他們之間並沒有“擁有關係(ownership)”,當我們同步的提交一個任務時,首先會阻塞當前佇列,然後等到下一次 runloop 時再在合適的執行緒中執行 block。
在執行 block 之前,首先會尋找合適的執行緒來執行block,然後阻塞這個執行緒,直到 block 執行完畢。尋找執行緒的規則是: 任何提交到主佇列的 block 都會在主執行緒中執行,在不違背此規則的前提下,文件還告訴我們系統會自動進行優化,儘可能的在當前執行緒執行 block。
順便補充一句,GCD 死鎖的充分條件是:“向當前佇列重複同步提交 block”。從原理來看,死鎖的原因是提交的 block 阻塞了佇列,而佇列阻塞後永遠無法執行完 dispatch_sync()
,可見這裡完全和程式碼所在的執行緒無關。
另一個例子也可以證明這一點,在主執行緒中向一個序列佇列同步的派發 block,根據上文選擇執行緒的原則,block 將在主執行緒中執行,但同樣不會導致死鎖:
1 2 3 4 5 6 |
dispatch_queue_t queue = dispatch_queue_create("com.kt.deadlock", nil); dispatch_sync(queue, ^{ NSLog(@"current thread = %@", [NSThread currentThread]); }); // 輸出結果: // current thread = {number = 1, name = main} |
原因分析
囉嗦了這麼多,回到之前描述的 bug 中來。現在我們知道,即使是在主執行緒中執行的程式碼,也很可能不是執行在主佇列中(反之則必然)。如果我們在子佇列中呼叫 MapKit
的 addOverlay
方法,即使當前處於主執行緒,也會導致 bug 的產生,因為這個方法的底層實現判斷的是主佇列而非主執行緒。
更進一步的思考,有時候為了保證 UI 操作在主執行緒執行,如果有一個函式可以用來建立新的 UILabel
,為了確保執行緒安全,程式碼可能是這樣:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
- (UILabel *)labelWithText: (NSString *)text { __block UILabel *theLabel; if ([NSThread isMainThread]) { theLabel = [[UILabel alloc] init]; [theLabel setText:text]; } else { dispatch_sync(dispatch_get_main_queue(), ^{ theLabel = [[UILabel alloc] init]; [theLabel setText:text]; }); } return theLabel; } |
從嚴格意義上來講,這樣的寫法不是 100% 安全的,因為我們無法得知相關的系統方法是否存在上述 Bug。
解決方案
由於提交到主佇列的 block 一定在主執行緒執行,並且在 GCD 中執行緒切換通常都是由指定某個佇列引起的,我們可以做一個更加嚴格的判斷,即用判斷是否處於主佇列來代替是否處於主執行緒。
GCD 沒有提供 API 來進行相應的判斷,但我們可以另闢蹊徑,利用 dispatch_queue_set_specific
和 dispatch_get_specific
這一組方法為主佇列打上標記:
1 2 3 4 5 6 7 8 9 10 11 |
+ (BOOL)isMainQueue { static const void* mainQueueKey = @"mainQueue"; static void* mainQueueContext = @"mainQueue"; static dispatch_once_t onceToken; dispatch_once(&onceToken, ^{ dispatch_queue_set_specific(dispatch_get_main_queue(), mainQueueKey, mainQueueContext, nil); }); return dispatch_get_specific(mainQueueKey) == mainQueueContext; } |
用 isMainQueue
方法代替 [NSThread isMainThread]
即可獲得更好的安全性。
參考資料
- Community bug reports about MapKit
- GCD’s Main Queue vs Main Thread
- ReactiveCocoa 中遇到類似的坑
- Why can’t we use a dispatch_sync on the current queue?
打賞支援我寫出更多好文章,謝謝!
打賞作者
打賞支援我寫出更多好文章,謝謝!
任選一種支付方式