内部実装から理解するSwift Concurrency — 4. async関数の中断時に処理がスケジューリングされる仕組み
本記事は、「内部実装から理解するSwift Concurrency」シリーズの、4つ目の記事である。
第1回の記事で、SwiftコードはSwift Intermediate Language (SIL) を経て、LLVM IRに変換されること、そしてSILのhop_to_executor命令によってasync関数の中断・再開時のスレッドの切り替えが行われることを紹介した。
今回は、このhop_to_executor命令の内部実装を深掘りすることで、async関数の中断・再開の仕組みの理解を深めることを目指す。
この記事のスコープとしては、async関数の中断時に処理がスケジューリングされる仕組みにフォーカスしている。
SILのhop_to_executor命令の仕様
まずは、SILのhop_to_executorの仕様をおさらいする。
hop_to_executorは、以下のようなシンタックスで利用される[1]。
sil-instruction ::= 'hop_to_executor' sil-operand
hop_to_executor %0 : $T
// $T must be Builtin.Executor or conform to the Actor protocol
以下に、SwiftのGitHubリポジトリに書かれている説明を引用する。
Ensures that all instructions, which need to run on the actor's executor actually run on that executor. This instruction can only be used inside an
@asyncfunction.
Checks if the current executor is the one which is bound to the operand actor. If not, begins a suspension point and enqueues the continuation to the executor which is bound to the operand actor.
すなわち、hop_to_executorは、async関数内のみで使用可能な命令で、指定されたExecutorで実際に命令が実行されるよう、必要に応じてExecutorを切り替える。
現在のExecutorが、指定されたExecutorと合致しているかをチェックする。そして合致していない場合には、そのExecutor上での処理を中断し、後続の処理 (continuation) を、ActorあるいはTaskで設定されたExecutorに対してenqueueする。
以降は、上述したhop_to_executorによるExecutorのチェック・切り替えが、どのような実装で行われているのかを、ソースコードから追っていく。
hop_to_executor命令の実装を追う
hop_to_executor命令の実装は、IRGenSILFunction::visitHopToExecutorInstという関数にある。今回調査する「実行スレッドの切り替え」は、最後に呼ばれるemitSuspensionPoint内で行われている。
IRGenFunction::emitSuspensionPointの実装
emitSuspensionPointは、その名の通り、suspension point (中断地点) となるLLVM IRを生成 (emit) する関数である。emitSuspensionPointの実装を以下に示す。
今回重要なのは、L2985にあるcreateAsyncSuspendFnを呼んでいる部分である。
ここでは、サスペンド (中断) 用の補助関数 (suspendFn) が生成されている。
このサスペンド関数とは、現在実行されている関数 (Caller) を中断し、再開に必要な継続 (Continuation) を次の実行主体 (Callee) に渡しつつ、以降の制御を Callee 側に移すための関数である。
サスペンド関数は、LLVMの@llvm.coro.suspend.async組み込み関数に引数として渡されることとなる[3]。
call {ptr, ptr, ptr} (ptr, ptr, ...) @llvm.coro.suspend.async(
ptr %resume_func_ptr,
ptr %context_projection_function,
ptr %suspend_function, // 👈 サスペンド関数が渡される
ptr %arg1, ptr %arg2, i8 %arg3)
そして、サスペンド関数は、実際に中断が発生する時に実行される。
このサスペンド関数内で、Executorのチェックと処理のエンキューが行われている。
そこで、その実装を確かめるべく、次はサスペンド関数の処理を定義するcreateAsyncSuspendFnの実装を確かめる。
IRGenFunction::createAsyncSuspendFnの実装
IRGenFunction::createAsyncSuspendFnの実装を以下に示す。
重要なのは、L3055-L3060の部分である。ここで、Executorの切り替え処理を行うLLVM IRが生成される。
以下の部分で、swift_task_switchを呼び出す命令が生成されている。
このswift_task_switchは、ランタイム関数の一種である。ランタイム関数とは、コンパイル時にコンパイラが生成するコードから呼び出される、実行時に動作する低レベルの関数である。
swift_task_switchは、その名が示す通り、Executorの切り替えを行うためのランタイム関数である。次のセクションでは、このswift_task_switchの実装を確認する。
swift_task_switchの実装
swift_task_switchは、Executorの切り替えを行うためのランタイム関数である。実装を以下に示す。
重要なパートをピックアップして解説する。
関数定義
以下の3つの引数を取る。
-
resumeContext: 再開時に使用されるコンテキスト (スタックやローカル変数等のデータ) -
resumeFunction: Executorの切り替え後の、処理の再開時に呼ばれる関数のポインタ -
newExecutor: 切り替え先のExecutor
Executorの切り替えが不要な場合にインラインで実行する
mustSwitchToRun関数でExecutorの切り替えが必要かをチェックする。
切り替えが不要な場合には、そのままインライン実行する。これにより、余分な非同期化・コンテキストスイッチが避けられる。
mustSwitchToRun関数の実装は、以下のようになっている。
ここで、SerialExecutorとは、Actorに紐づくExecutorである。
すなわち、currentSerialExecutorとnewSerialExecutorが異なるケースは、Actorが変更されているケースに相当する。そのため、SerialExecutor が異なる場合には、mustSwitchToRunはtrueとなり、Executorの切り替えが行われる。
一方、TaskExecutorとは、withTaskExecutorPreference[4]等により、Taskに対して設定されるExecutorである。
Actorによって設定されたSerialExecutorの利用は絶対であるのに対して、TaskExecutorの設定はあくまで "preference" であり、絶対ではない (すなわちSerialExecutorよりも優先度が低い)。
SerialExecutorが同一であっても、currentTaskExecutorとnewTaskExecutorが異なる場合には、mustSwitchToRunはtrueとなり、Executorの切り替えが行われる。
中断前後でSerialExecutorとTaskExecutorの両方が合致している場合のみ、Executorの切り替えはスキップされ、後続の関数が同期的に実行される。
同一スレッド上でのExecutorの切り替え (Thread Handoff)
Executorを切り替える必要がある場合でも、可能あれば現在のスレッドをそのまま使いつつ、新しいExecutorに切り替えて、後続の関数を同期的に実行する。
「可能あれば」という部分の具体的な条件については、長くなるためここでは説明を省く。
これにより、別スレッドへの切り替えによるコンテキストスイッチを回避できる。
非同期処理のエンキュー
上述したfast pathに該当しない場合には、中断後の処理が次のExecutorに対してエンキューされ、非同期的に実行される。
以上より、Executorあるいはスレッドの切り替えが不要な場合には、同期的に実行され、非同期処理によるオーバーヘッドの発生が回避されることがわかる。
次は、処理のExecutorへのエンキューを行う関数であるflagAsAndEnqueueOnExecutorの詳細を見ていく。
AsyncTask::flagAsAndEnqueueOnExecutorの実装
AsyncTask::flagAsAndEnqueueOnExecutorの実装を以下に示す。
こちらは2つのパートに分けて説明する。
TaskDependencyStatusRecordの設定
タスクは**TaskDependencyStatusRecord**と呼ばれるものを最大1つ持っている。
Executorにエンキューされる前に、タスクが持つTaskDependencyStatusRecordが更新される。以下の部分のコードが、その処理に相当する。
TaskDependencyStatusRecordとは、実行待ちのタスクが、何の完了を待っているのかを示すオブジェクトである。タスクには別のタスクやExecutorなどとの依存関係があり、Executorによる実行待ちの場合もあれば、別のタスクの完了待ちの場合もある。
参考までに、TaskDependencyStatusRecordのソースコードの一部を以下に示す。
タスクが依存する対象として、TaskやTaskGroup、Continuation、Executorがあることが分かる。
swift_task_enqueueの実行
最後に、swift_task_enqueueが実行される。
これは、その名の通り、Executorへのタスクのエンキューを行う。swift_task_switchと同様に、ランタイム関数の一種である。次のセクションで、swift_task_enqueueの実装を解説する。
swift_task_enqueueImplの実装
swift_task_enqueueは、タスクを適切なExecutorにスケジューリングする役割を持ちます。
実装 (swift_task_enqueueImpl) のソースコードを以下に示す。
ActorあるいはTaskに設定されたExecutorによって、スケジューリングの方法が変わる。
Executorの種別に応じた、処理の分岐を以下に図示する。

