Open3

Swift Concurrency: actorで同時アクセスが制御されるのはどこか?

kabeyakabeya

今日、たまたま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 (like transfer(amount:to:)), and instance subscripts, are all actor-isolated by default.

「アクターのインスタンス上のすべての宣言は、デフォルトですべてアクターにより隔離されています。これら隔離される宣言には、ストアドインスタンスプロパティ、コンピューティドインスタンスプロパティ(この例での「balance」)、インスタンスメソッド(この例での「transfer(amount:to:)」)、インスタンス添字が含まれます。」

って書いてあります。インスタンスメソッドも隔離されるよって話なんですよね。
その話が正しいのだとすると、前のTaskimage(from:)呼び出しが終わるまで次のTaskimage(from:)はブロックされるはずなんです。動画と話が違います。

実際、Hacking with Swiftの記事でも、そのような認識でURLCacheという、動画とほぼ同じ例が書かれています。

https://www.hackingwithswift.com/quick-start/concurrency/how-to-create-and-use-an-actor-in-swift

ですが、実際にサンプルを作って動かしてみると、WWDC21の動画の通りなんですね。

この外にTaskを4つぐらい作って実行してみると、実行するたびに結果が変わりますし、途中でprintしてみると、awaitのところで次のTaskが走ってくる様子が分かります。

ということは?

actorで隔離されているのはプロパティだけで、メソッドは隔離されてはいない、ということなのかしら。

違うか、image(from:)を実行しているのは厳密には1つしかない、ということですかね。
awaitで止まらない限りは、他のTaskimage(from:)を実行しないけども、awaitで止まっていれば、他のTaskimage(from:)を実行することもある、というような。

もしそうでなく、プロパティだけ隔離されているとなると、cacheにアクセスする際には他のTaskを待ち受ける可能性があるということですから、awaitが必ず必要という文法になっていないとおかしなことになります。でもそうはなってなくて、selfcacheにはawaitなしのノンブロッキングアクセスが保証されています。

なので、メソッドが同時に1つしか呼び出されない、というよりは、メソッドがawaitで自動的に区切られて、その区切られた各パートが、自動的にクリティカルセクションみたいなもので保護される、というようなイメージですかね。そういう意味では「SE-0306」の説明は正確ではないというか。

自分でスレッドとか起こすとかよりもかなり柔軟に実行されると思うのですが、ちゃんと把握しないと思いもよらない不具合を引き起こしそうですね。

kabeyakabeya

その区切られた各パートが、自動的にクリティカルセクションみたいなもので保護される、というようなイメージですかね。

違いますね。
別々にクリティカルセクションがあるわけじゃなくて、ともかくこのactorに関するすべての処理は同時に1個しかアクティブにならない、ということですね。
1つしか「実行されない」というよりは、1つしか「アクティブにならない」が正しい気がしますね。
「中断中(await中)」は「実行されている」けども「アクティブでない」というか。executedだけどnot runningというか。

kabeyakabeya

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つしかアクティブにならない、というような話なんですね、たぶん。

ただ、これ、例を見ないと気付かんよねと思いました。