もう怖くないCocoaの並列処理(GCD & NSOperation/NSOperationQueue)

Cocoaの並列処理(GCDとNSOperation/NSOperationQueue)に関するまとめです。この記事ではいわゆる「スレッド」については扱いません。スレッドについては、前に記事を書いたのでそちらを参照してください。

スレッドによる並列処理からの脱却

スレッドは、歴史のある並列処理のための概念ですが、OSのカーネルの方にも密接に関わりがあるなど、抽象度は低めと言えます。

そこで、Cocoaでは、並列処理のための抽象度の高いインターフェースが提供されるようになりました。それがGCDとNSOperation/NSOperationQueueです。これらを使えば、スレッドを何個作成してタスクをどのように実行するか等の細かいことは、ランタイム側が全て決定してくれるので、ユーザは、並列処理したいタスクを定義し、その実行をシステムに委ねるだけで良くなります。また、直列のキューを上手く利用すれば、ロックを必要とせず、排他制御デッドロックから開放されます。ただし、リアルタイム性が求められる場合は、スレッドを利用したほうが良いようです。

GCDとNSOperation/NSOperationQueue

Cocoaでは、タスクベースの並列処理を実現する手段として、GCDとNSOperation/NSOperationQueueの2種類のAPIが提供されています。GCDはCベース、NSOperation/NSOperationQueueはObjective-CベースのAPIです。これら2つの何が違うのかというと、

GCD is a low-level C-based API that enables very simple use of a task-based concurrency model. NSOperation and NSOperationQueue are Objective-C classes that do a similar thing. NSOperation was introduced first, but as of 10.6 and iOS 4, NSOperationQueue and friends are internally implemented using GCD.

In general, you should use the highest level of abstraction that suits your needs. This means that you should usually use NSOperationQueue instead of GCD, unless you need to do something that NSOperationQueue doesn't support.

Note that NSOperationQueue isn't a "dumbed-down" version of GCD; in fact, there are many things that you can do very simply with NSOperationQueue that take a lot of work with pure GCD. (Examples: bandwidth-constrained queues that only run N operations at a time; establishing dependencies between operations. Both very simple with NSOperation, very difficult with GCD.) Apple's done the hard work of leveraging GCD to create a very nice object-friendly API with NSOperation. Take advantage of their work unless you have a reason not to.

Caveat: On the other hand, if you really just need to send off a block, and don't need any of the additional functionality that NSOperationQueue provides, there's nothing wrong with using GCD. Just be sure it's the right tool for the job.

ios - NSOperation vs Grand Central Dispatch - Stack Overflow

NSOperation/NSOperationQueueは内部的にはGCDで実装されているようです。要するにNSOperation/NSOperationQueueはGCDのObjective-Cラッパーということですね。また、

In general, I agree with this. NSOperation and NSOperationQueue provide support for dependencies and one or two other things that GCD blocks and queues don't have, and they abstract away the lower-level details of how the concurrent operations are implemented. If you need that functionality, NSOperation is a very good way to go.

However, after working with both, I've found myself replacing all of my NSOperation-based code with GCD blocks and queues. I've done this for two reasons: there is significant overhead when using NSOperation for frequent actions, and I believe my code is cleaner and more descriptive when using GCD blocks.

The first reason comes from profiling in my applications, where I found that the NSOperation object allocation and deallocation process took a significant amount of CPU resources when dealing with small and frequent actions, like rendering an OpenGL ES frame to the screen. GCD blocks completely eliminated that overhead, leading to significant performance improvements.

The second reason is more subjective, but I believe that my code is cleaner when using blocks than NSOperations. The quick capture of scope allowed by a block and the inline nature of them make for less code, because you don't need to create custom NSOperation subclasses or bundle up parameters to be passed into the operation, and more descriptive code in my opinion, because you can place the code to be run in a queue at the point where it is fired off.

objective c - Why should I choose GCD over NSOperation and blocks for high-level applications? - Stack Overflow

GCDに比べてNSOperation/NSOperationQueueの方がより高度な処理を行える一方で、オーバーヘッドは幾分大きいようです。また、GCDを使ったほうが綺麗に書けるケースもあるので、その辺りを考慮して両者を使い分けると良いとのことです。

