Cocoaのマルチスレッドシステム

Cocoaのマルチスレッド機構について解剖してみました。

実験環境

NSThread?時代はGCDでしょ!

Cocoaには、GCD(Grand Central Dispatch)という新しい並列処理APIが入り、今はこちらを積極的に使うことが推奨されています。とはいえ、CocoaのベースとなっているのはCocoaスレッド(NSThread)なので、NSThreadとNSRunLoopに関する理解は、Cocoaのプログラミングをする上で必須だと思います。

ということで、この記事ではNSThreadを中心に掘り下げていきます。

NSThreadの基本

NSThreadの使い方を簡単に説明すると、適当なクラスに別スレッドで処理するメソッドを用意して、それを適切にinitWithTarget:selector:object:メソッドに渡しstartするだけです。

#import <Foundation/Foundation.h>

@interface ThreadTest : NSObject
- (void)doSomething;
@end

@implementation ThreadTest
- (void)doSomething
{
    for (int i = 0; i < 5; ++i) {
        NSLog(@"doSomething - %d", i);
        [NSThread sleepForTimeInterval:1];
    }
}
@end

int main(int argc, char* argv[])
{
    @autoreleasepool {
        ThreadTest *test = [[ThreadTest alloc] init];
        NSThread *thread = [[NSThread alloc] initWithTarget:test selector:@selector(doSomething) object:nil];
        [thread start];
        while ([thread isExecuting]) {
            NSLog(@"waiting...");
            [NSThread sleepForTimeInterval:2];
        }
        NSLog(@"Done!");
    }
    return 0;
}


このコードをコンパイルして実行してみると、

$ clang basic.m -framework Foundation
$ ./a.out
2014-05-04 19:20:07.317 a.out[11110:507] waiting...
2014-05-04 19:20:07.317 a.out[11110:1f03] doSomething - 0
2014-05-04 19:20:08.320 a.out[11110:1f03] doSomething - 1
2014-05-04 19:20:09.320 a.out[11110:507] waiting...
2014-05-04 19:20:09.321 a.out[11110:1f03] doSomething - 2
2014-05-04 19:20:10.322 a.out[11110:1f03] doSomething - 3
2014-05-04 19:20:11.321 a.out[11110:507] waiting...
2014-05-04 19:20:11.324 a.out[11110:1f03] doSomething - 4
2014-05-04 19:20:13.322 a.out[11110:507] Done!

メインスレッドで2秒ごとに、サブスレッドで1秒ごとにループが周っていることが確認できます。

ちなみに、OS X 10.4以前は、NSThreadのdetachNewThreadSelector:toTarget:withObject:クラスメソッドでスレッドを作成*1していたようですが、このメソッドは値を返さないので、スレッドを作成側でNSTreadインスタンスを取得することができません。なので、基本的には、OS X 10.5以降ではinitWithTarget:selector:object:を使った方が良いとされています。

また、別の方法として、NSThreadを継承してmainメソッドをオーバーライドする方法もあるようです。ここでは詳しく触れないので、適宜リファレンスの方を参照してください。

NSThreadとRunLoop

マルチスレッドに必ずついて回るのがアトミック性の保証です。アトミック性を保証するには排他制御が一般的で、CocoaでもNSLockやNSCondition*2を使えば実現することができます。

また、アトミック性を保証するもう一つの方法として、状態へのアクセスを特定のスレッドからしか行わないという方法があります。これはGUIアプリケーションでよく用いられる方法で*3iOSのUIKitでもこの方式が取られています。

ここでは、後者の方法について掘り下げます。

他のスレッドにメソッドを呼ばせる

UIKitのようなGUIアプリケーションでは、UIパーツをメインスレッドからしか更新できないので、極力マルチスレッドを避けたくなります。が、ネットワーク通信のような非同期な処理であったり、画像処理のように重い処理がある時は、シングルスレッドでというわけにはいきません*4。例えば「バックグラウンドのスレッドで画像をダウンロードしてきて、それをUImageViewで表示したい」という場面では、バックグラウンドのスレッドではUIImageViewを操作できないので、何らかの手段でメインスレッドに画像をセットしてもらう必要があります。

この問題に対処するために、Cocoaでは、他のスレッドにメソッドを呼ばせる手段が用意されています。それがNSObjectクラスのperformSelectorOnMainThread:...performSelector:onThread...です。

最初のサンプルコードを少し改変して実験してみます。

#import <Foundation/Foundation.h>

NSString* currentThreadName()
{
    NSThread *thread = [NSThread currentThread];
    return [thread isMainThread] ? @"main" : [thread name];
}

@interface ThreadTest : NSObject
- (void)doSomething;
@end

@implementation ThreadTest
- (void)doSomething
{
    for (int i = 0; i < 5; ++i) {
        NSLog(@"%@ - %d", currentThreadName(), i);
        [NSThread sleepForTimeInterval:1];
        [self performSelectorOnMainThread:@selector(progress:) withObject:@(i+1) waitUntilDone:YES];
    }
}
- (void)progress:(NSNumber *)i
{
    NSLog(@"%@ - %@", currentThreadName(), i);
}
@end


