對于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這樣的代碼! 共勉.