マルチスレッドプログラミングのレビューにて
先輩に言われてひぎゃーって叫んだ本日のNG集的な。
- 別スレッドの処理を待って次の処理を実行する方法
- コールバック
- コールバック地獄とはなんたるや
- GCDの
dispatch_sync
使うdispatch_sync
はキューに入れたタスクのデッドロックに注意
- コールバック
別スレッドの処理を待って次の処理を実行する方法
コールバック
コールバックを使うと下記のような形で、別のスレッドで行いたい処理の後に行う処理を呼び出し元から指定できる。
- (void) executeSomethingAsyncWithCompletionHandler:(void (^)())block { dispatch_async(firstQueue, ^{ // firstQueueのスレッドで行いたいようなタスク block(); // blockの内容はfirstQueueのスレッドから実行される }); } // ここはメインスレッドなど他のスレッド [self executeAsyncSomethingWithCompletionHandler: ^ { NSLog(@"execute on firstQueue"); }];
また、指定した処理は確かに別スレッドで行いたい処理の後に実行される。
しかし、executeSomethingAsyncWithCompletionHandler:
のようなコールバックを引数に取るメソッドの後の行より、その処理が後に実行されるとは限らない。
// ここはメインスレッドなど他のスレッド [self executeSomethingAsyncWithCompletionHandler: ^ { NSLog(@"execute on firstQueue"); }]; NSLog(@"this may be executed before firstQueue log"); // 上と下のNSLogの行は同期実行されない。つまり、下のNSLogの行は上の行の実行を待たない。
なので、コールバックの処理の結果を期待している行が非同期実行されるため、その行以降にコールバック処理の結果を前提としている行がないか、メソッド内だけでなく呼び出し元まで辿って確認していく必要がある。
コールバック地獄とはなんたるや
これを呼び出し元まで辿って実行順序や実行されるスレッドを保証しようとしたいとします。
手始めに次の例。
static int seed = 0; - (void) setup { [self executeAsyncSomethingWithCompletionHandler: ^(int _rand) { NSLog(@"execute on firstQueue"); seed = _rand; }]; } - (void) configure { NSLog(@"seed %d", seed); [self requestAPIWithSeed: seed]; // configureはsetup内部で実行されるexecuteSomethingAsyncWithCompletionHandler:の結果を前提としている } - (void) start { [self setup]; [self configure]; // ここで順序が入れ替わっては困る } - (void) executeSomethingAsyncWithCompletionHandler:(void (^)(int))block { dispatch_async(firstQueue, ^{ sleep(10); block(rand() % 10); // blockの内容はfirstQueueのスレッドから実行される }); }
setup
と configure
の順序を保証するためにコールバックを使って考えるとすると、 setup
メソッドが引数としてコールバックを受け取り、setup
メソッドの最後の方で configure
メソッドが含まれるブロックが実行されるように書けばいいかもしれません。
static int seed = 0; - (void) setupWithCompletionBlock:(void (^)())block { [self executeAsyncSomethingWithCompletionHandler: ^(int _rand) { NSLog(@"execute on firstQueue"); seed = _rand; block(); }]; } - (void) configure { NSLog(@"seed %d", seed); [self requestAPIWithSeed: seed]; // configureはsetup内部で実行されるexecuteSomethingAsyncWithCompletionHandler:の結果を前提としている } - (void) start { [self setupWithCompletionBlock:^{ [self configure]; }]; } - (void) executeSomethingAsyncWithCompletionHandler:(void (^)(int))block { dispatch_async(firstQueue, ^{ sleep(10); block(rand() % 10); // blockの内容はfirstQueue以外のスレッドから実行される }); }
ここで、start
の呼び出し元を辿ってみたら、
- (void) initialization { [Configuration start]; [Configuration getCustomSettings]; // startの一連の処理でrequestAPIWithSeedが完了したことを前提としている }
となっていたとします。
すると、先ほどの setup
と configure
の順序を保証するために、今度は start
メソッドの中身が非同期実行になってしまっていたので…これは困りますね。
同じようにコールバックを使って解決しようとすると…
- (void) initialization { [Configuration startWithCompletionHandler:^ { [Configuration getCustomSettings]; // startの一連の処理でrequestAPIWithSeedが完了したことを前提としている }]; }
static int seed = 0; - (void) setupWithCompletionBlock:(void (^)())block { [self executeAsyncSomethingWithCompletionHandler: ^(int _rand) { NSLog(@"execute on firstQueue"); seed = _rand; block(); }]; } - (void) configure { NSLog(@"seed %d", seed); [self requestAPIWithSeed: seed]; // configureはsetup内部で実行されるexecuteSomethingAsyncWithCompletionHandler:の結果を前提としている } - (void) startWithCompletionBlock:(void (^)())block { [self setupWithCompletionBlock:^{ [self configure]; block(); }]; } - (void) executeSomethingAsyncWithCompletionHandler:(void (^)(int))block { dispatch_async(firstQueue, ^{ sleep(10); block(rand() % 10); // blockの内容はfirstQueueのスレッドから実行される }); }
となり、なかなかややこしくなってきましたね。さらに、よくある話ですが、 configure
も非同期処理だったら… ということを考えると非常にめんどくさいですね。処理を書いてるのかblockを渡す機械になってるのかよくわかりません。
また、コールバックの内容を別のスレッドで実行する必要のあるケースはさらにややこしくなりますね、インデントがいくらあっても足りないです。
こういうのをコールバック地獄といい、非同期に行われるが順序を保証したい処理を書くのに便利ということでモバイル界隈で Rx
が爆発的に広まったのでした*1。
GCDのdispatch_sync
を使う
上記のような場合、ObjCやSwiftならdispatch_syncを使って書くと随分シンプルに書けます*2。
static int seed = 0; - (void) setup { dispatch_sync(firstQueue, ^ { int result = [self executeSomething]; NSLog(@"execute on firstQueue"); seed = result; }); // 上のブロックの実行が終わるまでここの行にこないしreturnもしない } - (void) configure { NSLog(@"seed %d", seed); [self requestAPIWithSeed: seed]; // configureはsetup内のexecuteSomethingの結果を前提としている } - (void) start { [self setup]; // setupからreturnして次の行へ行く時、executeSomethingが終わっていることが保証されている [self configure]; // ここで順序が入れ替わらなくて嬉しい! } - (int) executeSomething { sleep(10); // いまさらですが重いタスクの大体のつもり return seed() % 10; }
- (void) initialization { [Configuration start]; // 連鎖的にstartからreturnした時は、requestAPIWithSeedが終わっている [Configuration getCustomSettings]; // startの一連の処理でrequestAPIWithSeedが完了しているので嬉しい! }
別スレッドで実行したい処理について、呼び出し元にややこしいのを伝播せずその場で処理できるので dispatch_sync
よさそうに見えますが、これはあくまでシンタックスやコードの見た目の話です。
処理が重たいので別スレッドで実行したいといった場合は、その重たい処理の実行を元のスレッドで待つことになるので乱用すると全体としてパフォーマンスが悪くなります。
プラットフォームの都合でめっちゃ軽い処理なんだけどどうしても他のスレッドで実行しなくちゃいけない、それを実行しても処理が重くならない、みたいな場合に利用は控えるべきです。
また、次の節みたいな話もあります。
dispatch_sync
はキューに入れたタスクのデッドロックに注意
二つのスレッドでお互いのスレッドのキューにタスクを投げ合って、お互いのキューにまだタスクが残っているうちに、両方が dispatch_sync
で新しくタスクを投げあってしまうとデッドロックが起こることがあります。
具体的に表にすると以下のような例です。
なお、mainQueueはメインスレッドのキュー、myQueueは独自スレッドのキューという感じでお願いします。
独自スレッド | 独自スレッドのキュー | メインスレッド | メインスレッドのキュー |
---|---|---|---|
dispatch_async(mainQueue, タスクA) | |||
タスクB未着手*3 | タスクA | ||
タスクB未着手 | dispatch_sync(myQueue, タスクB) | タスクA | |
タスクB未着手 | タスクB | タスクB消化待ち | タスクA |
dispatch_sync(mainQueue, タスクC) | タスクB | タスクB消化待ち | タスクA |
タスクC消化待ち | タスクB | タスクB消化待ち | タスクA / タスクC |
タスクC消化待ちなのでタスクB着手できず | タスクB | タスクB消化待ちなので、A, C消化できず | タスクA / タスクC |
タスクCの追加がなければ、
独自スレッド | 独自スレッドのキュー | メインスレッド | メインスレッドのキュー |
---|---|---|---|
dispatch_async(mainQueue, タスクA) | |||
タスクB未着手 | タスクA | ||
タスクB未着手 | dispatch_sync(myQueue, タスクB) | タスクA | |
タスクB未着手 | タスクB | タスクB消化待ち | タスクA |
タスクB消化開始 | タスクB | タスクB消化待ち | タスクA |
タスクB消化中 | タスクB | タスクB消化待ち | タスクA |
タスクB消化 | タスクB | タスクB消化待ちなので、A消化できず | タスクA |
A消化開始 | タスクA |
となります。
現場からは以上です。