Cocoa深入學習:NSOperationQueue、NSRunLoop和執行緒安全

weixin_34234823發表於2017-12-13

原文連結:https://blog.cnbluebox.com/blog/2014/07/01/cocoashen-ru-xue-xi-nsoperationqueuehe-nsoperationyuan-li-he-shi-yong/

目前在 iOS 和 OS X 中有兩套先進的同步 API 可供我們使用:NSOperation 和 GCD 。其中 GCD 是基於 C 的底層的 API ,而 NSOperation 則是 GCD 實現的 Objective-C API。 雖然 NSOperation 是基於 GCD 實現的, 但是並不意味著它是一個 GCD 的 “dumbed-down” 版本, 相反,我們可以用NSOperation 輕易的實現一些 GCD 要寫大量程式碼的事情。 因此, NSOperationQueue 是被推薦使用的, 除非你遇到了 NSOperationQueue 不能實現的問題。

  1. 為什麼優先使用NSOperationQueue而不是GCD
    曾經我有一段時間我非常喜歡使用GCD來進行併發程式設計,因為雖然它是C的api,但是使用起來卻非常簡單和方便, 不過這樣也就容易使開發者忘記併發程式設計中的許多注意事項和陷阱。
    比如你可能寫過類似這樣的程式碼(這樣來請求網路資料):

dispatch_async(_Queue, ^{
//請求資料
NSData *data = [NSData dataWithContentURL:[NSURL URLWithString:@"http://domain.com/a.png"]];
dispatch_async(dispatch_get_main_queue(), ^{
[self refreshViews:data];
});
});

沒錯,它是可以正常的工作,但是有個致命的問題:這個任務是無法取消的 dataWithContentURL:是同步的拉取資料,它會一直阻塞執行緒直到完成請求,如果是遇到了超時的情況,它在這個時間內會一直佔有這個執行緒;在這個期間併發佇列就需要為其他任務新建執行緒,這樣可能導致效能下降等問題。
因此我們不推薦這種寫法來從網路拉取資料。
操作佇列(operation queue)是由 GCD 提供的一個佇列模型的 Cocoa 抽象。GCD 提供了更加底層的控制,而操作佇列則在 GCD 之上實現了一些方便的功能,這些功能對於 app 的開發者來說通常是最好最安全的選擇。NSOperationQueue相對於GCD來說有以下優點:
提供了在 GCD 中不那麼容易複製的有用特性。
可以很方便的取消一個NSOperation的執行
可以更容易的新增任務的依賴關係
提供了任務的狀態:isExecuteing, isFinished.
名詞: 本文中提到的 “任務”, “操作” 即代表要再NSOperation中執行的事情。

  1. Operation Queues的使用
    2.1 NSOperationQueue
    NSOperationQueue 有兩種不同型別的佇列:主佇列和自定義佇列。主佇列執行在主執行緒之上,而自定義佇列在後臺執行。在兩種型別中,這些佇列所處理的任務都使用 NSOperation 的子類來表述。

NSOperationQueue *mainQueue = [NSOperationQueue mainQueue]; //主佇列
NSOperationQueue *queue = [[NSOperationQueue alloc] init]; //自定義佇列
NSBlockOperation *operation = [NSBlockOperation blockOperationWithBlock:^{
//任務執行
}];
[queue addOperation:operation];
我們可以通過設定 maxConcurrentOperationCount 屬性來控制併發任務的數量,當設定為 1 時, 那麼它就是一個序列佇列。主對列預設是序列佇列,這一點和 dispatch_queue_t 是相似的。
2.2 NSOperation
你可以使用系統提供的一些現成的 NSOperation 的子類, 如 NSBlockOperation、 NSInvocationOperation 等(如上例子)。你也可以實現自己的子類, 通過重寫 main 或者 start 方法 來定義自己的 operations 。
使用 main 方法非常簡單,開發者不需要管理一些狀態屬性(例如 isExecuting 和 isFinished),當 main 方法返回的時候,這個 operation 就結束了。這種方式使用起來非常簡單,但是靈活性相對重寫 start 來說要少一些, 因為main方法執行完就認為operation結束了,所以一般可以用來執行同步任務。

