[Swift] withUnsafeContinuationがExecutorを引き継がないバグがSwift6.0で治った話と関連SE
バグの内容
検証環境
Swift compiler version info: swift-driver version: 1.87.1 Apple Swift version 5.9 (swiftlang-5.9.0.128.108 clang-1500.0.40.1)
Xcode version info: Xcode 15.0.1 Build version 15A507
Deployment target: arm64-apple-macosx14.0
以下のコードでMainActor.shared.assertIsolated()
が失敗する。
@MainActor
func doSomething() async {
await withUnsafeContinuation { continuation in
if #available(macOS 14.0, *) {
MainActor.shared.assertIsolated()
}
continuation.resume(returning: ())
}
}
await Task.detached {
await doSomething()
}.value
withUnsafeContinuation
は呼び出し元のExecutorを引き継ぐ仕様のはずが、引き継いでいないことがわかります。
@MainActor
func doSomething() async {
// ここはMainActorの executor
await withUnsafeContinuation { continuation in
// ここもMainActorの executorのはずが、generic executorになっている
MainActor.shared.assertIsolated()
continuation.resume(returning: ())
}
}
await Task.detached {
// ここはgeneric executor
await doSomething()
}.value
バグの原因
SILOptimizer
のOptimizeHopToExecutorPass
に存在するDead hop_to_executor elimination
が@_unsafeInheritExecutor
を想定できていない問題。
@_unsafeInheritExecutor
SE-0338 Clarify the Execution of Non-Actor-Isolated Async Functions
Swift5.7からexecutorの引き継ぎ条件が変わりました。
once a task switches to an actor's executor, they will remain there until either the task suspends or it needs to run on a different actor. But if a task suspends within a non-actor-isolated function for a different reason than a call or return, it will generally resume on a non-actor executor.
タスクがサスペンドするか、別のアクターで実行する必要があるまで、タスクはそこに留まります。しかし、タスクがコールやリターンとは異なる理由で非アクタ分離関数内でサスペンドした場合、通常、非アクタエクゼキュータ上で再開します。
Swift 5.7未満では「awaitしてsuspendする」か「isolationされている関数を呼び出す」をしない限りActorは変わりませんでした。(stickey)
しかし、この挙動だと以下のような問題がありました。
It can lead to unexpected "overhang", where an actor's executor continues to be tied up long after it was last truly needed. An actor's executor can be surprisingly inherited by tasks created during this overhang, leading to unnecessary serialization and contention for the actor.
It also becomes unclear how to properly isolate data in such a function: some data accesses may be safe because of the executor the function happens to run on dynamically, but it is unlikely that this is guaranteed by the system
- 本来何かのdata raceを防止するために作ったActorが、関係ないタスクまで延長されて、本来やりたい処理が遅れる=overhangされることがある。
- 関数(のある部分の)isolationが動的に変化することで、実際にはsafeなアクセスができるかもしれないが保証はできないケースが存在してわかりにくい。
actor-isolated
async
functions always formally run on the actor's executor
non-actor-isolatedasync
functions never formally run on any actor's executor
そのため、Swift 5.7以降ではactorにisolatedされていないasync関数は絶対に「どのアクターのexecutorでも実行されない」=「generic executorで実行される」ようになりました。
この時、with*Continuation
は呼び出し元のexecutorを引き継いだままにしたいため、stickeyな挙動を例外的に適応するattribute @_unsafeInheritExecutor
が作られ、with*Continuation
に追加されました。
OptimizeHopToExecutorPass
余分なhop_to_executor
を削除してオーバヘッドを減らすことを目的にした最適化です。複数最適化が存在しますが、バグの原因の最適化だけを紹介します。
Dead hop_to_executor elimination
Dead hop_to_executor elimination: if a hop_to_executor is not followed by
any code which requires to run on its actor's executor, it is eliminated:
\code
hop_to_executor %a
... // no instruction which require to run on %a
return
\endcode
hop_to_executor %a
の後のコードが%aのexecutorを必要としない場合hop_to_executor %a
を削除するという最適化。
「async関数の呼び出しはexecutorを必要としない」として実装されています。
例
@MainActor
func A() async {
// ここでMainActorのexecutorにhopしても
// await で別のexecutorにhopされるので無駄になる
// よって、最適化によってここのMainActorのexecutorへのhopを消す
await B()
}
func B() async {
}
Dead hop_to_executor elimination
が@_unsafeInheritExecutor
を想定できていない
通常Dead hop_to_executor elimination
の最適化は問題ありませんが
一方で@_unsafeInheritExecutor
が絡んでくると話は変わります。
@_unsafeInheritExecutor
が付いている関数はSwift5.7未満のstickeyな挙動を取るので、上記の最適化を当てはめてしまうと、問題になります。
@MainActor
func doSomething() async {
// 最適化によってここのMainActorのexecutorへのhopを消す
await withUnsafeContinuation { continuation in
// ここでは新しくexecutorにhopを行わない(stickey, 呼び出し元のexecutorを引き継ぐ)
// しかし、呼び出し元でMainActorへのhopが消えているため
// ここがMainActorのexecutorではなくなってしまう
MainActor.shared.assertIsolated()
continuation.resume(returning: ())
}
}
await Task.detached {
// ここはgeneral executor
await doSomething()
}.value
実際の出力(抜粋)
raw出力(最適化なし)
// swiftc -emit-silgen main.swift
// doSomething()
sil hidden [ossa] @$s4main11doSomethingyyYaF : $@convention(thin) @async () -> () {
bb0:
%0 = metatype $@thick MainActor.Type // user: %2
// function_ref static MainActor.shared.getter
%1 = function_ref @$sScM6sharedScMvgZ : $@convention(method) (@thick MainActor.Type) -> @owned MainActor // user: %2
%2 = apply %1(%0) : $@convention(method) (@thick MainActor.Type) -> @owned MainActor // users: %14, %3
%3 = begin_borrow %2 : $MainActor // users: %13, %10, %4
// 1. MainActorのexecutorにhop
hop_to_executor %3 : $MainActor // id: %4
%5 = alloc_stack $() // users: %12, %11, %9
// 2. withUnsafeContinuationを呼ぶ
// function_ref closure #1 in doSomething()
%6 = function_ref @$s4main11doSomethingyyYaFySccyyts5NeverOGXEfU_ : $@convention(thin) @substituted <τ_0_0> (UnsafeContinuation<τ_0_0, Never>) -> () for <()> // user: %7
%7 = thin_to_thick_function %6 : $@convention(thin) @substituted <τ_0_0> (UnsafeContinuation<τ_0_0, Never>) -> () for <()> to $@noescape @callee_guaranteed @substituted <τ_0_0> (UnsafeContinuation<τ_0_0, Never>) -> () for <()> // user: %9
// function_ref withUnsafeContinuation<A>(_:)
%8 = function_ref @$ss22withUnsafeContinuationyxySccyxs5NeverOGXEYalF : $@convention(thin) @async <τ_0_0> (@guaranteed @noescape @callee_guaranteed @substituted <τ_0_0> (UnsafeContinuation<τ_0_0, Never>) -> () for <τ_0_0>) -> @out τ_0_0 // user: %9
%9 = apply %8<()>(%5, %7) : $@convention(thin) @async <τ_0_0> (@guaranteed @noescape @callee_guaranteed @substituted <τ_0_0> (UnsafeContinuation<τ_0_0, Never>) -> () for <τ_0_0>) -> @out τ_0_0
// 3. MainActorのexecutorに再度hop(戻ってくる)
hop_to_executor %3 : $MainActor // id: %10
%11 = load [trivial] %5 : $*()
dealloc_stack %5 : $*() // id: %12
end_borrow %3 : $MainActor // id: %13
destroy_value %2 : $MainActor // id: %14
%15 = tuple () // user: %16
return %15 : $() // id: %16
} // end sil function '$s4main11doSomethingyyYaF'
canonical出力(最適化あり)
// swiftc -emit-sil main.swift
// doSomething()
sil hidden @$s4main11doSomethingyyYaF : $@convention(thin) @async () -> () {
bb0:
%0 = metatype $@thick MainActor.Type // user: %2
// function_ref static MainActor.shared.getter
%1 = function_ref @$sScM6sharedScMvgZ : $@convention(method) (@thick MainActor.Type) -> @owned MainActor // user: %2
%2 = apply %1(%0) : $@convention(method) (@thick MainActor.Type) -> @owned MainActor // users: %10, %8
%3 = alloc_stack $() // users: %9, %7
// MainActor executorへのhopがない!!
// 1. withUnsafeContinuationを呼ぶ
// function_ref closure #1 in doSomething()
%4 = function_ref @$s4main11doSomethingyyYaFySccyyts5NeverOGXEfU_ : $@convention(thin) @substituted <τ_0_0> (UnsafeContinuation<τ_0_0, Never>) -> () for <()> // user: %5
%5 = thin_to_thick_function %4 : $@convention(thin) @substituted <τ_0_0> (UnsafeContinuation<τ_0_0, Never>) -> () for <()> to $@noescape @callee_guaranteed @substituted <τ_0_0> (UnsafeContinuation<τ_0_0, Never>) -> () for <()> // user: %7
// function_ref withUnsafeContinuation<A>(_:)
%6 = function_ref @$ss22withUnsafeContinuationyxySccyxs5NeverOGXEYalF : $@convention(thin) @async <τ_0_0> (@guaranteed @noescape @callee_guaranteed @substituted <τ_0_0> (UnsafeContinuation<τ_0_0, Never>) -> () for <τ_0_0>) -> @out τ_0_0 // user: %7
%7 = apply %6<()>(%3, %5) : $@convention(thin) @async <τ_0_0> (@guaranteed @noescape @callee_guaranteed @substituted <τ_0_0> (UnsafeContinuation<τ_0_0, Never>) -> () for <τ_0_0>) -> @out τ_0_0
hop_to_executor %2 : $MainActor // id: %8
dealloc_stack %3 : $*() // id: %9
strong_release %2 : $MainActor // id: %10
%11 = tuple () // user: %12
return %11 : $() // id: %12
} // end sil function '$s4main11doSomethingyyYaF'
SIL全文
修正方法
@_unsafeInheritExecutor
をwithUnsafeContinuation
から削除し、代わりにisolated
引数と#isolation
を用いてActor(のexecutor)を引き継ぐ。
isolated引数 (Swift 5.5〜)
SE-0313 Improved control over actor isolation
Actorの外に書いた関数・メソッドでも、特定のActorへのisolateを実現するための機能です。
Global Actorの場合は@MainActor
のように関数・メソッドにattributeを付ければ解決しますが、Global Actorに限らないActor全体に対するアプローチです。
actor BankAccount {
let accountNumber: Int
var balance: Double
init(accountNumber: Int, initialDeposit: Double) {
self.accountNumber = accountNumber
self.balance = initialDeposit
}
// Actor内のメソッドも(isolated Self) -> (Double) -> ()として扱われる
func deposit(amount: Double) {
assert(amount >= 0)
balance = balance + amount
}
}
// 型の前にisolatedキーワードをつける(isolated引数)
// 関数はisolated引数のActorによってisolateされる
func deposit(amount: Double, to account: isolated BankAccount) {
assert(amount >= 0)
// accountのActor referenceでisolateされているので同期的にアクセスできる
account.balance = account.balance + amount
}
SILレベルの話をすると、isolated
引数を持つasync関数は、関数の冒頭でその引数の値にhop_to_executor
します。
#isolation
(Swift 6.0〜)
SE-0420 Inheritance of actor isolation
関数呼び出しでisolationが変わるとレシーバーや引数・帰り値でSendableが必要になるなどの制約が生まれるため、関数呼び出し元のisolationを引き継げるようにする(isolated引数に現在のisolationのActorを渡す)アプローチです。
- isolated引数でOptionalを取れるように拡張してisolationを抽象化
-
nil
がnonisolated
を表す
-
- 関数の呼び出し元のisolationのActorを表す特殊マクロ
#isolation
を追加
// NonSendable
final class RemoteCounter {
var count: Int?
// #isolation が呼び出し元と同じisolationになるような適切な式を生成する
func sync(isolation: isolated (any Actor)? = #isolation) async -> Int {
let newCount = await ...
count = newCount
return newCount
}
}
@MainActor
struct SomeView {
let counter = RemoteCounter()
func doSomething() async {
// MainActorのisolationがsyncに引き継がれるのでOK、data raceなし
await counter.sync()
// MainActorのisolationが引き継がれないのでダメ
await counter.sync(isolation: nil)
}
}
@_unsafeInheritExecutor
の代わりに#isolation
@_unsafeInheritExecutor
の代わりに#isolation
を使えばSwiftの言語機能上でActor(のexecutor)を引き継ぐことができます。
@_unsafeInheritExecutor
のアプローチでは、withUnsafeContinuation
自体はhop_to_executor
を行わないため(stickeyな挙動)「withUnsafeContinuation
の呼び出し前にhop_to_executor
が存在するか」が焦点でしたが
isolation: isolated (any Actor)? = #isolation
のアプローチでは、withUnsafeContinuation
内冒頭にhop_to_executor
が追加されるため、呼び出し元の最適化: Dead hop_to_executor elimination
の影響はなくなり、今回のバグは解消されます。
@MainActor
func doSomething() async {
// ここでMainActorのexecutorにhopしなくても
await withUnsafeContinuation { continuation in
// ここで、isolated引数によってMainActorのexecutorにhopされるので
// 呼び出し元の最適化はもう関係ない
if #available(macOS 14.0, *) {
MainActor.shared.assertIsolated()
}
continuation.resume(returning: ())
}
}
await Task.detached {
await doSomething()
}.value
実際にPRでもwithUnsafeContinuation
に以下のような変更が加えられています。
@available(SwiftStdlib 5.1, *)
- @_unsafeInheritExecutor
@_alwaysEmitIntoClient
public func withUnsafeContinuation<T>(
+ isolation: isolated (any Actor)? = #isolation,
_ fn: (UnsafeContinuation<T, Never>) -> Void
) async -> T {
return await Builtin.withUnsafeContinuation {
fn(UnsafeContinuation<T, Never>($0))
}
}
SIL
実際に修正PRマージ後のビルドでSILがどう変わったかみてみます。
環境
TOOLCHAINS=org.swift.600202404301a swift --version
Apple Swift version 6.0-dev (LLVM 7b8e6346027d2b1, Swift 763421cee31dc8f)
Target: arm64-apple-macosx14.0
withUnsafeContinuation
冒頭にhop_to_executor
が追加されていることがわかります。
// withUnsafeContinuation<A>(isolation:_:)
sil shared [available 12.0.0] @$ss22withUnsafeContinuation9isolation_xScA_pSgYi_ySccyxs5NeverOGXEtYalF : $@convention(thin) @async <T> (@sil_isolated @guaranteed Optional<any Actor>, @guaranteed @noescape @callee_guaranteed @substituted <τ_0_0> (UnsafeContinuation<τ_0_0, Never>) -> () for <T>) -> @out T {
// %0 // user: %9
// %1 // user: %3
// %2 // user: %7
bb0(%0 : $*T, %1 : $Optional<any Actor>, %2 : $@noescape @callee_guaranteed @substituted <τ_0_0> (UnsafeContinuation<τ_0_0, Never>) -> () for <T>):
// hop_to_executorがある!!
hop_to_executor %1 : $Optional<any Actor> // id: %3
// function_ref closure #1 in withUnsafeContinuation<A>(isolation:_:)
%4 = function_ref @$ss22withUnsafeContinuation9isolation_xScA_pSgYi_ySccyxs5NeverOGXEtYalFyBcXEfU_ : $@convention(thin) <τ_0_0> (Builtin.RawUnsafeContinuation, @guaranteed @noescape @callee_guaranteed @substituted <τ_0_0> (UnsafeContinuation<τ_0_0, Never>) -> () for <τ_0_0>) -> () // user: %7
%5 = alloc_stack $T // users: %10, %9, %6
%6 = get_async_continuation_addr T, %5 : $*T // users: %8, %7
%7 = apply %4<T>(%6, %2) : $@convention(thin) <τ_0_0> (Builtin.RawUnsafeContinuation, @guaranteed @noescape @callee_guaranteed @substituted <τ_0_0> (UnsafeContinuation<τ_0_0, Never>) -> () for <τ_0_0>) -> ()
await_async_continuation %6 : $Builtin.RawUnsafeContinuation, resume bb1 // id: %8
bb1: // Preds: bb0
copy_addr [take] %5 to [init] %0 : $*T // id: %9
dealloc_stack %5 : $*T // id: %10
%11 = tuple () // user: %12
return %11 : $() // id: %12
} // end sil function '$ss22withUnsafeContinuation9isolation_xScA_pSgYi_ySccyxs5NeverOGXEtYalF'
SIL全文
Swift 6.0でリリース
6.0ブランチにもcherry-pickが入り、Swift6.0で修正されそうです。祝!
Discussion
記事についての質問にツイッターで答えてもらいました