NSOperationQueue#addOperationはブロックをコピーする

マルチスレッド絡みの話を少々。

iOS 4.0以降は、GCD(Grand Central Dispatch)という仕組みが導入されており、ちょっとしたバックグラウンド処理ならば、ブロック構文と合わせて、とても簡単に記述できるようになっています。ブロックは、他の言語でいうところの「高階関数」というやつで、引数なしの無名ブロックであれば、

^{/*処理内容*/}

のような記法で記述できます。

例えば、以下の短いプログラムは、マルチスレッドで動きます。

@interface MyObject : NSObject
@end

@implementation MyObject
-(void)hello {
    NSLog(@"hello");
}

@end

int main(int argc, const char** argv)
{
    @autoreleasepool {
        MyObject* obj = [[[MyObject alloc] init] autorelease];
		
        [[[[NSOperationQueue alloc] init] autorelease] addOperationWithBlock:^{
            //--ここから
            [NSThread sleepForTimeInterval:3.0];
            [obj hello];
            //--ここまで、別スレッドで動く
        }];
    }
	
    [NSThread sleepForTimeInterval:5.0];
}

上記プログラムは以下のような手順で動きます。

・メインスレッド:MyObjectをインスタンス化して、autorelease
・メインスレッド:オペレーションの待ち行列を生成して、autorelease
・メインスレッド:オペレーションにブロックを追加
 ※メインスレッドは、オペレーションをキューに登録したらすぐに@autoreleasepoolを抜ける
・メインスレッド:5秒間スリープ
 ↓以下はメインスレッドが5秒スリープしている間に別スレッドで実行される
・バックグラウンドスレッド:3秒間スリープ
・バックグラウンドスレッド:objにhelloメッセージを送信 → ログに"hello"を出力
・バックグラウンドスレッド終了


さて、私はこれと似たようなコードを最初に見たときに、不思議に感じました。変数objは、メインスレッドで生成され、特に引き渡すような処理もせずに、別スレッドで使用されています。Appleのドキュメントには以下のように記述されています。


■ConcurrencyProgrammingGuidより抜粋

リスト 3-1 簡単なブロックの例

int x = 123;
int y = 456;

// ブロックの宣言と代入
void (^aBlock)(int) = ^(int z) {
printf("%d %d %d\n", x, y, z);
};

// ブロックを実行する
aBlock(789); // 印字: 123 456 789

ブロックを設計する際に考慮するべきガイドラインを以下に示します。

・ディスパッチキューを使って非同期に実行するブロックの場合、親である関数やメソッドのスカラ変数を参照し、ブロック内で使うのは安全です。しかし、大きな構造体やポインタベースの変数で、呼び出しコンテキストで確保、削除されるものは、この方法では参照できません。

スカラ値を参照するのは安全とのことですが、では、Objective-Cクラスのインスタンス変数はどうなのでしょうか。

最初の私が記述したプログラム例では、変数objは明らかに、バックグラウンドスレッドでの [obj hello] よりも先に@autoreleasepoolを抜けて、releaseされています。単純に考えるならば、[obj hello] の時点で、既にobjは解放されているのではないか、と思ったわけです。しかしながら、実行結果としては、[obj hello] は正常に動作しています。


同じドキュメントに、以下のようなことも書かれていました。

ディスパッチキューは、追加されたブロックをコピーしておき、実行終了後に解放するようになっています。したがって、キューに追加する際、明示的にブロックをコピーする必要はありません。

なるほど。

これを見たとき、1つの直感が働きました。

NSOperationQueue#addOperationが、addするブロックをコピーしているならば、そのブロック内で使われる、外部(親メソッド内のローカル)のObjective-Cクラスのインスタンス変数はretainされているのではないだろうか。

これを確かめるために、以下のようにコードを修正して実行してみました。

@interface MyObject : NSObject
@end

@implementation MyObject
-(void)hello {
    NSLog(@"hello");
}

-(id)retain {
    NSLog(@"retain");
    return [super retain];
}

-(oneway void)release {
    NSLog(@"release");
    [super release];
}

-(void)dealloc {
    NSLog(@"dealloc");
    [super dealloc];
}

@end

// テスト用メイン関数
int main(int argc, const char** argv)
{
    @autoreleasepool {
        MyObject* obj = [[[MyObject alloc] init] autorelease];
		
        [[[[NSOperationQueue alloc] init] autorelease] addOperationWithBlock:^{
            [NSThread sleepForTimeInterval:3.0];
            [obj hello];
        }];
    }
	
    [NSThread sleepForTimeInterval:5.0];
    NSLog(@"finish");
}

【実行結果】

2013-08-07 21:27:04.856 Test[1926:303] retain
2013-08-07 21:27:04.857 Test[1926:303] release
2013-08-07 21:27:07.859 Test[1926:1f03] hello
2013-08-07 21:27:07.861 Test[1926:2003] release
2013-08-07 21:27:07.862 Test[1926:2003] dealloc
2013-08-07 21:27:09.858 Test[1926:303] finish

思った通りでした。

やはり、objはretainされています。おそらく、「ブロックをコピーします」の説明の中には、「ブロック内で使用される外部オブジェクトをretainする」ことも含まれているのでしょう。であれば、納得です。これならば、参照するだけならば特に問題はないでしょうし、objの内部データが変更されることがあるような場合には、内部データが壊れないようにロックしてやれば良さそうです。*1


ただし、同じキューで動作する複数のタスクが、ブロック間で同じオブジェクトを共有することはできないという記述がありましたので、そのような場合には注意が必要です。

ディスパッチオブジェクト(ディスパッチキューを含む)はすべて、独自のコンテキストデータを結びつけて格納できます。データの設定/取得には、dispatch_set_context関数、 dispatch_get_context関数をそれぞれ使います。システムがこのデータを使うことはありません。 また、適当な時点でデータを割り当て、解放するのは、開発者の責任です。
キューに関しては、コンテキストデータとして、Objective-Cのオブジェクトその他のデータ構造を指すポインタを格納しておけば、キューやその意図する使いかたが識別しやすくなります。キューを破棄する際にコンテキストデータを解放(あるいは関連づけを解除)するためには、ファイナライザ関数を使うとよいでしょう。

厳密に、スレッドにオブジェクトを関連づけるには、この説明のように、コンテキストデータという位置づけで、オブジェクトをオペレーションキューにセットしておくのが無難そうです。

*1:実際には、GCDではロックの使用は推奨されていません。共有リソースを競合から保護するためには、専用の直列キューを利用するとよいそうです。あるいは、実行オペレーションに依存関係を持たせることもできるので、どうしてもロックを使用しなければならないという状況はほとんど起こりません。