概要
Swift Concurrency の actor は可変な状態をデータ競合から守るためのコンポーネントです。Swift の actor の特徴として reentrant (再入可能)であることが挙げられます。これがなんのことで、どういうありがたみがあって、アプリ開発者として気をつけるべき注意点にどういうものがあるのかを、主に Swift Evolution の actor のプロポーザル をもとにまとめます。
前提として、自分は Swift Concurrency 以外の文脈における actor のことはわかっていません。また、 WWDC / プロポーザルでは reentrant / reentrancy という言葉が使われていますが、記事中ではそれぞれ日本語で再入可能 / 再入と言うことにします。
この記事中での動作検証は Xcode 14 Beta 3 で行っています。
actor reentrancy(再入)とは
actor が再入可能であるとは、 actor のメソッドが実行中に await で suspend したときにその actor の他のメソッドの実行を開始できることを言います。例えば、以下のコードを実行すると、再入のため a → b → c ではなく a → c → b の順で出力されます 1 。
actor MyActor {
func f1() async {
print("a")
try! await Task.sleep(nanoseconds: 2_000_000_000)
print("b")
}
func f2() async {
print("c")
}
}
let myActor = MyActor()
Task.detached {
await myActor.f1()
}
Task.detached {
try! await Task.sleep(nanoseconds: 1_000_000_000)
await myActor.f2()
}
具体的に実行順を追っていくと以下のようになります。
- まず
f1が実行されてaが出力され、その後2秒間 sleep する - 一方で、
f1が実行された1秒後にf2が呼ばれ、f2はf1の完了を待たずに実行開始されてcが出力される -
f1の sleep が完了し、bが出力される
以上の流れの中の、 f1 が実行完了していないにも関わらず f2 が実行開始される部分が actor の再入です。仮に再入が許されないと仮定すると f1 が完了するまでは f2 の処理を開始できないので a -> b -> c と出力されますが、現状の Swift の actor はそのような仕様にはなっていないということです。
注意点として、再入が起こるからといって1つの actor 上で「同時」に2つの処理が実行されることがあるわけではありません。再入が起こり得るのはあくまで actor の処理が await で suspend した場合のみだからです。上記の例では f1 の中の sleep によって actor の手が空いたためその間に f2 を実行しているだけで、 f1 の処理を同期的に実行している最中に f2 が開始されることはありません。また、仮に f2 が実行中に f1 の sleep が終わったとしても f1 はすぐに続きの処理を実行し始めるのではなく、 f2 の実行完了を待ちます。以上により actor が持つ状態が複数の処理から同時に触られるということはないので、再入が起きた上でも actor は状態をデータ競合から守ることができます。
再入のありがたみ
原理上は再入を許さない actor というのも考えられますが、現状の Swift では再入可能な actor しかサポートされていません。これは、再入がいくつかの問題を排除してくれるというメリットがあるためです。もし再入が許されていなかったらどうなるかという仮定のもとにこれらの問題を見ていきます。
再入が防いでくれる問題1:デッドロック
以下の2つの actor が登場する処理について、仮に actor が再入不可能だったらどうなるかを考えてみましょう。 再入不可能だとすると、 actor のあるメソッドが実行開始されたらたとえ suspend 中であっても別のメソッドを実行開始できないということになります。
// 再入不可能だと仮定する
actor User {
func startWorking(with mac: Mac) async {
await mac.launch(user: self)
}
func useTouchID() {
print("place finger")
}
}
actor Mac {
func launch(user: User) async {
await user.useTouchID()
print("launch completed")
}
}
User の startWorking を実行すると、
-
startWorkingがMacのlaunchを呼び出したときMacはUserのuseTouchIDを呼び出そうとする -
UserのstartWorkingが実行中なので、 再入不可能なUserは別の処理であるuseTouchIDを開始することができない - これにより
MacはUserのstartWorkingの完了を待ち続けるが、逆にUserはstartWorkingの完了のためにMacのlaunchの完了を待ち続けている
という流れでデッドロックが発生し、処理が進まなくなってしまいます。
もちろん実際には Swift の actor が再入可能なので、 userTouchID は実行することができ、これにより Mac の launch は完了、 User の startWorking も完了します。このように、 actor が再入可能であるおかげでデッドロックが防がれています。
再入不可能であることによるデッドロックは実行時に検出したり、すべての actor の処理にタイムアウトを設定することで解除することができます。ただし、 actor のプロポーザルでは、そのような手法はコストが高いし協調的なキャンセルとの相性が悪く Swift Concurrency の方向性には合わないと判断されたと書かれています。
再入が防いでくれる問題2:ブロッキング
再入ができない actor の場合、並行して呼び出された場合にブロックされてしまいパフォーマンスが悪くなるという問題があります。プロポーザルで使われている以下の例で考えてみます。仮に ImageDownloader が再入不可能とすると image1 の呼び出しが先に行われた場合に、そのダウンロードとデコードが完了するまでは image2 の方の getImage を開始することができず待たされることになります。結果として、ほぼ同時に呼ばれた2つの image の取得に、1つの image を取得する場合のほぼ2倍の時間がかかってしまいます。
// 再入不可能だと仮定する
actor ImageDownloader {
var cache: [URL: Image] = [:]
func getImage(_ url: URL) async -> Image {
if let cachedImage = cache[url] {
return cachedImage
}
let data = await download(url)
let image = await Image(decoding: data)
return cache[url, default: image]
}
}
let downloader = ImageDownloader()
Task.detached { let image1 = await downloader.getImage(url1) }
Task.detached { let image2 = await downloader.getImage(url2) }
再入が可能であることにより、 image1 の呼び出しが await download(url) まで到達して suspend した隙に image2 の方でもダウンロードが開始でき、並行にダウンロード処理を行うことができます。これにより、2つの image のダウンロードが再入が許されない場合より速く完了します。
再入の注意点
再入が防いでくれる問題を2つ挙げましたが、逆に再入による注意点として suspend の前後で actor の状態が変わっている可能性がある点が挙げられます。このことに気づかず、actor の suspend 前の状態が維持されている前提でコードを書いてしまうと予期しない挙動になることがあります。
例として以下のカウンターを考えます。
actor Counter {
var count: Int = 0
func increment() -> Int {
count += 1
return count
}
}
increment は内部状態である count を増加させて返します。このコードは想定通り動き、increment は呼ぶたびに1ずつ増えた値が返ってきます。
ここで仕様変更があって、 increment 処理の値を増やしてから値を返すまでの間になんらかの非同期処理をする必要が出てきたとします。ここでは一旦 sleep にしておきましょう。
actor Counter {
var count: Int = 0
func increment() async -> Int {
count += 1
try! await Task.sleep(nanoseconds: 1_000_000_000)
return count
}
}
この変更によってincrement を呼ぶたびに値が1ずつ増えて返ってくるという振る舞いは壊れています。以下の3つの increment 呼び出しでは 1 2 3 が出力されることを期待したくなりますが、実際にはすべて 3 が出力されます。
let counter = Counter()
Task.detached { print(await counter.increment()) } // 3
Task.detached { print(await counter.increment()) } // 3
Task.detached { print(await counter.increment()) } // 3
これは、1つ目の呼び出しが count を増やして sleep に入った際に再入により2つ目の呼び出しが実行され count が増え sleep に入り、3つ目の呼び出しも同様に count を増やすので、3つの呼び出しの sleep が終了する際には count が 3 になっているためです。
このように、suspend を挟むと再入によって actor 内部の状態が変化していることがあるので、await の後で await 前の状態が維持されていない可能性があることを想定した実装をする必要があります。 actor の処理の中に await があったらその時点で変なことが起こらないか意識しておくくらいの心持ちでいたほうがいい気がします。
逆に言うと、仕様変更前の Counter のように await がなければそもそも再入が発生しないので再入の影響を考える必要はありません。Protect mutable state with Swift actors では actor の状態の変更は同期的な処理に閉じ込めることが推奨されています。
まとめ
- Swift の actor は再入可能。これは actor の処理が suspend した時に、同じ actor で別の処理を開始できることを意味する
- 再入可能であるおかげで、actor 処理におけるデッドロックやブロッキングが防がれている
- 再入により suspend 前後で actor の状態が変わり得ることに注意した実装をしていく必要がある
参考
-
もちろん実行環境によっては
a→b→cと実行されることが絶対にないとは言えないと認識していますが、この記事ではそういう可能性は考えないことにします。 ↩