Clarify the Execution of Non-Actor-Isolated Async Functions
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関数内での表現力を回復する方向の改良。