前言
隨著手機硬件的升級,多線程技術在應用開發中的地位可以說足以媲美UITableView了。然而,多線程技術在提供我們生產力的同時,也不可避免的帶來了陷阱,正如著名計算機學者所言:能力越大,bug越大。
本文嘗試從多個角度聊聊這些陷阱。
內存占用
線程的創建需要占用一定的內核物理內存以及CPU處理時間,具體消耗參見下表。
此外在CPU上切換線程上下文的花銷也是不廉價的,這些花銷體現在切換線程上下文時更新寄存器、尋址搜索等。這兩種花銷在並發編程時,可能會出現非常明顯的性能下降。
共享資源
對於使用共享資源的陷阱主要發生在兩點:線程競爭以及鎖
線程競爭
多個線程同時對共有的資源進行寫操作時,會產生數據錯誤,這種錯誤難以被發現,可能會導致應用無法繼續正常運行。
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ for (int idx = 0; idx < 100; idx++) { _flag--; } }); dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_HIGH, 0), ^{ for (int idx = 0; idx < 100; idx++) { _flag++; } });
鎖的開銷
鎖是為了解決線程競爭錯誤設計的方案,提供了不同的方式保證多個線程對共享資源的訪問限制。iOS提供了多種線程鎖供我們使用,具體的鎖在這裡就不再闡述。鎖的操作不當會導致死鎖出現,從而使得整個線程無法繼續執行。
- (int)recursiveToCalculateSum: (int)number { [_lock lock]; _sum += (number <= 1 ? 1 : [self recursiveToCalculateSum: number - 1]); [_lock unlock]; }
線程死鎖
線程死鎖與鎖的死鎖是兩個概念,但其原因其實是一樣的。當我們同步派發任務到當前隊列執行的時候。隊列堵塞,等待派發任務的執行。但由於隊列堵塞,派發任務永遠無法執行,形成一個死循環。通過libdispatch的源碼我們可以發現實際上sync內部是個信號加鎖操作,且sync對於global_queue和自定義隊列來說是直接執行,不會將任務壓入棧中。其代碼可以表示為:
do_task_in_target_queue(target, ^{ shared = SEM_GET_SHARED(sem); sem_wait(shared); task(); sem_post(shared); });
事實上sync操作是個無限等待的加鎖操作,所以當sync到當前線程的時候引發的是死鎖問題。這也是為什麼線程死鎖實際上並非同步隊列的問題,只是一個簡單的死鎖。
線程保活
線程的釋放是個不容易被注重到的細節,我們都知道NSTimer的准確度在很多時候不盡人意,為了提高精確度,很多人會在子線程啟動RunLoop保活(全局線程不存在釋放上的問題)。比如著名的AFNetworking啟用了一個空的NSPort端口保證回調線程保活:
+ (void)networkRequestThreadEntryPoint:(id)__unused object { @autoreleasepool { [[NSThread currentThread] setName:@"AFNetworking"]; NSRunLoop *runLoop = [NSRunLoop currentRunLoop]; [runLoop addPort:[NSMachPort port] forMode:NSDefaultRunLoopMode]; [runLoop run]; } }
在蘋果官方文檔中,啟動RunLoop的有三種方式:
- [NSRunLoop run]; - [NSRunLoop runUntilDate: [NSDate date]]; - [NSRunLoop runMode: NSRUnloopDefaultModes beforeDate: [NSDate date]];
除了後面兩者之外,第一種方式必須調用kill的方式殺死它才能結束,這也是不當使用RunLoop的陷阱之一。采用CFRunLoopRef的相關方法完成啟動和停止是一種更好的做法。
CFRunLoopRun(); CFRunLoopStop(CFRunLoopGetCurrent());
隊列優先級
更高優先級的任務在能更好的搶占CPU資源,這導致了低優先級方案在處理任務加鎖時可能導致被搶占執行,從而導致鎖無法正常打開,導致另一種特殊的死鎖。在不再安全的OSSpinLock中就提到了這一點。在GCD中系統創建了四種常駐並行隊列,分別對應不同優先級的任務處理:
#define DISPATCH_QUEUE_PRIORITY_HIGH 2 #define DISPATCH_QUEUE_PRIORITY_DEFAULT 0 #define DISPATCH_QUEUE_PRIORITY_LOW (-2) #define DISPATCH_QUEUE_PRIORITY_BACKGROUND INT16_MIN
如果按照從低到高的順序向這四個隊列裡面派發大量的日志輸出任務,可以看到在運行沒有多久的時間後,DISPATCH_QUEUE_PRIORITY_HIGH的任務會比提前於調用次序運行,而DISPATCH_QUEUE_PRIORITY_BACKGROUND總是接近最後執行完成的,這種資源搶占被稱作優先級反轉。
另一個問題是Custom Queue的線程優先級總是為DISPATCH_QUEUE_PRIORITY_DEFAULT,這意味著在某些時刻可能我們在創建的串行隊列上執行的任務也不一定是安全的。iOS8之後為自定義線程提供了QualityOfServer用來標志線程優先級。
typedef NS_ENUM(NSInteger, NSQualityOfService) { NSQualityOfServiceUserInteractive = 0x21, NSQualityOfServiceUserInitiated = 0x19, NSQualityOfServiceDefault = -1 NSQualityOfServiceUtility = 0x11, NSQualityOfServiceBackground = 0x09, } LXD_INLINE dispatch_queue_attr_t __LXDQoSToQueueAttributes(LXDQualityOfService qos) { dispatch_qos_class_t qosClass = __LXDQualityOfServiceToQOSClass(qos); return dispatch_queue_attr_make_with_qos_class(DISPATCH_QUEUE_SERIAL, qosClass, 0); };
這意味著開發者對於多線程通過使用搭配不同優先級的自定義串行隊列來更靈活的完成任務。
並發噩夢
系統本身提供了四種優先級的並行隊列給開發者使用,這意味著當我們async任務到這些全局線程中執行的時候,為了充分的發揮CPU的執行效率,GCD可能會多次創建線程來執行新的任務。
方便意味著隱藏的代價。試想一下這個場景,當前CPU核心正在執行一個IO操作,然後進入等待磁盤響應的狀態。在這個時間點上,CPU核心是處在未利用的狀態下的。這時候GCD一看:丫的偷懶?然後創建一個新的線程執行任務。假如派發的任務總是耗時的,且需要等待響應。那麼GCD會不斷的創建新的線程來充分利用CPU。當線程創建的足夠多的時候,GCD會嘗試釋放線程來減少壓力。但是由於線程中的IO操作並沒有執行完成,因此導致大量的線程無法釋放,占據了大量的內存使用。
for (NSInteger idx = 0; idx < N; idx++) { dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ NSString * filePath = [self filePathWithFileName: fileName]; NSData * data = [NSData dataWithContentsOfFile: filePath]; /// do something }); }
一旦這時候磁盤響應,開始讀取數據,這些線程爭奪CPU資源,占用的內存足以讓開發者崩潰。解決方案之一是我在GCD封裝中封裝的串行隊列執行方案,采用QoS對線程進行優先級設定,保證緊急任務優先得到處理。此外根據CPU核心創建的等量串行可以保證CPU核心得到最大利用化以及避免了並發隊列過度的線程創建。