🎭

内部実装から理解するSwift Concurrency — 5. Default Actorにおけるジョブのスケジューリング

に公開

本記事は、「内部実装から理解するSwift Concurrency」シリーズの、5つ目の記事である。

前回の記事では、async関数の中断時に処理がスケジューリングされる仕組みを解読してきた。

https://zenn.dev/kaseken/articles/3879be555133ba

以下のように、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に実装された各メソッドの詳細を見ていく。

https://github.com/swiftlang/swift/blob/47bf39ad50eafb3230b379778515b52d4dc1a1ec/stdlib/public/Concurrency/Actor.cpp#L1333-L1337

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経由で、DefaultActorImplinitializeメソッドが呼ばれる。

https://github.com/swiftlang/swift/blob/47bf39ad50eafb3230b379778515b52d4dc1a1ec/stdlib/public/Concurrency/Actor.cpp#L2270-L2272

https://github.com/swiftlang/swift/blob/47bf39ad50eafb3230b379778515b52d4dc1a1ec/stdlib/public/Concurrency/Actor.cpp#L1342-L1353

initializeのソースコードを読むと、prioritizedJobsActiveActorStatusという2つの属性の初期化が行われていることが分かる。次は、これら2つについて説明する。

Default Actorのジョブキュー

先述したprioritizedJobsは、Default Actorで実行待ちのジョブを優先度順に管理する優先度付きFIFOキューである。このソースコードを以下に示す。

https://github.com/swiftlang/swift/blob/47bf39ad50eafb3230b379778515b52d4dc1a1ec/stdlib/public/Concurrency/Actor.cpp#L1265-L1275

少々ややこしいことに、Default Actorは、このprioritizedJobsも含めて、2つの異なるキューを使用している。これら2つのキューについて説明する。

1つ目のキュー: LIFOキュー

説明の都合上、prioritizedJobsではなく、もう一方のキューから解説する。
1つ目のキューは、LIFO (Last-In-First-Out: 後入れ先出し) 順の単方向連結リストである。こちらはジョブの優先度を無視する。
このLIFOキューの先頭のジョブ (すなわち最後に入れられたジョブ) は、後述するActiveActorStatusFirstJobフィールドとして保持されている。

https://github.com/swiftlang/swift/blob/47bf39ad50eafb3230b379778515b52d4dc1a1ec/stdlib/public/Concurrency/Actor.cpp#L982-L996

このJobクラスの持つSchedulerPrivateフィールド (SchedulerPrivate[0]) に、その次のJobへのポインタが格納されている。

https://github.com/swiftlang/swift/blob/47bf39ad50eafb3230b379778515b52d4dc1a1ec/include/swift/ABI/Task.h#L98

https://github.com/swiftlang/swift/blob/47bf39ad50eafb3230b379778515b52d4dc1a1ec/stdlib/public/Concurrency/Actor.cpp#L1205-L1212

すなわち、以下の図に示すような、単方向連結リストとなっている。


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順となる。

https://github.com/swiftlang/swift/blob/47bf39ad50eafb3230b379778515b52d4dc1a1ec/stdlib/public/Concurrency/Actor.cpp#L1790-L1802


以上のように、Default Actorは「高速なLIFOキュー」と「低速なFIFOキュー」の2段階のキューを用いた構造によって、「処理の高速さ」と「優先度の制御」が両立されている。

Default ActorのActiveActorStatusとは

続いて、Default Actorの持つActiveActorStatusについて説明する。
DefaultActorImplには、_status()というメソッドがあり、これはActiveActorStatusのアクセサメソッドである。

https://github.com/swiftlang/swift/blob/47bf39ad50eafb3230b379778515b52d4dc1a1ec/stdlib/public/Concurrency/Actor.cpp#L1398-L1400

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のソースコードを以下に示す。

https://github.com/swiftlang/swift/blob/47bf39ad50eafb3230b379778515b52d4dc1a1ec/stdlib/public/Concurrency/Actor.cpp#L2278-L2284

ここでは、DefaultActorImplenqueueメソッドが呼ばれている。DefaultActorImpl::enqueueのソースコードを以下に示す。複雑な関数のため、重要な部分をピックアップして解説する。

https://github.com/swiftlang/swift/blob/47bf39ad50eafb3230b379778515b52d4dc1a1ec/stdlib/public/Concurrency/Actor.cpp#L1556-L1655

LIFOキューへのジョブの追加

現在のActiveActorStatusが持つLIFOキュー (未処理ジョブの一覧) に対して、エンキューされたジョブが追加される。なお、while (true)ループ内で行われているのは、CAS (Compare-And-Swap) パターンで並行アクセスに対応するためである。

https://github.com/swiftlang/swift/blob/47bf39ad50eafb3230b379778515b52d4dc1a1ec/stdlib/public/Concurrency/Actor.cpp#L1565-L1573

アクターの実行ステータスの更新

元々アイドル状態 (Idle) だった場合には、実行待ち (Scheduled) へとActiveActorStatusの状態を遷移させる。

https://github.com/swiftlang/swift/blob/47bf39ad50eafb3230b379778515b52d4dc1a1ec/stdlib/public/Concurrency/Actor.cpp#L1575-L1578

CAS (Compare-And-Swap) でステータスを更新する

CASパターンで、ActiveActorStatusを更新する。
上記の変更中に、別スレッドで元々の_status()の値が変更されていた場合には、while (true)ループで更新処理をやり直す。

https://github.com/swiftlang/swift/blob/47bf39ad50eafb3230b379778515b52d4dc1a1ec/stdlib/public/Concurrency/Actor.cpp#L1609-L1611

