内部実装から理解するSwift Concurrency — 5. Default Actorにおけるジョブのスケジューリング
本記事は、「内部実装から理解するSwift Concurrency」シリーズの、5つ目の記事である。
前回の記事では、async関数の中断時に処理がスケジューリングされる仕組みを解読してきた。
以下のように、swift_task_enqueueというランタイム関数が呼ばれ、その内部でActor/Executorの種別に応じて、ジョブのエンキュー用のメソッド (_swift_task_enqueueOnTaskExecutor等) が呼ばれることが分かった。

swift_task_enqueueの実装 - Executorによる処理の分岐
本記事では、このうちDefault Actor (カスタムのSerialExecutorをデフォルトのactor) が使用されているケースに関して、swift_defaultActor_enqueueが呼ばれた先の処理を調査する。
Default Actorとは
Default Actorとは、カスタムのSerialExecutorを設定したりせずに、デフォルトの状態で定義されたアクターのことである。なお、@MainActorを指定している場合は、Default Actorに該当しない。
以下にDefault Actorの例を示す。
actor Counter {
var count = 0
func increment() {
count += 1
}
}
Default Actorの実装は、DefaultActorImplというクラスとして定義されている。以降の説明で、Default Actorに実装された各メソッドの詳細を見ていく。
Default Actorの初期化
Default Actorは、作成されたアクターのインスタンスごとに存在する。
例えば、以下のコードの場合、3つの独立したDefaultActorImplインスタンスが作成される。それぞれのDefaultActorImplが、独自のジョブキューを持っている。
// 3つの独立したアクターインスタンスを作成
let counter1 = Counter() // DefaultActorインスタンス #1
let counter2 = Counter() // DefaultActorインスタンス #2
let counter3 = Counter() // DefaultActorインスタンス #3
初期化時には、swift_defaultActor_initialize経由で、DefaultActorImplのinitializeメソッドが呼ばれる。
initializeのソースコードを読むと、prioritizedJobsとActiveActorStatusという2つの属性の初期化が行われていることが分かる。次は、これら2つについて説明する。
Default Actorのジョブキュー
先述したprioritizedJobsは、Default Actorで実行待ちのジョブを優先度順に管理する優先度付きFIFOキューである。このソースコードを以下に示す。
少々ややこしいことに、Default Actorは、このprioritizedJobsも含めて、2つの異なるキューを使用している。これら2つのキューについて説明する。
1つ目のキュー: LIFOキュー
説明の都合上、prioritizedJobsではなく、もう一方のキューから解説する。
1つ目のキューは、LIFO (Last-In-First-Out: 後入れ先出し) 順の単方向連結リストである。こちらはジョブの優先度を無視する。
このLIFOキューの先頭のジョブ (すなわち最後に入れられたジョブ) は、後述するActiveActorStatusのFirstJobフィールドとして保持されている。
このJobクラスの持つSchedulerPrivateフィールド (SchedulerPrivate[0]) に、その次のJobへのポインタが格納されている。
すなわち、以下の図に示すような、単方向連結リストとなっている。

Default Actorの単方向連結リストによるLIFOキュー
Default Actorにジョブがエンキューされると、初めにこちらのLIFOキューの先頭にジョブが追加される。このLIFOキューへのジョブの追加は、ロックフリーなアトミック操作である、かつこの時点では優先度順のソートも行われない、そのため、複数のスレッドからジョブの追加が可能であり、かつ非常に高速に完了する。
2つ目のキュー: 優先度付きキュー (prioritizedJobs)
2つ目のキューは、先述した優先度付きのFIFO (First-In-First-Out: 先入れ先出し) キューである。
LIFOキューは、複数のスレッドから操作が可能、かつロックフリーで高速であったのに対して、こちらの優先度付きFIFOキューは、アクターのロックによって保護されており、そのロックを保持している1つのスレッドのみ操作可能である。
アクターの実行スレッドは、LIFOキューへのジョブの追加を検知すると、LIFOから未処理のジョブ一覧を取得する。そして、このジョブ一覧を、FIFO順になるようリバースした上で、優先度付きFIFOキューへと追加する。
このように、「ジョブのLIFOへのエンキュー」と「ジョブの優先度順ソート」が別ステップに分けられることで、優先度付きFIFOキューへの追加をバッチ処理可能となり、効率化につながっている。
優先度付きFIFOキューの実装は、以下の図に示すように、優先度ごとにFIFOキューを持つような形となっている。

