😸

[Swift] withUnsafeContinuationがExecutorを引き継がないバグがSwift6.0で治った話と関連SE

2024/05/04に公開1

バグの内容

検証環境

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

https://github.com/apple/swift/issues/69659

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

バグの原因

SILOptimizerOptimizeHopToExecutorPassに存在する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-isolated async 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に追加されました。

https://github.com/apple/swift/pull/41376

OptimizeHopToExecutorPass

https://github.com/apple/swift/blob/swift-5.10-RELEASE/lib/SILOptimizer/Mandatory/OptimizeHopToExecutor.cpp

余分な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を必要としない」として実装されています。

https://github.com/apple/swift/blob/a2eb9e04cc8ae4d7e951924738ad6606cdfbfbb0/lib/SILOptimizer/Mandatory/OptimizeHopToExecutor.cpp#L314-L317

@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全文

https://gist.github.com/kntkymt/3f8af33b98f238128a0eba638b74b81d#file-sil_stage-canonical

修正方法

https://github.com/apple/swift/pull/72578

@_unsafeInheritExecutorwithUnsafeContinuationから削除し、代わりに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を抽象化
    • nilnonisolatedを表す
  • 関数の呼び出し元の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全文

https://gist.github.com/kntkymt/3f8af33b98f238128a0eba638b74b81d#file-fixed-sil_stage-canonical

Swift 6.0でリリース

6.0ブランチにもcherry-pickが入り、Swift6.0で修正されそうです。祝!

https://github.com/apple/swift/pull/72830

Discussion