swift-concurrency-extrasのMainSerialExecutorは非同期処理のテストを安定化させるハック
TCA Advent Calendar 2024の記事です。
はじめに
TCAに使われているswift-concurrency-extrasのMainSerialExecutorについて解説します。バージョンはv1.3です。
もし何か間違いがあったらコメントいただければ助かります。
MainSerialExecutorについて
めちゃくちゃ雑にいうと、テスト用にTaskのエグゼキュータをメインスレッドに置き換えてテストを安定化させるためのものです。
サンプル
次のような結果が[1, 2, 3]
となるテストがあるとします。これは高確率でテスト失敗します。
final class ConcurrencyExtraDemoTests: XCTestCase {
func testExample0() async throws {
var array = [Int]()
let task = Task {
array.append(1)
Task.detached {
array.append(2)
}
}
await Task.yield()
array.append(3)
await task.value
XCTAssertEqual(array, [1, 2, 3])
}
}
テストが失敗する原因は、Actorが適用されていないTaskは、実行時に実行するスレッドが動的に決まるからでしょう。
さらにTask.detachedもあり、その上部のTaskの階層を打ち切るため、ほぼ100%に近い確率でテストが失敗するかもしれません。
MainSerialExecutorはTaskの実行をフックし、全てメインスレッドでの実行に置き換えます。テストをパスするように書き換えたものを次に示します。
final class ConcurrencyExtraDemoTests: XCTestCase {
// こんなテストもパスできる
func testExample1() async throws {
await withMainSerialExecutor {
var array = [Int]()
let task = Task {
array.append(1)
Task.detached {
array.append(2)
}
}
await Task.yield()
array.append(3)
await task.value
XCTAssertEqual(array, [1, 2, 3])
}
}
}
この例はTaskをテスト用のメソッドで直接実行していますが、Taskの実行が関数の中でも同じことです。テスト対象のコードを変更して検証するための何かをする必要もないし、テスト対象にDIする必要もありません。
さらに複雑な例はREADMEに書かれています。
どうやってこれを実現しているか
Swiftが公式に公開している仕様ではないですが、swift_task_enqueueGlobal_hook
というシンボル名のアドレスに、Taskの実行をフックできるクロージャを登録することができるようです。
swift_task_enqueueGlobal_hook
- Unix系OSはPOSIX仕様に沿った関数が用意されている
- テストやデバッグ用に下記の関数がある
- dlopen
- 実行時に動的ライブラリ(共有オブジェクト)をロード
-
dlopen(nil, 0)
で現在のプロセス
-
- 実行時に動的ライブラリ(共有オブジェクト)をロード
- dlsym
- 動的ライブラリ(共有オブジェクト)とシンボル名から関数や変数のアドレス取得
-
swift_task_enqueueGlobal_hook
のシンボル名に該当のクロージャのアドレスがある- 指定の引数を持つ任意のクロージャをセットしたり、フックする必要がないならnilをセットする
-
- 動的ライブラリ(共有オブジェクト)とシンボル名から関数や変数のアドレス取得
- dlopen
- テストやデバッグ用に下記の関数がある
実際のコードは次の通り
どのようなクロージャをhookさせているのか
- 前提
- MainActorにはenqueueメソッドがあり、MainActorで実行できるjobを登録できる
- jobの型はUnownedJob
MainActor.shared.enqueue(job)
- MainActorにはenqueueメソッドがあり、MainActorで実行できるjobを登録できる
クロージャでは引数としてTaskで実行されるjobを取得でき、そのjobをMainActorでenqueueするようです。
テスト時に利用するメソッド
検証時、テストしたいコードに対してwithMainSerialExecutorを使いますが、そのメソッドについてもみてみます。
@isolated(any)
引数 @isolated(any) () async throws -> Void
は外部からこのクロージャを与えることになりますが、おそらく、外部でなんらかのグローバルアクターを指定した場合にそれを継承するためだと思います。具体的には次のコードのクロージャをMainActorとして利用可能にしているのでしょうきっと。
final class MainSerialExecutorActor: XCTestCase {
@MainActor
func testWithMainSerialExecutor() async {
await withMainSerialExecutor {
// ここを`testWithMainSerialExecutor`で指定している
// globalActorであるMainActorにしたい。
}
}
}
これはPRを見て判断しました。
なんとなくSE-0431にもそういうことが書いてあるような気がする...
おわりに
TCAではテスト時に使われています。
TestClockについても記事にした方が良さそうですね。
Discussion