Swift Digest
SE-0338 | Swift Evolution

Clarify the Execution of Non-Actor-Isolated Async Functions

Proposal
SE-0338
Authors
John McCall
Review Manager
Doug Gregor
Status
Implemented (Swift 5.7)

01 何が問題だったのか

SE-0306 でアクターが、SE-0296 で async/await が導入されましたが、どちらの Proposal も「アクターに isolate されていない async 関数がどのエグゼキュータで実行されるのか」を明確に規定していませんでした。Swift 5.5 時点の実装では、そうした関数は意図的にエグゼキュータを切り替えることがなく、呼び出された時点の current executor の上で動き続けていました。

動的で読み取りづらい実行ルール

アクターに isolate された async 関数は、呼び出しや中断からの再開のたびにそのアクターのエグゼキュータへ戻るように実装されています。その結果、タスクは一度アクターのエグゼキュータに乗ると、中断するか別のアクターへ移る必要があるまでそこに「張り付き」ます。一方で、non-actor-isolated な async 関数はそういった切り替えを行わないため、呼び出された時点のエグゼキュータをそのまま使い続けます。

このルールには以下のような困りごとがあります。

  • アクターから呼び出された non-actor-isolated な関数が長く走り続けると、本来アクター上で動く必要のない処理のあいだもアクターのエグゼキュータが占有され、overhang(アクターの抱え込み)が発生します。
  • その overhang 中に Task イニシャライザで新しいタスクを作ると、意図せずアクターのエグゼキュータを継承してしまい、競合や直列化を招きます。
  • 関数内で扱う値の isolation が「たまたま乗っていたエグゼキュータ」に依存することになり、静的に保証できません。

どのエグゼキュータで動くかはパフォーマンスだけでなく、どの値を安全に触れるか、Task で作ったタスクが何を継承するかといった意味論にも直結します。そのため、Swift には「このコードはどのエグゼキュータ上で動くのか」を直感的に理解できるルールが必要です。

sendability チェックの穴

加えて、non-actor-isolated な async 関数の呼び出しに対する Sendable チェックには、そもそも既存ルールの時点で穴があります。現状のルールでは、アクターに isolate された async 関数を別アクター(あるいは非アクター)から呼ぶときは引数と戻り値が Sendable であることが要求されますが、non-actor-isolated な async 関数にはその制約がかかっていません。

actor MyActor {
  var isolated: NonSendableValue

  func inside_one() async {
    await outside(argument: isolated)
  }

  func inside_two() async {
    isolated.operate()
  }
}

func outside(argument: NonSendableValue) async {
  await Task.sleep(nanoseconds: 1_000)
  // ここに到達した時点で、argument はもうアクターのエグゼキュータ上で
  // 動いている保証がないため、inside_two() の isolated.operate() と
  // 並行に触られる可能性がある。
  argument.operate()
}

outside は現行の実行ルールでも提案する新しい実行ルールでも、どこかで元のアクターから離れ得ます。それにもかかわらず non-Sendable な値をそのまま渡せてしまうため、データ競合が発生する余地が残されています。

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

本Proposalは、実行ルールと Sendable ルールの両方を整理します。まず、non-actor-isolated な async 関数は「どのアクターにも属さない汎用のエグゼキュータ(generic executor)で動く」と明確に定め、Sendable ルールもそれに合わせて引き締めます。

なお、SE-0338 によって定められたこの「呼び出しごとに汎用エグゼキュータへ切り替わる」挙動は、後に SE-0461 によって「デフォルトでは呼び出し元のアクターに留まる」方向へ見直されます。本Proposalの内容は Swift 5.7 から SE-0461 の導入前までの挙動として理解しておくとよいでしょう。

汎用エグゼキュータへの切り替え

async 関数のエグゼキュータは静的に次のように決まります。

  • actor-isolated な async 関数は、常にそのアクターのエグゼキュータで動く。
  • non-actor-isolated な async 関数は、どのアクターにも属さない汎用のエグゼキュータで動く。

後者では、次の3つのタイミングでエグゼキュータが汎用のものへ切り替わります。

  • その関数が呼び出されたとき
  • その関数が行った async 呼び出しから戻ったとき
  • withContinuation などの内部的な中断から再開したとき

切り替わる結果、アクターから呼び出された場合はその時点でアクターを手放すため、overhang は発生しません。

extension MyActor {
  func update() async {
    // actor-isolated なので、呼ばれた瞬間にアクターのエグゼキュータへ切り替わる。

    let update = await session.readConsistentUpdate()

    // 呼び出しから戻ったので、再びアクターのエグゼキュータへ戻る。
    name = update.name
    age = update.age
  }
}

extension MyNetworkSession {
  func readConsistentUpdate() async -> Update {
    // non-actor-isolated なので、呼ばれた瞬間に汎用エグゼキュータへ切り替わる。
    // アクターから呼ばれていた場合は、ここでアクターを手放す。

    var update: Update?
    for _ in 0..<1000 {
      let newUpdate = await readUpdateOnce()
      // 戻ってきた時点でも汎用エグゼキュータへ戻る。
      if update == newUpdate { break }
      update = newUpdate
    }
    return update!
  }
}

ここでの「エグゼキュータ」はあくまで formal なもので、実際の挙動は静的・動的な最適化の対象です。対象の関数が大した処理をせずにすぐ返る・中断する・別の async を呼ぶ場合は、そのまま呼び出し側のエグゼキュータに乗り続けても構いません。また、すでに目的のエグゼキュータ上にいるときは切り替えが suspend を伴わないといった最適化も引き続き行われます。ただし、たとえば同じアクターに対して連続で2回呼び出すようなケースでは、実装がアクターを解放しないまま続けて実行する可能性があり、別のタスクが割り込めない場面が観測できることがあります。

Sendable ルールの引き締め

async 呼び出しでは、引数と戻り値は次のいずれかを満たさない限り Sendable でなければなりません。

  • 呼び出し元と呼び出し先が、ともに同じアクターに isolate されていることが静的にわかっている。
  • 呼び出し元と呼び出し先が、ともに non-actor-isolated であることが静的にわかっている。

これにより、先ほどの outside(argument:) の例は、argument が non-Sendable な値なのでコンパイルエラーになります。アクターから外へ持ち出そうとしている時点で弾かれるため、再開時にアクターを離れていても安全です。

この変更は、concurrency をすでに使っているコードに対しては source breaking になり得ます。また、本Proposalの実行ルールに合わせて「アクター上の値を non-actor-isolated な関数へ素通しで渡していた」ようなコードは、新しいルールの下では暗黙にデータ競合を含むことになるため、少なくとも警告で知らせる方針です。

今後の方向性

今回のルールを出発点として、今後次のような拡張が検討され得ます。いずれも speculative なもので、実現を約束するものではありません。

  • 呼び出し元のエグゼキュータを明示的に継承する async 関数。関数シグネチャで表明する形になり、reasync のような機能と相性がよいと考えられています。
  • 中断前のコードが current executor に依存していないことをオプティマイザに知らせ、不要なエグゼキュータ切り替えを削除できる手段。
  • 「アクターに isolate された値」と「タスクに isolate された値」を sendability ルール上で区別し、アクターに isolate された async 関数内での表現力を回復する方向の改良。