@implementation YourOperation

  • (void)main
    {
    // 任務程式碼 ...
    }
    @end
    如果你希望擁有更多的控制權,或者想在一個操作中可以執行非同步任務,那麼就重寫 start 方法, 但是注意:這種情況下,你必須手動管理操作的狀態, 只有當傳送 isFinished 的 KVO 訊息時,才認為是 operation 結束

@implementation YourOperation

  • (void)start
    {
    self.isExecuting = YES;
    // 任務程式碼 ...
    }
  • (void)finish //非同步回撥
    {
    self.isExecuting = NO;
    self.isFinished = YES;
    }
    @end
    當實現了start方法時,預設會執行start方法,而不執行main方法
    為了讓操作佇列能夠捕獲到操作的改變,需要將狀態的屬性以配合 KVO 的方式進行實現。如果你不使用它們預設的 setter 來進行設定的話,你就需要在合適的時候傳送合適的 KVO 訊息。
    需要手動管理的狀態有:
    isExecuting 代表任務正在執行中
    isFinished 代表任務已經執行完成
    isCancelled 代表任務已經取消執行
    手動的傳送 KVO 訊息, 通知狀態更改如下 :

[self willChangeValueForKey:@"isCancelled"];
_isCancelled = YES;
[self didChangeValueForKey:@"isCancelled"];
為了能使用操作佇列所提供的取消功能,你需要在長時間操作中時不時地檢查 isCancelled 屬性, 比如在一個長的迴圈中:

@implementation MyOperation

  • (void)main
    {
    while (notDone && !self.isCancelled) {
    // 任務處理
    }
    }
    @end
  1. RunLoop
    在cocoa中講到多執行緒,那麼就不得不講到RunLoop。 在ios/mac的編碼中,我們似乎不需要過多關心程式碼是如何執行的,一切彷彿那麼自然。比如我們知道當滑動手勢時,tableView就會滾動,啟動一個NSTimer之後,timer的方法就會定時執行, 但是為什麼呢,其實是RunLoop在幫我們做這些事情:分發訊息。
    3.1 什麼是RunLoop
    你應該看過這樣的虛擬碼解釋ios的app中main函式做的事情:

int main(int argc, char * argv[])
{
while (true) {
[[NSRunLoop currentRunLoop] runMode:NSDefaultRunLoopMode beforeDate:[NSDate distantFuture]];
}
}
也應該看過這樣的程式碼用來阻塞一個執行緒:

while (!complete) {
[[NSRunLoop currentRunLoop] runMode:NSDefaultRunLoopMode beforeDate:[NSDate distantFuture]];
}
或許你感覺到他們有些神奇,希望我的解釋能讓你明白一些.
我們先思考一個問題: 當我們開啟一個IOS應用之後,什麼也不做,這時候看起來是沒有程式碼在執行的,為什麼應用沒有退出呢?
我們在寫c的簡單的只有一個main函式的程式時就知道,當main的程式碼執行完,沒有事情可做的時候,程式就執行完畢退出了。而我們IOS的應用是如何做到在沒有事情做的時候維持應用的執行的呢? 那就是RunLoop。
RunLoop的字面意思就是“執行迴路”,聽起來像是一個迴圈。實際它就是一個迴圈,它在迴圈監聽著事件源,把訊息分發給執行緒來執行。RunLoop並不是執行緒,也不是併發機制,但是它線上程中的作用至關重要,它提供了一種非同步執行程式碼的機制。
3.2 事件源
runloop
由圖中可以看出NSRunLoop只處理兩種源:輸入源、時間源。而輸入源又可以分為:NSPort、自定義源、performSelector:OnThread:delay:, 下面簡單介紹下這幾種源:
3.2.1 NSPort 基於埠的源
Cocoa和 Core Foundation 為使用埠相關的物件和函式建立的基於埠的源提供了內在支援。Cocoa中你從不需要直接建立輸入源。你只需要簡單的建立埠物件,並使用NSPort的方法將埠物件加入到run loop。埠物件會處理建立以及配置輸入源。
NSPort一般分三種: NSMessagePort(基本廢棄)、NSMachPort、 NSSocketPort。 系統中的NSURLConnection就是基於NSSocketPort進行通訊的,所以當在後臺執行緒中使用NSURLConnection 時,需要手動啟動RunLoop, 因為後臺執行緒中的RunLoop預設是沒有啟動的,後面會講到。
3.2.2 自定義輸入源
在Core Foundation程式中,必須使用CFRunLoopSourceRef型別相關的函式來建立自定義輸入源,接著使用回撥函式來配置輸入源。Core Fundation會在恰當的時候呼叫回撥函式,處理輸入事件以及清理源。常見的觸控、滾動事件等就是該類源,由系統內部實現。
一般我們不會使用該種源,第三種情況已經滿足我們的需求
3.2.3 performSelector:OnThread
Cocoa提供了可以在任一執行緒執行函式(perform selector)的輸入源。和基於埠的源一樣,perform selector請求會在目標執行緒上序列化,減緩許多在單個執行緒上容易引起的同步問題。而和基於埠的源不同的是,perform selector執行完後會自動清除出run loop。
此方法簡單實用,使用也更廣泛。
3.2.4 定時源
定時源就是NSTimer了,定時源在預設的時間點同步地傳遞訊息。因為Timer是基於RunLoop的,也就決定了它不是實時的。
3.3 RunLoop觀察者
我們可以通過建立CFRunLoopObserverRef物件來檢測RunLoop的工作狀態,它可以檢測RunLoop的以下幾種事件:
Run loop入口
Run loop將要開始定時
Run loop將要處理輸入源
Run loop將要休眠
Run loop被喚醒但又在執行喚醒事件前
Run loop終止
3.4 Run Loop Modes
RunLoop對於上述四種事件源的監視,可以通過設定模式來決定監視哪些源。 RunLoop只會處理與當前模式相關聯的源,未與當前模式關聯的源則處於暫停狀態。
cocoa和Core Foundation預先定義了一些模式(Apple文件翻譯):
Mode Name Description
Default NSDefaultRunLoopMode (Cocoa) kCFRunLoopDefaultMode (Core Foundation) 預設情況下,將包含所有操作,並且大多數情況下都會使用此模式
Connection NSConnectionReplyMode (Cocoa) 此模式用於處理NSConnection的回撥事件
Modal NSModalPanelRunLoopMode (Cocoa) 模態模式,此模式下,RunLoop只對處理模態相關事件
Event Tracking NSEventTrackingRunLoopMode (Cocoa) 此模式下用於處理視窗事件,滑鼠事件等
Common Modes NSRunLoopCommonModes (Cocoa) kCFRunLoopCommonModes (Core Foundation) 此模式用於配置”組模式”,一個輸入源與此模式關聯,則輸入源與組中的所有模式相關聯。
我們也可以自定義模式,可以參考ASIHttpRequest在同步執行時,自定義了 runLoop 的模式叫 ASIHTTPRequestRunLoopMode。ASI的Timer源就關聯了此模式。
3.5 常見問題一:為什麼TableView滑動時,Timer暫停了?
我們做個測試: 在一個 viewController 的 scrollViewWillBeginDecelerating: 方法裡面打個斷點, 然後滑動 tableView。 待斷點處, 使用 lldb 列印一下 [NSRunLoop currentRunLoop] 。 在描述中可以看到當前的RunLoop的執行模式:
current mode = UITrackingRunLoopMode
common modes = <CFBasicHash 0x14656e60 [0x3944dae0]>{type = mutable set, count = 2,
entries =>
0 : <CFString 0x398d54c0 [0x3944dae0]>{contents = "UITrackingRunLoopMode"}
1 : <CFString 0x39449d10 [0x3944dae0]>{contents = "kCFRunLoopDefaultMode"}
}
也就是說,當前主執行緒的 RunLoop 正在以 UITrackingRunLoopMode 的模式執行。 這個時候 RunLoop 只會處理與 UITrackingRunLoopMode “繫結”的源, 比如觸控、滾動等事件;而 NSTimer 是預設“繫結”到 NSRunLoopDefaultMode 上的, 所以 Timer 是事情是不會被 RunLoop 處理的,我們的看到的時定時器被暫停了!
常見的解決方案是把Timer“繫結”到 NSRunLoopCommonModes 模式上, 那麼Timer就可以與:
1
[[NSRunLoop currentRunLoop] addTimer:timer forMode:NSRunLoopCommonModes];
這樣這個Timer就可以和當前組中的兩種模式 UITrackingRunLoopMode 和 kCFRunLoopDefaultMode 相關聯了。 RunLoop在這兩種模式下,Timer都可以正常執行了。
注意: 由上面可以發現 NSTimer 是不準確的。 因為RunLoop只負責分發源的訊息。如果執行緒當前正在處理繁重的任務,比如迴圈,就有可能導致Timer本次延時,或者少執行一次。網上有人做過實驗:
runloop_timer
上面的Log是一個間隔為 1 s 的計時器,我們可以發現在 12.836s ~ 15.835s 之間的時間段內, 明顯的 13s 的方法沒有執行。 14s 的方法有所延遲。
因此當我們用NSTimer來完成一些計時任務時,如果需要比較精確的話,最好還是要比較“時間戳”。
3.6 常見問題二:後臺的NSURLConnection不回撥,Timer不執行
我們知道每個執行緒都有它的RunLoop, 我們可以通過 [NSRunLoop currentRunLoop] 或 CFRunLoopGetCurrent() 來獲取。 但是主執行緒和後臺執行緒是不一樣的。主執行緒的RunLoop是一直在啟動的。而後臺執行緒的RunLoop是預設沒有啟動的。
後臺執行緒的RunLoop沒有啟動的情況下的現象就是:“程式碼執行完,執行緒就結束被回收了”。就像我們簡單的程式執行完就退出了。 所以如果我們希望在程式碼執行完成後還要保留執行緒等待一些非同步的事件時,比如NSURLConnection和NSTimer, 就需要手動啟動後臺執行緒的RunLoop。
啟動RunLoop,我們需要設定RunLoop的模式,我們可以設定 NSDefaultRunLoopMode。 那預設就是監聽所有時間源:

