本文是投稿文章,作者:RyanJIN(簡書)
對於iOS的並發編程, 用的最普遍的就是GCD了, GCD結合Block可以so easy的實現多線程並發編程. 但如果你看一些諸如AFNetworking, SDWebImage的源碼, 你會發現它們使用的都是NSOperation, 納尼? 難道NSOperation這貨更屌? YES, 它確實更屌! Okay, 那我們就先來簡單PK下GCD和NSOperation(當然這裡也包括NSOperationQueue).
1). NSOperation是基於GCD之上的更高一層封裝, 擁有更多的API(e.g. suspend, resume, cancel等等).
2). 在NSOperationQueue中, 可以指定各個NSOperation之間的依賴關系.
3). 用KVO可以方便的監測NSOperation的狀態(isExecuted, isFinished, isCancelled).
4). 更高的可定制能力, 你可以繼承NSOperation實現可復用的邏輯模塊.
Soga, 原來NSOperation這麼拽! Apple官方文檔和網絡上有很多NSOperation的資料, 但大部分都是很書面化的解釋(臣妾看不懂啊%>_<%), 看著看著就雲深不知處了. 所以這篇文章我會以灰常通俗的方式來解釋NSOperation的並發編程. Okay, let's go!
並發編程的幾個概念
並發編程簡單來說就是讓CPU在同一時間運行多個任務. 這裡面有幾個容易混淆的概念, 我們先來一個個的梳理下:
1). 串行(Serial) VS. 並行(Concurrent)
串行和並行描述的是任務和任務之間的執行方式. 串行是任務A執行完了任務B才能執行, 它們倆只能順序執行. 並行則是任務A和任務B可以同時執行.
2). 同步(Synchronous) VS. 異步(Asynchronous)
同步和異步描述的其實就是函數什麼時候返回. 比如用來下載圖片的函數A: {download image}, 同步函數只有在image下載結束之後才返回, 下載的這段時間函數A只能搬個小板凳在那兒坐等... 而異步函數, 立即返回. 圖片會去下載, 但函數A不會去等它完成. So, 異步函數不會堵塞當前線程去執行下一個函數!
3). 並發(Concurrency) VS. 並行(Parallelism)
這個更容易混淆了, 先用Ray大神的示意圖和說明來解釋一下: 並發是程序的屬性(property of the program), 而並行是計算機的屬性(property of the machine).
還是很抽象? 那我再來解釋一下, 並行和並發都是用來讓不同的任務可以"同時執行", 只是並行是偽同時, 而並發是真同時. 假設你有任務T1和任務T2(這裡的任務可以是進程也可以是線程):
a. 首先如果你的CPU是單核的, 為了實現"同時"執行T1和T2, 那只能分時執行, CPU執行一會兒T1後馬上再去執行T2, 切換的速度非常快(這裡的切換也是需要消耗資源的, context switch), 以至於你以為T1和T2是同時執行了(但其實同一時刻只有一個任務占有著CPU).
b. 如果你是多核CPU, 那麼恭喜你, 你可以真正同時執行T1和T2了, 在同一時刻CPU的核心core1執行著T1, 然後core2執行著T2, great!
其實我們平常說的並發編程包括狹義上的"並行"和"並發", 你不能保證你的代碼會被並行執行, 但你可以以並發的方式設計你的代碼. 系統會判斷在某一個時刻是否有可用的core(多核CPU核心), 如果有就並行(parallelism)執行, 否則就用context switch來分時並發(concurrency)執行. 最後再以Ray大神的話結尾: Parallelism requires Concurrency, but Concurrency does not guarantee Parallelism!
並發吧, NSOperation!
NSOperation可以自己獨立執行(直接調用[operation start]), 也可以放到NSOperationQueue裡面執行, 這兩種情況下是否並發執行是不同的. 我們先來看看NSOperation獨立執行的並發情況.
1. 獨立執行的NSOperation
NSOperation默認是非並發的(non-concurrent), 也就說如果你把operation放到某個線程執行, 它會一直block住該線程, 直到operation finished. 對於非並發的operation你只需要繼承NSOperation, 然後重寫main()方法就妥妥滴了, 比如我們用非並發的operation來實現一個下載需求:
@implementation YourOperation - (void)main { @autoreleasepool { if (self.isCancelled) return; NSData *imageData = [[NSData alloc] initWithContentsOfURL:imageURL]; if (self.isCancelled) { imageData = nil; return; } if (imageData) { UIImage *downloadedImage = [UIImage imageWithData:imageData]; } imageData = nil; if (self.isCancelled) return; [self.delegate performSelectorOnMainThread:@selector(imageDownloaderDidFinish:) withObject:downloadedImage waitUntilDone:NO]; } } @end
由於NSOperation是可以cancel的, 所以你需要在operation程序內部執行過程中判斷當前operation是否已經被cancel了(isCancelled). 如果已經被cancel那就不往下執行了. 當你在外面調用[operation cancel]後, isCancelled會被置為YES.
NSOperation有三個狀態量isCancelled, isExecuting和isFinished. isCancelled上面解釋過. main函數執行完成後, isExecuting會被置為NO, 而isFinished則被置為YES.
那腫麼實現並發(concurrent)的NSOperation呢? 也很簡單:
1). 重寫isConcurrent函數, 返回YES, 這個告訴系統各單位注意了我這個operation是要並發的.
2). 重寫start()函數.
3). 重寫isExecuting和isFinished函數
為什麼在並發情況下需要自己來設定isExecuting和isFinished這兩個狀態量呢? 因為在並發情況下系統不知道operation什麼時候finished, operation裡面的task一般來說是異步執行的, 也就是start函數返回了operation不一定就是finish了, 這個你自己來控制, 你什麼時候將isFinished置為YES(發送相應的KVO消息), operation就什麼時候完成了. Got it? Good.
還是上面那個下載的例子, 我們用並發的方式來實現:
- (BOOL)isConcurrent { return YES; } - (void)start { [self willChangeValueForKey:@"isExecuting"]; _isExecuting = YES; [self didChangeValueForKey:@"isExecuting"]; NSURLRequest * request = [NSURLRequest requestWithURL:imageURL]; _connection = [[NSURLConnection alloc] initWithRequest:request delegate:self]; if (_connection == nil) [self finish]; } - (void)finish { self.connection = nil; [self willChangeValueForKey:@"isExecuting"]; [self willChangeValueForKey:@"isFinished"]; _isExecuting = NO; _isFinished = YES; [self didChangeValueForKey:@"isExecuting"]; [self didChangeValueForKey:@"isFinished"]; } #pragma mark - NSURLConnection delegate - (void)connection:(NSURLConnection *)connection didReceiveResponse:(NSURLResponse *)response { // to do something... } - (void)connection:(NSURLConnection *)connection didReceiveData:(NSData *)data { // to do something... } - (void)connectionDidFinishLoading:(NSURLConnection *)connection { [self finish]; } - (void)connection:(NSURLConnection *)connection didFailWithError:(NSError *)error { [self finish]; } @end
Wow, 並行的operation好像有那麼點意思了. 這裡面還有幾點需要mark一下:
a). operation的executing和finished狀態量需要用willChangeValueForKey/didChangeValueForKey來觸發KVO消息.
b). 在調用完NSURLConnection之後start函數就返回了, 後面就坐等connection的回調了.
c). 在connection的didFinish或didFail回調裡面設置operation的finish狀態, 告訴系統operation執行完畢了.
如果你是在主線程調用的這個並發的operation, 那一切都是非常的perfect, 就算你當前在操作UI也不影響operation的下載操作. BUT, 如果你是在子線程調用的, 或者把operation加到了非main queue, 那麼問題來了, 你會發現這貨的NSURLConnection delegate不走了, what's going on here? 要解釋這個問題就要請出另外一個武林高手NSRunLoop, Okay, 下面進入NSRunLoop的show time.
Hey, NSRunLoop你是神馬東東?
關於NSRunLoop推薦看一下孫源@sunnnyxx的分享視頻. 其實從字面上就可以看出來, RunLoop就是跑圈, 保證程序一直在執行. App運行起來之後, 即使你什麼都不做, 放在那兒它也不會退出, 而是一直在"跑圈", 這就是RunLoop干的事. 主線程會自動創建一個RunLoop來保證程序一直運行. 但子線程默認不創建NSRunLoop, 所以子線程的任務一旦返回, 線程就over了.
上面的並發operation當start函數返回後子線程就退出了, 當NSURLConnection的delegate回調時, 線程已經木有了, 所以你也就收不到回調了. 為了保證子線程持續live(等待connection回調), 你需要在子線程中加入RunLoop, 來保證它不會被kill掉.
RunLoop在某一時刻只能在一種模式下運行, 更換模式時需要暫停當前的Loop, 然後重啟新的Loop. RunLoop主要有下面幾個模式:
NSDefalutRunLoopMode : 默認Mode, 通常主線程在這個模式下運行
UITrackingRunLoopMode : 滑動ScrollView是會切換到這個模式
NSRunLoopCommonModes: 包括上面兩個模式
這邊需要特別注意的是, 在滑動ScrollView的情況下, 系統會自動把RunLoop模式切換成UITrackingRunLoopMode來保證ScrollView的流暢性.
[NSTimer scheduledTimerWithTimeInterval:1.f target:self selector:@selector(timerAction:) userInfo:nil reports:YES];
當你在滑動ScrollView的時候上面的timer會失效, 原因是Timer是默認加在NSDefalutRunLoopMode上的, 而滑動ScrollView後系統把RunLoop切換為UITrackingRunLoopMode, 所以timer就不會執行了. 解決方法是把該Timer加到NSRunLoopCommonModes下, 這樣即使滑動ScrollView也不會影響timer了.
[[NSRunLoop currentRunLoop] addTimer:timer forMode:NSRunLoopCommonModes];
另外還有一個trick是當tableview的cell從網絡異步加載圖片, 加載完成後在主線程刷新顯示圖片, 這時滑動tableview會造成卡頓. 通常的思路是tableview滑動的時候延遲加載圖片, 等停止滑動時再顯示圖片. 這裡我們可以通過RunLoop來實現.
[self.cellImageView performSelector:@sector(setImage:) withObject:downloadedImage afterDelay:0 inModes:@[NSDefaultRunLoopMode]];
當NSRunLoop為NSDefaultRunLoopMode的時候tableview肯定停止滑動了, why? 因為如果還在滑動中, RunLoop的mode應該是UITrackingRunLoopMode.
好了, 既然我們已經了解RunLoop的東東了, 我們可以回過頭來解決上面子線程並發NSOperation下NSURLConnection的Delegate不走的問題, 各位童鞋且繼續往下看^_^
呼叫NSURLConnection的異步回調
現在解決方案已經很清晰了, 就是利用RunLoop來監督線程, 讓它一直等待delegate的回調. 上面已經說到Main Thread是默認創建了一個RunLoop的, 所以我們的Option 1是讓start函數在主線程運行(即使[operation start]是在子線程調用的).
- (void)start { if (![NSThread isMainThread]) { [self performSelectorOnMainThread:@selector(start) withObject:nil waitUntilDone:NO]; return; } // set up NSURLConnection... }
或者這樣:
- (void)start { [[NSOperationQueue mainQueue] addOperationWithBlock:^{ self.connection = [NSURLConnection connectionWithRequest:self.request delegate:self]; }]; }
這樣我們可以簡單直接的使用main run loop, 因為數據delivery是非常快滴. 然後我們就可以將處理incoming data的操作放到子線程去...
Option 2是讓operation的start函數在子線程運行, 但是我們為它創建一個RunLoop. 然後把URL connection schedule到上面去. 我們先來瞅瞅AFNetworking是怎麼做滴:
+ (void)networkRequestThreadEntryPoint:(id)__unused object { @autoreleasepool { [[NSThread currentThread] setName:@"AFNetworking"]; NSRunLoop *runLoop = [NSRunLoop currentRunLoop]; [runLoop addPort:[NSMachPort port] forMode:NSDefaultRunLoopMode]; [runLoop run]; } } + (NSThread *)networkRequestThread { static NSThread *_networkRequestThread = nil; static dispatch_once_t oncePredicate; dispatch_once(&oncePredicate, ^{ _networkRequestThread = [[NSThread alloc] initWithTarget:self selector:@selector(networkRequestThreadEntryPoint:) object:nil]; [_networkRequestThread start]; }); return _networkRequestThread; } - (void)start { [self.lock lock]; if ([self isCancelled]) { [self performSelector:@selector(cancelConnection) onThread:[[self class] networkRequestThread] withObject:nil waitUntilDone:NO modes:[self.runLoopModes allObjects]]; } else if ([self isReady]) { self.state = AFOperationExecutingState; [self performSelector:@selector(operationDidStart) onThread:[[self class] networkRequestThread] withObject:nil waitUntilDone:NO modes:[self.runLoopModes allObjects]]; } [self.lock unlock]; }
AFNetworking創建了一個新的子線程(在子線程中調用NSRunLoop *runloop = [NSRunLoop currentRunLoop]; 獲取RunLoop對象的時候, 就會創建RunLoop), 然後把它加到RunLoop裡面來保證它一直運行.
這邊我們可以簡單的判斷下當前start()的線程是子線程還是主線程, 如果是子線程則調用[NSRunLoop currentRunLoop]創新RunLoop, 否則就直接調用[NSRunLoop mainRunLoop], 當然在主線程下就沒必要調用[runLoop run]了, 因為它本來就是一直run的.
P.S. 我們還可以使用CFRunLoop來啟動和停止RunLoop, 像下面這樣:
[self.connection scheduleInRunLoop:[NSRunLoop currentRunLoop] forMode:NSRunLoopCommonModes]; CFRunLoopRun();
等到該Operation結束的時候, 一定要記得調用CFRunLoopStop()停止當前線程的RunLoop, 讓當前線程在operation finished之後可以退出.
2. NSOperationQueue裡面執行NSOperation
NSOpertion可以add到NSOperationQueue裡面讓Queue來觸發其執行, 一旦NSOperation被add到Queue裡面那麼我們就不care它自身是不是並發設計的了, 因為被add到Queue裡面的operation必定是並發的. 而且我們可以設置Queue的maxConcurrentOperationCount來指定最大的並發數(也就是幾個operation可以同時被執行, 如果這個值設為1, 那這個Queue就是串行隊列了).
為嘛添加到Queue裡面的operation一定會是並發執行的呢? Queue會為每一個add到隊列裡面的operation創建一個線程來運行其start函數, 這樣每個start都分布在不同的線程裡面來實現operation們的並發執行.
重要的事情再強調一遍: 我們這邊所說的並發都是指NSOperation之間的並發(多個operation同時執行), 如果maxConcurrentOperationCount設置為1或者把operation放到[NSOperationQueue mainQueue]裡面執行, 那它們只會順序(Serial)執行, 當然就不可能並發了.
[NSOperationQueue mainQueue]返回的主隊列, 這個隊列裡面任務都是在主線程執行的(當然如果你像AFNetworking一樣在start函數創建子線程了, 那就不是在主線程執行了), 而且它會忽略一切設置讓你的任務順序的非並發的執行, 所以如果你把NSOperation放到mainQueue裡面了, 那你就放棄吧, 不管你怎麼折騰, 它是絕對不會並發滴. 當然, 如果是[[NSOperationQueue alloc] init]那就是子隊列(子線程)了.
那...那不對呀, 如果我在子線程調用[operation start]函數, 或者把operation放到非MainQueue裡面執行, 但是在operation的內部把start拋到主線程來執行(利用主線程的main run loop), 那多個operation其實不都是要在主線程執行的麼, 這樣還能並發? Luckily, 仍然是並發執行的(其實我想說的是那必須能並發啊...哈哈).
我們可以先來看看單線程和多線程下的各個任務(task)的並發執行示意圖:
Yes! 和上面討論狹義並發(Concurency)和並行(Parallelism)概念時的理解是一樣的, 在單線程情況下(也就是mainQueue的主線程), 各個任務(在我們這裡就是一個個的NSOperation)可以通過分時來實現偽並行(Parallelism)執行.
而在多線程情況下, 多個線程同時執行不同的任務(各個任務也會不停的切換線程)實現task的並發執行.
另外, 我們在往Queue裡面添加operation的時候可以指定它們的依賴關系, 比如[operationB addDependency:operationA], 那麼operationB會在operationA執行完畢之後才會執行. 還記得這邊"執行完畢(isFinished)"的概念嗎? 在並發情況下這個狀態量是由你自己設定的, 比如operationA是用來異步下載一張圖片, 那麼只有圖片下載完成之後或者超過timeout下載失敗之後, isFinished狀態量被標記為YES, 這時Queue才會從隊列裡面移除operationA, 並啟動operationB. 是不是很cool? O(∩_∩)O~~
NSOperation實驗課
下面我們進入實驗課啦, 要想真正了解某個東東, 還是需要打開Xcode, 寫上幾行代碼, 然後Commard+R. 為了幫Apple提升Xcode的使用率:-D, 我會給出幾個case, 童鞋們可以自己編寫test code來驗證:
1). 創建兩個operation, 然後直接[operation start], 在NSOperation並發設計和非並發設計的情況下, 查看這兩個operation是否同時執行了(最簡單的打log看是不是交替打印).
2). 在主線程和子線程下分別調用[operation start], 看看執行情況.
3). 創建operation並放到NSOperationQueue裡面執行, 分別看看mainQueue和非mainQueue下的執行情況.
4). maxConcurrentOperationCount設置後的執行情況.
5). 試試NSOperation的依賴關系設置, [operationB addDependency:operationA].
6). 寫個完整的demo吧, 比如簡單的HTTP Downloader.
最後送上干貨Demo, RJHTTPDownloader, 用NSOperation實現的一個下載類. 有的童鞋肯定會說用AFNetwroking就可以了, 為嘛要自己去寫呢? 這個嘛, 偶是覺得別人的代碼再怎麼看和用都不是你的, 自己動手寫的才真正belongs to you! 而且這也不算是重復造輪子, 只是學習輪子是怎麼構造的, 這樣一步一步的慢慢積累, 總有一天我們也能寫出像AFNetworking這樣的代碼! 共勉.