swift_task_enqueueの実装 - Executorによる処理の分岐
なお、ここで最終的に呼ばれる各メソッド (_swift_task_enqueueOnTaskExecutor・swift_task_enqueueGlobal・swift_defaultActor_enqueue・_swift_task_enqueueOnExecutor) の内部実装に関しては、別の記事で解説する。
TaskでTaskExecutorが設定されている場合
Taskで実行されている場合 (actorで実行されていない場合)、serialExecutorRef.isGeneric()がtrueとなる。また、TaskExecutorが設定されている場合、taskExecutorRef.isDefined()がtrueとなる。
このパターンでは_swift_task_enqueueOnTaskExecutorによって処理がスケジューリングされる、
TaskでTaskExecutorが設定されていない場合
Taskで実行されていて、かつTaskExecutorが設定されていない (taskExecutorRef.isDefined()がfalse) の場合、swift_task_enqueueGlobalによって処理がスケジューリングされる。
actorでSerialExecutorが設定されていない場合
actorで実行されている場合、先述したserialExecutorRef.isGeneric()はfalseとなる。
また、カスタムのSerialExecutorが設定されていない場合、serialExecutorRef.isDefaultActor()がtrueとなる。この場合、swift_defaultActor_enqueueによって処理がスケジューリングされる。
actorでSerialExecutorが設定されている場合 (@MainActorでの実行も含む)
actorで実行されている、かつカスタムのSerialExecutorが設定されている場合 (serialExecutorRef.isDefaultActor()がfalseの場合)、_swift_task_enqueueOnExecutorによって処理がスケジューリングされる。
@MainActorで実行されている場合も、このケースに該当する。
以上、SILのhop_to_executor命令の内部実装を深掘りし、async関数の中断時に、別のExecutorへのスケジューリングが行われる仕組みを見てきた。
次の記事では、各メソッド (_swift_task_enqueueOnTaskExecutor・swift_task_enqueueGlobal・swift_defaultActor_enqueue・_swift_task_enqueueOnExecutor) によって、Executorに処理がエンキューされる際のプラットフォーム固有の実装、すなわち実際にどのようにOSスレッドのスケジューリングが行われるのかを解明していく予定である。
-
hop_to_executor: https://github.com/swiftlang/swift/blob/0f75a3a69e0bd6e182aba383ad998c72e3696235/docs/SIL/Instructions.md?plain=1#L276 ↩︎ -
Swift ConcurrencyにおけるActorとExecutorの関係性: https://zenn.dev/kaseken/articles/d34b112d132ef1 ↩︎
-
Async Lowering: https://llvm.org/docs/Coroutines.html#async-lowering ↩︎
-
withTaskExecutorPreference: https://developer.apple.com/documentation/swift/withtaskexecutorpreference(_:isolation:operation:) ↩︎
Discussion