Swift Concurrency: actorで同時アクセスが制御されるのはどこか?
今日、たまたまWWDC 21の「Protected mutable state with Swift actors」を見てたんです。
で、この動画の9分あたりから「Actor reentrancy」というテーマが出てくるんですね。
以下のようなコードです。
actor ImageDownloader {
private var cache: [URL: Image] = [:]
func image(from url: URL) async throws -> Image? {
if let cached = cache[url] { // 1) キャッシュをチェックする
return cached
}
let image = try await downloadImage(from: url) // 2) 画像を実際にダウンロードしてくる
cache[url] = image // 3) キャッシュを書き換える
return image
}
}
で、動画の説明を聞いていると、このimage(from:)
はアクター外からアクセスしても複数同時に走るような感じのことを言うんです。
1)のキャッシュをチェックして、なければ2)でダウンロードして、結果を3)でキャッシュに入れる、のだけども、実際は2)のところで一時停止する。で、その間に別のTask
がもう1個、image(from:)
を呼び出すとcache
も更新されていないうえ、この2個の呼び出しの間に画像が差し替わったら、1個目の呼び出しと2個目の呼び出しで返ってくる画像が(キャッシュしているくせに)変わってしまう、というんです。
(だからリエントラントにしろ、という話です)
あれ?ってなったのは、SwiftのSE-0306には
All declarations on an instance of an actor, including stored and computed instance properties (like
balance
), instance methods (liketransfer(amount:to:)
), and instance subscripts, are all actor-isolated by default.
「アクターのインスタンス上のすべての宣言は、デフォルトですべてアクターにより隔離されています。これら隔離される宣言には、ストアドインスタンスプロパティ、コンピューティドインスタンスプロパティ(この例での「balance」)、インスタンスメソッド(この例での「transfer(amount:to:)」)、インスタンス添字が含まれます。」
って書いてあります。インスタンスメソッドも隔離されるよって話なんですよね。
その話が正しいのだとすると、前のTask
のimage(from:)
呼び出しが終わるまで次のTask
のimage(from:)
はブロックされるはずなんです。動画と話が違います。
実際、Hacking with Swiftの記事でも、そのような認識でURLCache
という、動画とほぼ同じ例が書かれています。
ですが、実際にサンプルを作って動かしてみると、WWDC21の動画の通りなんですね。
この外にTask
を4つぐらい作って実行してみると、実行するたびに結果が変わりますし、途中でprint
してみると、await
のところで次のTask
が走ってくる様子が分かります。
ということは?
actor
で隔離されているのはプロパティだけで、メソッドは隔離されてはいない、ということなのかしら。
違うか、image(from:)
を実行しているのは厳密には1つしかない、ということですかね。
await
で止まらない限りは、他のTask
がimage(from:)
を実行しないけども、await
で止まっていれば、他のTask
がimage(from:)
を実行することもある、というような。
もしそうでなく、プロパティだけ隔離されているとなると、cache
にアクセスする際には他のTask
を待ち受ける可能性があるということですから、await
が必ず必要という文法になっていないとおかしなことになります。でもそうはなってなくて、self
のcache
にはawait
なしのノンブロッキングアクセスが保証されています。
なので、メソッドが同時に1つしか呼び出されない、というよりは、メソッドがawait
で自動的に区切られて、その区切られた各パートが、自動的にクリティカルセクションみたいなもので保護される、というようなイメージですかね。そういう意味では「SE-0306」の説明は正確ではないというか。
自分でスレッドとか起こすとかよりもかなり柔軟に実行されると思うのですが、ちゃんと把握しないと思いもよらない不具合を引き起こしそうですね。
その区切られた各パートが、自動的にクリティカルセクションみたいなもので保護される、というようなイメージですかね。
違いますね。
別々にクリティカルセクションがあるわけじゃなくて、ともかくこのactor
に関するすべての処理は同時に1個しかアクティブにならない、ということですね。
1つしか「実行されない」というよりは、1つしか「アクティブにならない」が正しい気がしますね。
「中断中(await中)」は「実行されている」けども「アクティブでない」というか。executedだけどnot runningというか。
SEでなく言語のドキュメントには、このことが書いてありました。最初からここ見ろって話ですね。
The following aspects of the Swift concurrency model work together to make it easier to reason about shared mutable state:
- Code in between possible suspension points runs sequentially, without the possibility of interruption from other concurrent code.
- Code that interacts with an actor’s local state runs only on that actor.
- An actor runs only one piece of code at a time.
この最後の「An actor runs only one piece of code at a time.」が、1つしかアクティブにならない、というような話なんですね、たぶん。
ただ、これ、例を見ないと気付かんよねと思いました。