//Cocoa
[[NSRunLoop currentRunLoop] runMode:NSDefaultRunLoopMode beforeDate:[NSDate distantFuture]];

//Core Foundation
CFRunLoopRun();
我們也可以設定其他模式執行, 甚至自定義執行Mode,但是我們就需要把“事件源” “繫結”到該模式上:

extern NSString *kMyCustomRunLoopMode;
//NSURLConnection
[_connection scheduleInRunLoop:[NSRunLoop currentRunLoop] forMode:kMyCustomRunLoopMode];
[[NSRunLoop currentRunLoop] runMode:kMyCustomRunLoopMode beforeDate:[NSDate distantFuture]];

//Timer
[[NSRunLoop currentRunLoop] addTimer:_timer forMode:kMyCustomRunLoopMode];
[[NSRunLoop currentRunLoop] runMode:kMyCustomRunLoopMode beforeDate:[NSDate distantFuture]];

3.7 NSCommonRunLoopModes
這一節是2016-3-24補充
因為以前的3.6的程式碼有個錯誤,而且對於NSRunLoopCommonModes沒有詳細說明,造成有同學對這塊產生困惑, 因此有必要再開一節補充下關於NSRunLoopCommonModes的概念。
NSRunLoopCommonModes 並不是一個真正的runLoopMode, 也就是說這樣的寫法是錯誤的:
1
[[NSRunLoop currentRunLoop] runMode:NSRunLoopCommonModes beforeDate:[NSDate distantFuture]]; //wrong
這個寫法並不會讓runLoop執行。
下面解釋下什麼是CommonModes。

