🧵

swift-concurrency-extrasのMainSerialExecutorは非同期処理のテストを安定化させるハック

2024/12/07に公開

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をセットする

実際のコードは次の通り

https://github.com/pointfreeco/swift-concurrency-extras/blob/1.3.0/Sources/ConcurrencyExtras/MainSerialExecutor.swift#L99-L101

どのようなクロージャをhookさせているのか

  • 前提
    • MainActorにはenqueueメソッドがあり、MainActorで実行できるjobを登録できる
      • jobの型はUnownedJob
      • MainActor.shared.enqueue(job)

クロージャでは引数としてTaskで実行されるjobを取得でき、そのjobをMainActorでenqueueするようです。

https://github.com/pointfreeco/swift-concurrency-extras/blob/1.3.0/Sources/ConcurrencyExtras/MainSerialExecutor.swift#L77-L90

テスト時に利用するメソッド

検証時、テストしたいコードに対してwithMainSerialExecutorを使いますが、そのメソッドについてもみてみます。

https://github.com/pointfreeco/swift-concurrency-extras/blob/1.3.0/Sources/ConcurrencyExtras/MainSerialExecutor.swift#L31-L39

@isolated(any)

引数 @isolated(any) () async throws -> Voidは外部からこのクロージャを与えることになりますが、おそらく、外部でなんらかのグローバルアクターを指定した場合にそれを継承するためだと思います。具体的には次のコードのクロージャをMainActorとして利用可能にしているのでしょうきっと。

final class MainSerialExecutorActor: XCTestCase {
  @MainActor
  func testWithMainSerialExecutor() async {
      await withMainSerialExecutor {
          // ここを`testWithMainSerialExecutor`で指定している
          // globalActorであるMainActorにしたい。
      }
  }
}

これはPRを見て判断しました。

https://github.com/pointfreeco/swift-concurrency-extras/pull/46

なんとなくSE-0431にもそういうことが書いてあるような気がする...

https://github.com/swiftlang/swift-evolution/blob/main/proposals/0431-isolated-any-functions.md

おわりに

TCAではテスト時に使われています。

https://github.com/search?q=repo%3Apointfreeco%2Fswift-composable-architecture withMainSerialExecutor&type=code

LockIsolatedについては別の記事にしています

TestClockについても記事にした方が良さそうですね。

Discussion