GCDの使い方

まず、基本となるGCDの方を見ていきます。

GCDはタスクを格納するキュー(ディスパッチキュー)を中心に動いており、現在、3種類のキューが定義されています。

  • 直列キュー:タスクが追加された順に順番に実行されるキュー。dispatch_queue_create("jp.hateblo.shin.LABEL_NAME", NULL)*1でいくつでも自由に作成可能。共有リソースにアクセスするときに使われることが多い模様。
  • 並列キュー:追加したタスクが並列に実行されるキュー。4つのグローバルな並列キューが予め用意されているが、iOS5以降は自由に作成可能。グローバルなキューはdispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0)*2で取得可能。
  • メインディスパッチキュー:特別な直列キューで、これに追加したタスクは全てメインスレッド上で実行される。UIを更新するのに便利。取得はdispatch_get_main_queue()で可能。

各キューは、コード的には、生成の仕方が異なるだけで、それ以外の使い方は同じです。

タスクの登録はdispatch_asyncdispatch_syncで行います。タスクは登録した時点で随時実行が開始されます*3dispatch_syncについては、呼び出した地点で呼び出し元のタスク(もしくはスレッド)が停止します。

// 直列キューの例
#import <Foundation/Foundation.h>

int main(int argc, char* argv[])
{
    @autoreleasepool {
        dispatch_queue_t queue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0);
        dispatch_async(queue, ^{
            [NSThread sleepForTimeInterval:1];
            NSLog(@"@Done: %d", 1);
        });
        dispatch_async(queue, ^{
            [NSThread sleepForTimeInterval:1];
            NSLog(@"@Done: %d", 2);
        });
        // ここでqueueに溜まった全てのタスクが終わるまでメインスレッドは待機
        dispatch_sync(queue, ^{
            [NSThread sleepForTimeInterval:1];
            NSLog(@"@Done: %d", 3);
        });
    }
    return 0;
}
$ clang gcd1.m -framework Foundation
$ ./a.out
2014-05-05 13:31:00.011 a.out[12929:1003] @Done: 1
2014-05-05 13:31:01.014 a.out[12929:1003] @Done: 2
2014-05-05 13:31:02.016 a.out[12929:507] @Done: 3

ディスパッチキューは、Objective-Cのオブジェクトではないので、原則的には、dispatch_retaindispatch_releaseで参照カウントを制御する必要があります。

#import <Foundation/Foundation.h>

// 非同期なadd。
// 結果は特定のqueue上でコールバックblockで返答される。
void addAsync(int x, int y, dispatch_queue_t queue, void (^block)(int))
{
    dispatch_retain(queue);
    dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
        [NSThread sleepForTimeInterval:3];
        dispatch_async(queue, ^{ block(x + y); });
        dispatch_release(queue);
    });
}

void doAddAsync()
{
    dispatch_queue_t queue = dispatch_queue_create("jp.hateblo.shin", NULL);
    addAsync(1, 2, queue, ^(int r) { NSLog(@"%d", r); });
    addAsync(100, 200, queue, ^(int r) { NSLog(@"%d", r); });
    addAsync(1000, 2000, queue, ^(int r) { NSLog(@"%d", r); });
    dispatch_async(queue, ^{ NSLog(@"Start!"); });
    dispatch_release(queue);
}

int main(int argc, char* argv[])
{
    // ARC有効の時はデプロイターゲットによってqueueの扱いが変わる(後述)
    @autoreleasepool {
        doAddAsync();
        [NSThread sleepForTimeInterval:5];
        NSLog(@"Done!");
    }
    return 0;
}

addAsyncでqueueをretainすることで、非同期処理が終わるまで破棄されるのを防ぐことが出来ます。

……が、ややこしいことに、実はiOS6/OSX 10.8以降ではdispatch_queue_tがARCの制御下に入るようになっています*4。実際にXcodeで試してみると、ちゃんと警告が出ます*5

f:id:s-shin:20140505232600p:plain
f:id:s-shin:20140505232618p:plain