struct __CFRunLoop {
CFMutableSetRef _commonModes; // 有哪些Mode被標記為Common
CFMutableSetRef _commonModeItems; // 這裡面就是RunLoop的源,observer,Timer等
CFRunLoopModeRef _currentMode; // 當前執行的Mode
...
};
上面大概是CFRunLoop中有關CommonMode的結構。這裡面2個概念解釋一下:
在RunLoop裡可以用CFRunLoopAddCommonMode將一個Mode標記為Common屬性,那麼這個Mode就會存在_commonModes裡面。主執行緒預設的kCFRunLoopDefaultMode 和 UITrackingRunLoopMode 都已經是CommonModes了,不需要再標記。
_commonModeItems裡面存放的源, observer, timer等,在每次runLoop執行的時候都會被同步到具有Common標記的Modes裡。因此只要_currentMode是一個Common的Mode, 那麼_commonModeItems裡面的源,observer,timer也會執行。
因此這樣addTimer時,timer也會執行的, 因為timer被新增到了_commonModeItems裡面。

[[NSRunLoop currentRunLoop] addTimer:_timer forMode:NSRunLoopCommonModes];
[[NSRunLoop currentRunLoop] runMode:NSRunLoopDefaultMode beforeDate:[NSDate distantFuture]];
3.8 問題三:本節開頭的例子為何可以阻塞執行緒

while (!complete) {
[[NSRunLoop currentRunLoop] runMode:NSDefaultRunLoopMode beforeDate:[NSDate distantFuture]];
}
你應該知道這樣一段程式碼可以阻塞當前執行緒,你可能會奇怪:RunLoop就是不停迴圈來檢測源的事件,為什麼還要加個 while 呢?
這是因為RunLoop的特性,RunLoop會在沒有“事件源”可監聽時休眠。也就是說如果當前沒有合適的“源”被RunLoop監聽,那麼這步就跳過了,不能起到阻塞執行緒的作用,所以還是要加個while迴圈來維持。
同時注意:因為這段程式碼可以阻塞執行緒,所以請不要在主執行緒寫下這段程式碼,因為它很可能會導致介面卡住。

  1. 執行緒安全
    講了這麼多,你是否已經對併發程式設計已經躍躍欲試了呢? 但是併發程式設計一直都不是一個輕鬆的事情,使用併發程式設計會帶來許多陷阱。哪怕你是一個很成熟的程式設計師和架構師,也很難避免執行緒安全的問題;使用的越多,出錯的可能就越大,因此可以不用多執行緒就不要使用。
    關於併發程式設計的不可預見性有一個非常有名的例子:在1995年, NASA (美國宇航局)傳送了開拓者號火星探測器,但是當探測器成功著陸在我們紅色的鄰居星球后不久,任務嘎然而止,火星探測器莫名其妙的不停重啟,在計算機領域內,遇到的這種現象被定為為優先順序反轉,也就是說低優先順序的執行緒一直阻塞著高優先順序的執行緒。在這裡我們想說明的是,即使擁有豐富的資源和大量優秀工程師的智慧,併發也還是會在不少情況下反咬你你一口。
    4.1 資源共享和資源飢餓
    併發程式設計中許多問題的根源就是在多執行緒中訪問共享資源。資源可以是一個屬性、一個物件,通用的記憶體、網路裝置或者一個檔案等等。在多執行緒中任何一個共享的資源都可能是一個潛在的衝突點,你必須精心設計以防止這種衝突的發生。
    一般我們通過鎖來解決資源共享的問題,也就是可以通過對資源加鎖保證同時只有一個執行緒訪問資源
    4.1.1 互斥鎖
    互斥訪問的意思就是同一時刻,只允許一個執行緒訪問某個特定資源。為了保證這一點,每個希望訪問共享資源的執行緒,首先需要獲得一個共享資源的互斥鎖。 對資源加鎖會引發一定的效能代價。
    4.1.2 原子性
    從語言層面來說,在 Objective-C 中將屬性以 atomic 的形式來宣告,就能支援互斥鎖了。事實上在預設情況下,屬性就是 atomic 的。將一個屬性宣告為 atomic 表示每次訪問該屬性都會進行隱式的加鎖和解鎖操作。雖然最把穩的做法就是將所有的屬性都宣告為 atomic,但是加解鎖這也會付出一定的代價。
    4.1.3 死鎖
    互斥鎖解決了競態條件的問題,但很不幸同時這也引入了一些其他問題,其中一個就是死鎖。當多個執行緒在相互等待著對方的結束時,就會發生死鎖,這時程式可能會被卡住。
    比如下面的程式碼:

