Swift Digest
SE-0471 | Swift Evolution

Improved Custom SerialExecutor isolation checking for Concurrency Runtime

Proposal
SE-0471
Authors
Konrad 'ktoso' Malawski
Review Manager
Doug Gregor
Status
Implemented (Swift 6.2)

01 何が問題だったのか

SE-0424 で、カスタムの SerialExecutorcheckIsolated() を実装することで、assumeIsolatedassertIsolated の動的 isolation チェックに参加できるようになりました。これによって、たとえば DispatchSerialQueue 上で直接動かされているコードからでも、アクターの isolated state に安全にアクセスできるようになっています。

ただし checkIsolated() は「pass or crash」型のAPIで、チェックに失敗した場合は必ず fatalError などでプログラムを停止させる必要があります。この設計には二つの限界があります。

ひとつめはエラーメッセージの情報量です。checkIsolated() の内部で dispatchPrecondition(condition: .onQueue(self.queue)) などを呼んで落ちるため、出力されるのはたいてい expected [...] 形式のメッセージだけで、「実際にどのアクター/エグゼキュータ上で動いていたのか」という、デバッグに一番必要な情報が欠けがちです。

final class ExampleExecutor: SerialExecutor {
  func checkIsolated() {
    dispatchPrecondition(condition: .onQueue(self.queue))
  }
}

ふたつめはチェックモードの柔軟性です。ライブラリが厳格な並行性チェックを段階的に導入する場合、いきなりクラッシュさせるのではなく、まず警告を出しておいて利用者に修正の猶予を与えたいことがあります。ところが checkIsolated() は呼ばれた時点でクラッシュする契約のため、Swift ランタイム側が「チェックは走らせたいが、違反しても落としたくない」という場面では、そもそも checkIsolated() を呼びに行けません。結果として、カスタムエグゼキュータに対する isolation 違反の警告機能を Swift ランタイムが提供するのが不可能でした。

また、@isolated(any) クロージャなどから取り出した any Actor 値に対して、そのアクターの SerialExecutor を取り出す手段もなく、isolation を照会するための入口自体が限られていました。

02 どのように解決されるのか

SerialExecutor プロトコルに、新たなプロトコル要件 isIsolatingCurrentContext() を追加します。クラッシュせずに真偽値を返す設計なので、ランタイムはその結果を見て、クラッシュさせるか警告に留めるかを選べます。

protocol SerialExecutor {
  /// `checkIsolated()` よりも前に、ランタイムが isolation 違反を確認し、
  /// 必要に応じて警告を出すために呼び出すことがある。
  func isIsolatingCurrentContext() -> Bool?

  // SE-0424 で導入済み。違反時はクラッシュさせる契約
  @available(SwiftStdlib 6.0, *)
  func checkIsolated()
}

extension SerialExecutor {
  /// 後方互換のためのデフォルト実装
  func isIsolatingCurrentContext() -> Bool? { nil }
}

戻り値は Bool? で、nil は「判定不能」を意味します。デフォルト実装は nil を返すので、既存のエグゼキュータはこの要件を実装しなくてもコンパイルでき、従来どおり checkIsolated() にフォールバックします。

ランタイムのチェック順序

assumeIsolated() などが呼ばれたとき、Swift ランタイムは次の順に isolation を確認します。

  1. 現在のタスクが isolate されているエグゼキュータと、期待されるエグゼキュータを比較する(高速パス)。
  2. 期待側のエグゼキュータが isIsolatingCurrentContext() を実装している場合はそれを呼ぶ。
    • true を返せばチェック成功。
    • false を返せばチェック失敗。このとき checkIsolated() は呼ばれない。
    • nil を返せば判定不能として次へ進む。
  3. 上記で結論が出ず、かつ checkIsolated() が実装されていれば、それを呼ぶ(従来どおりクラッシュ or 成功)。
  4. どちらも実装されていなければ、ベストエフォートのメッセージでクラッシュする。

重要なのは、isIsolatingCurrentContext()true / false のいずれかを返した時点で、checkIsolated() はもう呼ばれないという点です。両方を実装している場合、isIsolatingCurrentContext() 側が実質的に checkIsolated() を置き換える形になります。

実装方針

新しくカスタムエグゼキュータを書く場合は、可能な限り isIsolatingCurrentContext() を実装するのが推奨です。ランタイムが「警告モード」で isolation を確認するとき(isolated conformance のチェックや isolation 違反の警告出力など、クラッシュが許容できない文脈)に使えるのはこちらだけで、checkIsolated() は呼ばれません。

final class MyQueueExecutor: SerialExecutor {
  let queue: DispatchSerialQueue

  // 新: true / false を返せる
  func isIsolatingCurrentContext() -> Bool? {
    // キュー上にいるかどうかをクラッシュさせずに判定
    return isOnQueue(queue)
  }
}

#if swift(>=...) などで分岐させ、古いツールチェインではこれまでどおり checkIsolated() を実装しておけば、両対応できます。isIsolatingCurrentContext() を実装するエグゼキュータが、同時に checkIsolated() を実装しておくことにも意味はあり、たとえば「真偽値を返すのは難しいが、違反したときにクラッシュさせることはできる」というエグゼキュータは checkIsolated() のみを実装する形になります。

従来の checkIsolated() は非推奨にはなりません。複数のSwiftバージョンを同時にサポートしているライブラリが無用な警告を受けずに済むようにするためで、将来的に非推奨化される可能性は残されています。

Actor から SerialExecutor を取り出す

any Actor から、その寿命に紐付いた SerialExecutor を取り出すための scoped なAPIも追加されます。

extension Actor {
  @_alwaysEmitIntoClient
  @available(SwiftStdlib 5.1, *)
  public nonisolated func withSerialExecutor<T, E: Error>(
    _ operation: (any SerialExecutor) throws(E) -> T
  ) throws(E) -> T

  @_alwaysEmitIntoClient
  @available(SwiftStdlib 5.1, *)
  public nonisolated func withSerialExecutor<T, E: Error>(
    _ operation: (any SerialExecutor) async throws(E) -> T
  ) async throws(E) -> T
}

クロージャの実行中はアクターが retain されるため、SerialExecutor の寿命はアクターの寿命に紐付きます。これと isIsolatingCurrentContext() を組み合わせることで、@isolated(any) クロージャから取り出したアクターに対して、「期待する isolation でなければ警告を出す」というコードをライブラリ側で書けるようになります。

func something(operation: @escaping @isolated(any) () -> ()) {
  operation.isolation.withSerialExecutor { se in
    if se.isIsolatingCurrentContext() != true {
      warn("'something' must be called from the same isolation as the operation closure is isolated to!" +
           "This will become a runtime crash in future releases of this library.")
    }
  }
}

このAPIはバックデプロイされ、concurrency ランタイムのバージョンに依存せず利用できます。

影響範囲

isIsolatingCurrentContext() を使うには新しい concurrency ランタイムが必要で、古いランタイムと組み合わさった場合は従来の checkIsolated() 経路にフォールバックします。既存コードはデフォルト実装のまま動くため、アップグレードに伴う破壊的な変化はなく、対応したエグゼキュータを使っている箇所で isolation 違反メッセージが詳しくなったり、警告モードが利用可能になったりする、という形で段階的にメリットが現れます。

Future Directions

isIsolated 相当のメソッドを ActorMainActor 自身の公開APIとして提供する案もありましたが、誤用を招きやすいという懸念からこのProposalには含まれていません。十分なユースケースが出てくれば将来的に再検討される可能性はありますが、現時点では speculative な方向性です。