Default Actorの優先度付きFIFOキュー
ジョブの実行時には、DefaultActorImpl::drainOneメソッドにより、prioritizedJobsから最も優先度の高いジョブが取り出され、このジョブが実行される。優先度が同一のジョブが複数存在する場合にはFIFO順となる。
以上のように、Default Actorは「高速なLIFOキュー」と「低速なFIFOキュー」の2段階のキューを用いた構造によって、「処理の高速さ」と「優先度の制御」が両立されている。
Default ActorのActiveActorStatusとは
続いて、Default Actorの持つActiveActorStatusについて説明する。
DefaultActorImplには、_status()というメソッドがあり、これはActiveActorStatusのアクセサメソッドである。
swift::atomic<T>によってアトミックな操作が可能となっている。これにより、複数スレッドからのActorに対する操作 (ジョブのエンキューなど) が安全に可能となっている。
このActiveActorStatusは、その名の通り、アクターの状態を管理する変数である。
具体的には、以下のような情報を1つのアトミック変数として管理している。
- アクターの現在の状態 (アイドル状態、スケジュール済み、実行中、解放待ち)
- アクターにエンキューされたジョブの最大の優先度
- 現在アクターのロックを保持しているスレッドの識別子
- 現在エンキューされたジョブのリストへのポインタ (これは先述したLIFOキューの先頭のジョブへのポインタである)
続いて、Default Actorにジョブがエンキューされた際の動作を追っていく。これにより、先ほど説明した、LIFOキュー、優先度付きFIFOキュー、ならびにActiveActorStatusの役割に関する理解もさらに深まるだろう。
Default Actorへのジョブのエンキュー
swift_defaultActor_enqueueの実装
Default Actorへのジョブのエンキューは、swift_defaultActor_enqueueという関数で行われる。swift_defaultActor_enqueueのソースコードを以下に示す。
ここでは、DefaultActorImplのenqueueメソッドが呼ばれている。DefaultActorImpl::enqueueのソースコードを以下に示す。複雑な関数のため、重要な部分をピックアップして解説する。
LIFOキューへのジョブの追加
現在のActiveActorStatusが持つLIFOキュー (未処理ジョブの一覧) に対して、エンキューされたジョブが追加される。なお、while (true)ループ内で行われているのは、CAS (Compare-And-Swap) パターンで並行アクセスに対応するためである。
アクターの実行ステータスの更新
元々アイドル状態 (Idle) だった場合には、実行待ち (Scheduled) へとActiveActorStatusの状態を遷移させる。
CAS (Compare-And-Swap) でステータスを更新する
CASパターンで、ActiveActorStatusを更新する。
上記の変更中に、別スレッドで元々の_status()の値が変更されていた場合には、while (true)ループで更新処理をやり直す。
ジョブのスケジューリング
scheduleActorProcessJobが呼ばれ、ジョブのスケジューリングが行われる。
scheduleActorProcessJob内で、ProcessOutOfLineJobクラスのインスタンスが作られる。
その後、通常の場合、swift_task_enqueueGlobalが呼ばれ、グローバルキューに対してProcessOutOfLineJobがエンキューされる。
その後は実行環境に依存するが、macOS/iOS等ではDispatchQueueによるジョブのスケジューリングが行われる。ジョブの実行時にProcessOutOfLineJob::processが呼ばれる。
ProcessOutOfLineJob::processの実装
ProcessOutOfLineJob::processのソースコードを以下に示す。
ここでdefaultActorDrain(actor)が呼ばれているのがポイントである。
defaultActorDrainのソースコードを以下に示す。重要な部分をピックアップして解説する。
tryLockによるアクターのロックの取得
actor->tryLock(true)によって、アクターのロックが取得される。
ここで一つ重要な点として、tryLock内でhandleUnprioritizedJobsという関数が呼ばれている。
このhandleUnprioritizedJobsこそ、以前に説明した、LIFOキューから優先度付きFIFOキューへとジョブを移植する関数である。これによって、実行すべきタスクを優先度付きFIFOキューから取得できるようになる。
優先度付きFIFOキューからのジョブの取得
currentActor->drainOne()によって、優先度付きのFIFOキューから、最も優先度の高いジョブが取得される。
ジョブの実行
runJobInEstablishedExecutorContext(job)によって、そのジョブが実行される。
swift::runJobInEstablishedExecutorContextの実装を見ると、さらにrunInFullyEstablishedContextが呼ばれている。
runInFullyEstablishedContextの実装を見ると、ResumeTask(ResumeContext)が呼ばれている。
ResumeTaskとは、Taskを継続させる関数である。
すなわち、ResumeTask(ResumeContext)によって、ここで中断された関数のContinuation (後続の処理) が呼び出されて、処理が再開することとなる。
ジョブの取得・実行は、while (true)ループ内にある。
すなわち、優先度付きFIFOキューが空になるまで、スレッドはジョブの取得・実行を繰り返すこととなる。
新たに追加されたジョブの処理
while (true)ループ内で、processIncomingQueueという関数が呼ばれている。
これは、ジョブの取得・実行のループ中に、新たにLIFOキューに追加されたジョブを、優先度付きFIFOキューに移すためである。
processIncomingQueueの実装を見ると、tryLockと同様に、内部でhandleUnprioritizedJobsが呼ばれている。繰り返すと、これがLIFOキューから優先度付きFIFOキューにジョブを移す関数である。
ここまで、処理のバケツリレーが非常に複雑であったが、Default Actorが使用されている場合に関しては、これでasync関数が中断・再開するまでの流れを一通り確認できたことになる。
Default Actorにおける、ジョブのスケジューリングの仕組みの解説は、以上とする。
別の記事で、Main Actorで実行された場合や、Taskで実行された場合に関しても、ジョブがスケジューリングされる仕組みを解明する予定である。
Discussion