stackoverflowによれば、

If your deployment target is lower than iOS 6.0 or Mac OS X 10.8

You need to use dispatch_retain and dispatch_release on your queue. ARC does not manage them.

If your deployment target is iOS 6.0 or Mac OS X 10.8 or later

ARC will manage your queue for you. You do not need to (and cannot) use dispatch_retain or dispatch_release if ARC is enabled.

(中略)

For compatibility with old codebases, you can prevent the compiler from seeing your queue as an Objective-C object by defining OS_OBJECT_USE_OBJC to 0. For example, you can put this in your .pch file (before any #import statements):

#define OS_OBJECT_USE_OBJC 0
http://stackoverflow.com/questions/8618632/does-arc-support-dispatch-queues

とのことで、後方互換はOS_OBJECT_USE_OBJCで何とかする仕様になっているそうです。

ディスパッチキュー以外の機能

GCDの目玉はディスパッチキューですが、それ以外に、以下の機能も提供しています。

  • ディスパッチグループ:タスクの終了を待機するのに便利な機能。
  • ディスパッチセマフォ:有限なリソースを取り扱う時に便利な機能。
  • ディスパッチソース:システムイベント(タイマーやシグナルなど)を扱える機能。

ここでは、ディスパッチグループとディスパッチセマフォについてのみ取り上げます。ディスパッチソースに関してはシステムイベントもキューで処理できるんだということさえ知っていれば良いかと思います。

ディスパッチグループ

例えば、並列キューを使った以下のようなプログラムがあった時、

#import <Foundation/Foundation.h>

int main(int argc, char* argv[])
{
    @autoreleasepool {
        dispatch_queue_t queue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0);
        dispatch_async(queue, ^{
            [NSThread sleepForTimeInterval:3];
            NSLog(@"@Done: %d", 1);
        });
        dispatch_async(queue, ^{
            [NSThread sleepForTimeInterval:3];
            NSLog(@"@Done: %d", 2);
        });
        dispatch_sync(queue, ^{
            [NSThread sleepForTimeInterval:3];
            NSLog(@"@Done: %d", 3);
        });
        // ここでqueueと並列で色々処理したい
    }
    return 0;
}

dispatch_syncを使うことでキューに溜まったタスクの終了を待機しているので、メインスレッドで別処理を行うことが出来ません。

このような時にディスパッチグループが使えます。

#import <Foundation/Foundation.h>

int main(int argc, char* argv[])
{
    @autoreleasepool {
        dispatch_queue_t queue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0);
        dispatch_group_t group = dispatch_group_create();
               
        dispatch_group_async(group, queue, ^{
            [NSThread sleepForTimeInterval:3];
            NSLog(@"Done: %d", 1);
        });
        dispatch_group_async(group, queue, ^{
            [NSThread sleepForTimeInterval:3];
            NSLog(@"Done: %d", 2);
        });
        dispatch_group_async(group, queue, ^{
            [NSThread sleepForTimeInterval:3];
            NSLog(@"Done: %d", 3);
        });
       
        // この時点で溜まっているキューが終わり次第呼ばれる。
        // waitの対象にはならない。
        dispatch_group_notify(group, queue, ^{
            NSLog(@"All tasks are done!");
        });
       
        NSLog(@"do something...");
        [NSThread sleepForTimeInterval:1];
        NSLog(@"waiting...");
        dispatch_group_wait(group, DISPATCH_TIME_FOREVER);
        // ここで少し待たないとnotifyの処理が行われる前に
        // プログラムが終わってしまう。
        [NSThread sleepForTimeInterval:1];
    }
    return 0;
}
$ ./a.out
2014-05-05 15:05:55.084 a.out[13358:507] do something...
2014-05-05 15:05:56.087 a.out[13358:507] waiting...
2014-05-05 15:05:58.085 a.out[13358:1203] Done: 3
2014-05-05 15:05:58.085 a.out[13358:1103] Done: 2
2014-05-05 15:05:58.085 a.out[13358:1003] Done: 1
2014-05-05 15:05:58.086 a.out[13358:1003] All tasks are done!