int main(int argc, char* argv[])
{
    @autoreleasepool {
        ThreadTest *test = [[ThreadTest alloc] init];
        NSThread *thread = [[NSThread alloc] initWithTarget:test selector:@selector(doSomething) object:nil];
        [thread setName:@"bg"];
        [thread start];
        while ([thread isExecuting]) {
            NSLog(@"%@ - waiting...", currentThreadName());
            [NSThread sleepForTimeInterval:2];
        }
        NSLog(@"Done!");
    }
    return 0;
}

[self performSelectorOnMainThread:@selector(progress:) withObject:@(i+1) waitUntilDone:YES];で、バックグラウンドのスレッドが、メインスレッドに、progressメソッドを実行するようリクエストを出しています。waitUntilDoneがYESなので、メインスレッドによるprogressメソッドの実行が終了するまで、バックグラウンドスレッドは待機することになります。

さて、これを実行してみるとどうなるでしょうか。

$ ./a.out
2014-05-04 20:27:24.301 a.out[11320:2003] bg - 0
2014-05-04 20:27:24.301 a.out[11320:507] main - waiting...
2014-05-04 20:27:26.305 a.out[11320:507] main - waiting...
2014-05-04 20:27:28.307 a.out[11320:507] main - waiting...
2014-05-04 20:27:30.308 a.out[11320:507] main - waiting...
2014-05-04 20:27:32.310 a.out[11320:507] main - waiting...
...

プログラムが終了しません。どうもperformSelectorOnMainThreadの所で処理が止まっているようです。メインスレッドがビジーなので、performSelectorOnMainThreadのリクエストが受理されず、待機状態になってしまっているのですね。

スレッドの中にRunLoopがいる

ここで重要になってくるのがRunLoop(実行ループ)の概念です。RunLoopはJavaScriptのシングルスレッドのイベントシステムに似ています。

先程のコードの問題点は、RunLoopを考慮していない点です。どの部分が考慮していないのかというと、

        while ([thread isExecuting]) {
            NSLog(@"%@ - waiting...", currentThreadName());
            [NSThread sleepForTimeInterval:2];
        }

この部分です。実はNSThreadのsleepForTimeIntervalは、RunLoopもsleepさせてしまいます。

RunLoopを以下のように書いたとすると、

RunLoop |処理|空き時間|処理|処理|空き時間|…
"|"で囲まれている部分が一括りの処理。

現状はこうなっています。

RunLoop |NSLog -- sleep -- NSLog -- sleep -- ...
performSelectorOnMainThread:progress ↑ 入り込めない…

期待するのはこうなることです。

RunLoop |NSLog|sleep|NSLog|sleep|progress|NSLog|...
performSelectorOnMainThread:progress ↑ 入れた!実行!

こうするためには、sleepForTimeIntervalの代わりに、NSRunLoopのrunUntilDate:を使用します。runUntilDateにより、指定した時間まで、制御をRunLoopへ戻すことができます*5

        while ([thread isExecuting]) {
            NSLog(@"%@ - waiting...", currentThreadName());
            //[NSThread sleepForTimeInterval:2];
            [[NSRunLoop currentRunLoop] runUntilDate:[NSDate dateWithTimeIntervalSinceNow:2]];
        }
$ ./a.out
2014-05-04 21:00:48.459 a.out[11427:507] main - waiting...
2014-05-04 21:00:48.459 a.out[11427:1d03] bg - 0
2014-05-04 21:00:49.462 a.out[11427:507] main - 1
2014-05-04 21:00:49.462 a.out[11427:1d03] bg - 1
2014-05-04 21:00:50.462 a.out[11427:507] main - waiting...
2014-05-04 21:00:50.464 a.out[11427:507] main - 2
2014-05-04 21:00:50.465 a.out[11427:1d03] bg - 2
2014-05-04 21:00:51.467 a.out[11427:507] main - 3
2014-05-04 21:00:51.467 a.out[11427:1d03] bg - 3
2014-05-04 21:00:52.464 a.out[11427:507] main - waiting...
2014-05-04 21:00:52.469 a.out[11427:507] main - 4
2014-05-04 21:00:52.469 a.out[11427:1d03] bg - 4
2014-05-04 21:00:53.471 a.out[11427:507] main - 5
2014-05-04 21:00:54.465 a.out[11427:507] Done!

無事、期待通りの動作を実現することが出来ました。

マルチスレッド実現手法色々

ここでは、NSThreadを中心に説明しましたが、Cocoaでは様々な手段で並列処理を実現できます。

  • NSThread
  • NSObjectのperformSelectorInBackground:withObject:(内部的にはNSThread)
  • POSIXスレッド(pthread)
  • オペレーションオブジェクト(NSOperationとNSOperationQueue)
  • Grand Central Dispatch(dispatch_*)
  • タイマー
  • ...

オペレーションオブジェクトとGCDについては、まだ勉強不足なので、今度まとめます。

*1:スレッドは自動で開始する

*2:NSLockを継承している

*3:おそらくパフォーマンスの問題。逐一排他制御するよりも良いのだと思う。

*4:シングルスレッドでやるとスレッドが止まってUIが操作できなくなる

*5:ノンプリエンプティブマルチタスクで、制御をスケジューラに返すためにyieldするのと同じ要領です。