dispatch_sync(_queue, ^{
dispatch_sync(_queue, ^{
//do something
});
})
再比如:

main() {
dispatch_sync(dispatch_get_main_queue(), ^{
//do something
});
}
上面兩個例子也可以說明 dispatch_sync 這個API是危險的,所以儘量不要用。
當你的程式碼有死鎖的可能時,它就會發生
4.1.4 資源飢餓
當你認為已經足夠了解併發程式設計面臨的問題時,又出現了一個新的問題。鎖定的共享資源會引起讀寫問題。大多數情況下,限制資源一次只能有一個執行緒進行讀取訪問其實是非常浪費的。因此,在資源上沒有寫入鎖的時候,持有一個讀取鎖是被允許的。這種情況下,如果一個持有讀取鎖的執行緒在等待獲取寫入鎖的時候,其他希望讀取資源的執行緒則因為無法獲得這個讀取鎖而導致資源飢餓的發生。
4.2 優先順序反轉
優先順序反轉是指程式在執行時低優先順序的任務阻塞了高優先順序的任務,有效的反轉了任務的優先順序。GCD提供了3種級別的優先順序佇列,分別是Default, High, Low。 高優先順序和低優先順序的任務之間共享資源時,就可能發生優先順序反轉。當低優先順序的任務獲得了共享資源的鎖時,該任務應該迅速完成,並釋放掉鎖,這樣高優先順序的任務就可以在沒有明顯延時的情況下繼續執行。然而高優先順序任務會在低優先順序的任務持有鎖的期間被阻塞。如果這時候有一箇中優先順序的任務(該任務不需要那個共享資源),那麼它就有可能會搶佔低優先順序任務而被執行,因為此時高優先順序任務是被阻塞的,所以中優先順序任務是目前所有可執行任務中優先順序最高的。此時,中優先順序任務就會阻塞著低優先順序任務,導致低優先順序任務不能釋放掉鎖,這也就會引起高優先順序任務一直在等待鎖的釋放。如下圖:

使用不同優先順序的多個佇列聽起來雖然不錯,但畢竟是紙上談兵。它將讓本來就複雜的並行程式設計變得更加複雜和不可預見。因此我們寫程式碼的時候最好只用Default優先順序的佇列,不要使用其他佇列來讓問題複雜化。
關於dispatch_queue的底層執行緒安全設計可參考:底層併發 API

  1. 總結
    本文主要講了 NSOperationQueue、 NSRunLoop、 和執行緒安全等三大塊內容。 希望可以幫助你理解 NSOperation的使用, NSRunLoop的作用, 還有併發程式設計帶來的複雜性和相關問題。
    併發實際上是一個非常棒的工具。它充分利用了現代多核 CPU 的強大計算能力。但是因為它的複雜性,所以我們儘量使用高階的API,儘量寫簡單的程式碼,讓併發模型保持簡單; 這樣可以寫出高效、結構清晰、且安全的程式碼。
    參考和引文
    1、https://objccn.io/issue-2-1/

相關文章