dispatch_group_asyncでタスクを登録することで、後々必要に応じてdispatch_group_waitでキューに溜まったタスクの終了を待つことが出来ます。dispatch_group_notifyはオプショナルですが、終了処理を記載することが出来ます。

ディスパッチセマフォ

GCDでは伝統的なセマフォが実装されています。セマフォと言われると、排他制御のためのミューテックス*6を思い浮かべますが、直列キューを上手く使えば、クリティカルセクションに対する排他制御は必要ないという思想だからか、ドキュメントでは、有限のリソース(ex. ファイルディスクリプタ)へのアクセスを制御する手段としてセマフォが紹介されています。

他の使い方としては、例えば、ディスパッチグループの項で、非同期処理を待つ方法を紹介しましたが、セマフォを使っても非同期処理が一つであれば似たようなことが出来ます。

#import <Foundation/Foundation.h>

int main(int argc, char* argv[])
{
    @autoreleasepool {
        dispatch_semaphore_t semaphore = dispatch_semaphore_create(0);
        dispatch_queue_t queue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0);
        dispatch_async(queue, ^{
            [NSThread sleepForTimeInterval:2]; // do something
            NSLog(@"Finish!");
            dispatch_semaphore_signal(semaphore);
        });
        [NSThread sleepForTimeInterval:1]; // do something
        NSLog(@"waiting...");
        dispatch_semaphore_wait(semaphore, DISPATCH_TIME_FOREVER);
    }
    return 0;
}

このコードでは非同期処理のほうが長くなっているので、signalよりもwaitのほうが先に実行されますが、逆でも問題ありません。というのも、セマフォ作成時に指定する値はあくまでもセマフォの内部カウンタの初期値であって、上限値ではないからです*7。signalとwaitを同じ回数だけ呼ばれている限りは、タスク(スレッド)が停止することはありません。

おまけ:Objective-C以外でGCD

GCDの開発はAppleによって行われていますが、実はApache License 2.0なオープンソース公開されています。ただし、オリジナルのGCDは、Objective-Cのブロックありきの実装で、また内部的にもMac OSのサポートしかされていません。そこで、GCDをベースに他の環境・言語でも使えるようにしよう、という試みがなされています。

その一つが、XDispatchです*8。XDispatchでは、C・C++・Qt-likeなインターフェースでクロスプラットフォームで利用できるライブラリが提供されています。これを使えば、Mac以外でもGCDを使うことが出来るようです。

NSOperation/NSOperationQueueの使い方

GCDの方が随分長くなってしまいましたが、NSOperation/NSOperationQueueについても簡単に触れておきます。

NSOperation/NSOperationQueueの基本的な考え方はGCDと同様で、タスク(NSOperation)をキュー(NSOperationQueue)に追加して実行する設計になっています。なので、GCDとほとんど同じ要領で利用することが出来ます。

コード見た方がイメージつくので、細かい話の前に載せてしまいます。

#import <Foundation/Foundation.h>

NSBlockOperation* createAnOperation()
{
    // NSBlockOperationでは複数のブロックを持つことができ、
    // それらは並列に実行される。
    NSBlockOperation *op = [NSBlockOperation blockOperationWithBlock: ^{
        [NSThread sleepForTimeInterval:1];
        NSLog(@"Done: 1");
    }];
    [op addExecutionBlock: ^{
        [NSThread sleepForTimeInterval:1];
        NSLog(@"Done: 2");
    }];
    [op addExecutionBlock: ^{
        [NSThread sleepForTimeInterval:1];
        NSLog(@"Done: 3");
    }];
    return op;
}