ジョブのスケジューリング

scheduleActorProcessJobが呼ばれ、ジョブのスケジューリングが行われる。

https://github.com/swiftlang/swift/blob/47bf39ad50eafb3230b379778515b52d4dc1a1ec/stdlib/public/Concurrency/Actor.cpp#L1621-L1625

scheduleActorProcessJob内で、ProcessOutOfLineJobクラスのインスタンスが作られる。

https://github.com/swiftlang/swift/blob/47bf39ad50eafb3230b379778515b52d4dc1a1ec/stdlib/public/Concurrency/Actor.cpp#L1535

その後、通常の場合、swift_task_enqueueGlobalが呼ばれ、グローバルキューに対してProcessOutOfLineJobがエンキューされる。

https://github.com/swiftlang/swift/blob/47bf39ad50eafb3230b379778515b52d4dc1a1ec/stdlib/public/Concurrency/Actor.cpp#L1553

その後は実行環境に依存するが、macOS/iOS等ではDispatchQueueによるジョブのスケジューリングが行われる。ジョブの実行時にProcessOutOfLineJob::processが呼ばれる。

ProcessOutOfLineJob::processの実装

ProcessOutOfLineJob::processのソースコードを以下に示す。
ここでdefaultActorDrain(actor)が呼ばれているのがポイントである。

https://github.com/swiftlang/swift/blob/47bf39ad50eafb3230b379778515b52d4dc1a1ec/stdlib/public/Concurrency/Actor.cpp#L1902-L1908

defaultActorDrainのソースコードを以下に示す。重要な部分をピックアップして解説する。

https://github.com/swiftlang/swift/blob/47bf39ad50eafb3230b379778515b52d4dc1a1ec/stdlib/public/Concurrency/Actor.cpp#L1833-L1899

tryLockによるアクターのロックの取得

actor->tryLock(true)によって、アクターのロックが取得される。

https://github.com/swiftlang/swift/blob/47bf39ad50eafb3230b379778515b52d4dc1a1ec/stdlib/public/Concurrency/Actor.cpp#L1837

ここで一つ重要な点として、tryLock内でhandleUnprioritizedJobsという関数が呼ばれている。

https://github.com/swiftlang/swift/blob/47bf39ad50eafb3230b379778515b52d4dc1a1ec/stdlib/public/Concurrency/Actor.cpp#L2080-L2082

このhandleUnprioritizedJobsこそ、以前に説明した、LIFOキューから優先度付きFIFOキューへとジョブを移植する関数である。これによって、実行すべきタスクを優先度付きFIFOキューから取得できるようになる。

https://github.com/swiftlang/swift/blob/47bf39ad50eafb3230b379778515b52d4dc1a1ec/stdlib/public/Concurrency/Actor.cpp#L1777-L1787

優先度付きFIFOキューからのジョブの取得

currentActor->drainOne()によって、優先度付きのFIFOキューから、最も優先度の高いジョブが取得される。

https://github.com/swiftlang/swift/blob/47bf39ad50eafb3230b379778515b52d4dc1a1ec/stdlib/public/Concurrency/Actor.cpp#L1856

ジョブの実行

runJobInEstablishedExecutorContext(job)によって、そのジョブが実行される。

https://github.com/swiftlang/swift/blob/47bf39ad50eafb3230b379778515b52d4dc1a1ec/stdlib/public/Concurrency/Actor.cpp#L1871

swift::runJobInEstablishedExecutorContextの実装を見ると、さらにrunInFullyEstablishedContextが呼ばれている。

https://github.com/swiftlang/swift/blob/47bf39ad50eafb3230b379778515b52d4dc1a1ec/stdlib/public/Concurrency/Actor.cpp#L224-L245

runInFullyEstablishedContextの実装を見ると、ResumeTask(ResumeContext)が呼ばれている。

https://github.com/swiftlang/swift/blob/47bf39ad50eafb3230b379778515b52d4dc1a1ec/include/swift/ABI/Task.h#L406-L408

ResumeTaskとは、Taskを継続させる関数である。
すなわち、ResumeTask(ResumeContext)によって、ここで中断された関数のContinuation (後続の処理) が呼び出されて、処理が再開することとなる。

ジョブの取得・実行は、while (true)ループ内にある。
すなわち、優先度付きFIFOキューが空になるまで、スレッドはジョブの取得・実行を繰り返すこととなる。

新たに追加されたジョブの処理

while (true)ループ内で、processIncomingQueueという関数が呼ばれている。
これは、ジョブの取得・実行のループ中に、新たにLIFOキューに追加されたジョブを、優先度付きFIFOキューに移すためである。

https://github.com/swiftlang/swift/blob/47bf39ad50eafb3230b379778515b52d4dc1a1ec/stdlib/public/Concurrency/Actor.cpp#L1889

processIncomingQueueの実装を見ると、tryLockと同様に、内部でhandleUnprioritizedJobsが呼ばれている。繰り返すと、これがLIFOキューから優先度付きFIFOキューにジョブを移す関数である。

https://github.com/swiftlang/swift/blob/47bf39ad50eafb3230b379778515b52d4dc1a1ec/stdlib/public/Concurrency/Actor.cpp#L1741-L1774


ここまで、処理のバケツリレーが非常に複雑であったが、Default Actorが使用されている場合に関しては、これでasync関数が中断・再開するまでの流れを一通り確認できたことになる。

Default Actorにおける、ジョブのスケジューリングの仕組みの解説は、以上とする。
別の記事で、Main Actorで実行された場合や、Taskで実行された場合に関しても、ジョブがスケジューリングされる仕組みを解明する予定である。

Discussion