主線程中也不絕對(duì)安全的UI操作
從最初開(kāi)始學(xué)習(xí) iOS 的時(shí)候,我們就被告知 UI 操作一定要放在主線程進(jìn)行。這是因?yàn)?UIKit 的方法不是線程安全的,保證線程安全需要極大的開(kāi)銷。那么問(wèn)題來(lái)了,在主線程中進(jìn)行 UI 操作一定是安全的么?
顯然,答案是否定的!
在蘋果的 MapKit 框架中,有一個(gè)叫做 addOverlay 的方法,它在底層實(shí)現(xiàn)的時(shí)候,不僅僅要求代碼執(zhí)行在主線程上,還要求執(zhí)行在 GCD 的主隊(duì)列上。這是一個(gè)極罕見(jiàn)的問(wèn)題,但已經(jīng)有人在使用 ReactiveCocoa 時(shí)踩到了坑,并提交了 issue。
蘋果的 Developer Technology Support 承認(rèn)這是一個(gè) bug。不管這是 bug 還是歷史遺留設(shè)計(jì),也不管是不是在鉆牛角尖,為了避免再次掉進(jìn)同樣的坑,我認(rèn)為都有必要分析一下問(wèn)題發(fā)生的原因和解決方案。
GCD 知識(shí)復(fù)習(xí)
在 GCD 中,使用 dispatch_get_main_queue() 函數(shù)可以獲取主隊(duì)列。調(diào)用 dispatch_sync() 方法會(huì)把任務(wù)同步提交到指定的隊(duì)列。
注意一下隊(duì)列和線程的區(qū)別,他們之間并沒(méi)有“擁有關(guān)系(ownership)”,當(dāng)我們同步的提交一個(gè)任務(wù)時(shí),首先會(huì)阻塞當(dāng)前隊(duì)列,然后等到下一次 runloop 時(shí)再在合適的線程中執(zhí)行 block。
在執(zhí)行 block 之前,首先會(huì)尋找合適的線程來(lái)執(zhí)行block,然后阻塞這個(gè)線程,直到 block 執(zhí)行完畢。尋找線程的規(guī)則是: 任何提交到主隊(duì)列的 block 都會(huì)在主線程中執(zhí)行,在不違背此規(guī)則的前提下,文檔還告訴我們系統(tǒng)會(huì)自動(dòng)進(jìn)行優(yōu)化,盡可能的在當(dāng)前線程執(zhí)行 block。
順便補(bǔ)充一句,GCD 死鎖的充分條件是:“向當(dāng)前隊(duì)列重復(fù)同步提交 block”。從原理來(lái)看,死鎖的原因是提交的 block 阻塞了隊(duì)列,而隊(duì)列阻塞后永遠(yuǎn)無(wú)法執(zhí)行完 dispatch_sync(),可見(jiàn)這里完全和代碼所在的線程無(wú)關(guān)。
另一個(gè)例子也可以證明這一點(diǎn),在主線程中向一個(gè)串行隊(duì)列同步的派發(fā) block,根據(jù)上文選擇線程的原則,block 將在主線程中執(zhí)行,但同樣不會(huì)導(dǎo)致死鎖:
- dispatch_queue_t queue = dispatch_queue_create("com.kt.deadlock", nil);
- dispatch_sync(queue, ^{
- NSLog(@"current thread = %@", [NSThread currentThread]);
- });
- // 輸出結(jié)果:
- // current thread = {number = 1, name = main}
原因分析
啰嗦了這么多,回到之前描述的 bug 中來(lái)。現(xiàn)在我們知道,即使是在主線程中執(zhí)行的代碼,也很可能不是運(yùn)行在主隊(duì)列中(反之則必然)。如果我們?cè)谧雨?duì)列中調(diào)用 MapKit 的 addOverlay 方法,即使當(dāng)前處于主線程,也會(huì)導(dǎo)致 bug 的產(chǎn)生,因?yàn)檫@個(gè)方法的底層實(shí)現(xiàn)判斷的是主隊(duì)列而非主線程。
更進(jìn)一步的思考,有時(shí)候?yàn)榱吮WC UI 操作在主線程運(yùn)行,如果有一個(gè)函數(shù)可以用來(lái)創(chuàng)建新的 UILabel,為了確保線程安全,代碼可能是這樣:
- - (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;
- }
從嚴(yán)格意義上來(lái)講,這樣的寫法不是 100% 安全的,因?yàn)槲覀儫o(wú)法得知相關(guān)的系統(tǒng)方法是否存在上述 Bug。
解決方案
由于提交到主隊(duì)列的 block 一定在主線程運(yùn)行,并且在 GCD 中線程切換通常都是由指定某個(gè)隊(duì)列引起的,我們可以做一個(gè)更加嚴(yán)格的判斷,即用判斷是否處于主隊(duì)列來(lái)代替是否處于主線程。
GCD 沒(méi)有提供 API 來(lái)進(jìn)行相應(yīng)的判斷,但我們可以另辟蹊徑,利用 dispatch_queue_set_specific 和 dispatch_get_specific 這一組方法為主隊(duì)列打上標(biāo)記:
- + (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] 即可獲得更好的安全性。
參考資料
1.Community bug reports about MapKit
2.GCD’s Main Queue vs Main Thread
3.ReactiveCocoa 中遇到類似的坑
4.Why can’t we use a dispatch_sync on the current queue?