int main(int argc, char* argv[])
{
    @autoreleasepool {
        NSArray *ops = @[createAnOperation(), createAnOperation()];
        NSOperationQueue *queue = [[NSOperationQueue alloc] init];
        // ここでは、オペレーションを直列で実行するために、
        // オペレーションの最大同時実行数を1にする。
        [queue setMaxConcurrentOperationCount:1];
        // waitUntilFinishedをYESにすると待機する。
        [queue addOperations:ops waitUntilFinished:NO];
        // キューに直接ブロックオペレーションを追加する事もできる。
        [queue addOperationWithBlock: ^{
            NSLog(@"All done!");
        }];
        // 全てのオペレーションが終わるまで待機
        [queue waitUntilAllOperationsAreFinished];
    }
    return 0;
}
$ ./a.out
2014-05-05 22:47:24.787 a.out[13857:1603] Done: 2
2014-05-05 22:47:24.787 a.out[13857:2003] Done: 3
2014-05-05 22:47:24.787 a.out[13857:1403] Done: 1
2014-05-05 22:47:25.792 a.out[13857:2003] Done: 3
2014-05-05 22:47:25.792 a.out[13857:1403] Done: 1
2014-05-05 22:47:25.792 a.out[13857:1603] Done: 2
2014-05-05 22:47:25.794 a.out[13857:1403] All done!

NSBlockOperationは、標準で用意されているNSOperationの子クラスです。ここではNSBlockOperationしか使っていませんが、NSInvocationOperationというターゲット&セレクタで指定するタイプのものもあります。NSOperationを継承して、カスタマイズのNSOperationを作成することもできるようです*9

NSOperationQueueは、基本的にはGCDでいうところの並列キューです。サンプルコードでは、setMaxConcurrentOperationCount:1することで、直列キューにしています。

少しややこしいのですが、NSBlockOperationには複数のブロックを登録でき、それらは並列に実行されます。コードでは、並列に実行される複数のブロックを持つNSBlockOperationを、2つ直列でキューに入れています。

それと、NSOperationはオペレーション同士の依存関係を設定することができます。先のコードでは直列関係をオペレーションの同時実行数を制限しましたが、以下のように依存関係で実現することも出来ます。

#import <Foundation/Foundation.h>

int main(int argc, char* argv[])
{
    @autoreleasepool {
        NSOperation *op1 = [NSBlockOperation blockOperationWithBlock: ^{
            [NSThread sleepForTimeInterval:1];
            NSLog(@"Done: 1");
        }];
        NSOperation *op2 = [NSBlockOperation blockOperationWithBlock: ^{
            [NSThread sleepForTimeInterval:1];
            NSLog(@"Done: 2");
        }];
        [op2 addDependency: op1]; // op2はop1に依存
        NSOperationQueue *queue = [[NSOperationQueue alloc] init];
        [queue addOperations:@[op1, op2] waitUntilFinished:NO];
        [queue waitUntilAllOperationsAreFinished];
    }
    return 0;
}

ここでは取り上げませんが、各オペレーションには、キューにおける優先度を設定することも可能です。setCompletionBlockを使えばオペレーション完了時に呼ばれるブロックを設定することも出来ます。

ということで、GCDより色々機能があるんだなぁということは理解できるのではないでしょうか。

まとめ

実際にGCDやNSOperation/NSOperationQueueに触ってみると分かりますが、慣れるとNSThreadとかを使うのが馬鹿らしくなるほど、このAPIは良く出来ています。簡単にメインスレッドで行う処理を書けるのでUIとの親和性も高いし、性能を考えてスレッド数を計算したりする必要もありません。排他制御の代わりに直列キューを使えば、デッドロックも気にする必要がありません。是非、積極的に使っていきたい所です。

*1:第二引数は拡張のために予約されているようで、現状NULLで固定

*2:第二引数は0で固定

*3:dispatch_suspend, dispatch_resumeでキューの一時停止・再開が制御できます。

*4:https://developer.apple.com/jp/devcenter/ios/library/japanese.html の並列プログラミングガイド(最終更新日2012/12/13)は更新が止まっているのか、このことに関する記載がありません。。。

*5:clangでのDeployment Targetの設定方法が良く分からないのでXcodeで実験しました

*6:セマフォの同時アクセス数を1にした場合(バイナリセマフォ)と同等

*7:セマフォはsignalされたらされただけ、waitされたらされただけ、値が増減します。

*8:他にOpenGCDというプロジェクトもあります

*9:細かい話はドキュメントを参照。注意点とか結構書かれています。