概要
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
と実行されることが絶対にないとは言えないと認識していますが、この記事ではそういう可能性は考えないことにします。 ↩