前言
隨著手機硬體的升級,多執行緒技術在應用開發中的地位可以說足以媲美UITableView
了。然而,多執行緒技術在提供我們生產力的同時,也不可避免的帶來了陷阱,正如著名計算機學者所言:能力越大,bug越大
本文嘗試從多個角度聊聊這些陷阱。
記憶體佔用
執行緒的建立需要佔用一定的核心實體記憶體以及CPU
處理時間,具體消耗參見下表。
型別 | 消耗估算 | 詳情 |
---|---|---|
核心結構體 | 1KB | 儲存執行緒資料結構和屬性 |
棧空間 | 子執行緒(512KB) Mac主執行緒(8MB) iOS主執行緒(1MB) |
堆疊大小必須為4KB的倍數 子執行緒的最小記憶體為16KB |
建立時間 | 90微秒 | 1G記憶體 Intel 2GHz CPU Mac OS X v10.5 |
此外在CPU
上切換執行緒上下文的花銷也是不廉價的,這些花銷體現在切換執行緒上下文時更新暫存器、定址搜尋等。這兩種花銷在併發程式設計時,可能會出現非常明顯的效能下降。
共享資源
對於使用共享資源的陷阱主要發生在兩點:執行緒競爭以及鎖
- 執行緒競爭
多個執行緒同時對共有的資源進行寫操作時,會產生資料錯誤,這種錯誤難以被發現,可能會導致應用無法繼續正常執行。
12dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{for (int idx = 0; idx - 鎖的開銷
鎖是為了解決執行緒競爭錯誤設計的方案,提供了不同的方式保證多個執行緒對共享資源的訪問限制。iOS
提供了多種執行緒鎖供我們使用,具體的鎖在這裡就不再闡述。鎖的操作不當會導致死鎖出現,從而使得整個執行緒無法繼續執行。
123- (int)recursiveToCalculateSum: (int)number {[_lock lock];_sum += (number
執行緒死鎖
執行緒死鎖與鎖的死鎖是兩個概念,但其原因其實是一樣的。當我們同步派發任務到當前佇列執行的時候。佇列堵塞,等待派發任務的執行。但由於佇列堵塞,派發任務永遠無法執行,形成一個死迴圈。通過libdispatch的原始碼我們可以發現實際上sync
內部是個訊號加鎖操作,且sync
對於global_queue
和自定義佇列來說是直接執行,不會將任務壓入棧中。其程式碼可以表示為:
1 2 3 4 5 6 |
do_task_in_target_queue(target, ^{ shared = SEM_GET_SHARED(sem); sem_wait(shared); task(); sem_post(shared); }); |
事實上sync
操作是個無限等待的加鎖操作,所以當sync
到當前執行緒的時候引發的是死鎖問題。這也是為什麼執行緒死鎖實際上並非同步佇列的問題,只是一個簡單的死鎖。
執行緒保活
執行緒的釋放是個不容易被注重到的細節,我們都知道NSTimer
的準確度在很多時候不盡人意,為了提高精確度,很多人會在子執行緒啟動RunLoop
保活(全域性執行緒不存在釋放上的問題)。比如著名的AFNetworking
啟用了一個空的NSPort
埠保證回撥執行緒保活:
1 2 3 4 5 6 7 8 |
+ (void)networkRequestThreadEntryPoint:(id)__unused object { @autoreleasepool { [[NSThread currentThread] setName:@"AFNetworking"]; NSRunLoop *runLoop = [NSRunLoop currentRunLoop]; [runLoop addPort:[NSMachPort port] forMode:NSDefaultRunLoopMode]; [runLoop run]; } } |
在蘋果官方文件中,啟動RunLoop
的有三種方式:
1 2 3 |
- [NSRunLoop run]; - [NSRunLoop runUntilDate: [NSDate date]]; - [NSRunLoop runMode: NSRUnloopDefaultModes beforeDate: [NSDate date]]; |
除了後面兩者之外,第一種方式必須呼叫kill
的方式殺死它才能結束,這也是不當使用RunLoop
的陷阱之一。採用CFRunLoopRef
的相關方法完成啟動和停止是一種更好的做法。
1 2 |
CFRunLoopRun(); CFRunLoopStop(CFRunLoopGetCurrent()); |
佇列優先順序
更高優先順序的任務在能更好的搶佔CPU
資源,這導致了低優先順序方案在處理任務加鎖時可能導致被搶佔執行,從而導致鎖無法正常開啟,導致另一種特殊的死鎖。在不再安全的OSSpinLock中就提到了這一點。在GCD
中系統建立了四種常駐並行佇列,分別對應不同優先順序的任務處理:
1 2 3 4 |
#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
用來標誌執行緒優先順序。
1 2 3 4 5 6 7 8 9 10 11 12 |
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
操作並沒有執行完成,因此導致大量的執行緒無法釋放,佔據了大量的記憶體使用。
1 |
for (NSInteger idx = 0; idx |
一旦這時候磁碟響應,開始讀取資料,這些執行緒爭奪CPU
資源,佔用的記憶體足以讓開發者崩潰。解決方案之一是我在GCD封裝中封裝的序列佇列
執行方案,採用QoS
對執行緒進行優先順序設定,保證緊急任務優先得到處理。此外根據CPU
核心建立的等量序列可以保證CPU
核心得到最大利用化以及避免了併發佇列過度的執行緒建立。