クラウド Mac で検証する Swift TaskLocal の継承と分離

クラウド Mac で検証する Swift TaskLocal の継承と分離

クラウド Mac で検証する Swift TaskLocal の継承と分離

1 つのリクエストがダウンロード、解析、ディスクへの書き込みという 3 つの非同期処理を通る間に、ログのリクエスト ID が出たり消えたりする。そこで ID をグローバル変数にすると、今度は並列テストで値が混ざってしまう。クラウド Mac でこうした問題を調べるとき、最初に見直すべきなのはスレッド数ではありません。確認すべきなのは、コンテキストがどのタスクに伝わり、どの境界で伝播が止まるかです。

この記事では、独立した Swift パッケージで TaskLocal を検証します。業務サービスへの接続も、シミュレータも不要です。検証対象はスレッドへの所属ではなく、タスクのセマンティクスです。同じタスクの実行スレッドが切り替わっても、それだけでタスクローカル値が失われてはいけません。

コンテキストの伝播ルールを先に定義する

リクエスト ID はオプショナルな文字列として定義し、デフォルト値を nil にします。リクエストのコンテキストがない場合は、そのままデフォルト値を保ちます。もっともらしい ID を自動生成するよりも、コンテキストの渡し忘れを見つけやすくなるためです。

ルールは 5 つです。スコープ内では値を読み取れること。入れ子のスコープでは一時的に上書きできること。スコープを抜けると外側の値に戻ること。通常の子タスクとタスクグループの子タスクは、作成時のコンテキストを継承すること。そして、分離タスクはデフォルトでは継承しないことです。エラーがスローされた場合も、外側の状態に戻る必要があります。

TaskLocal はスレッドローカルストレージでも、グローバル変数を安全に包む仕組みでもありません。伝播するのはタスクのコンテキストであり、あるスレッドで最後に書き込まれた値ではありません。

値自体も、できるだけ小さく、不変にしておきます。この記事で渡すのは文字列だけで、可変の辞書、セッションオブジェクト、データベース接続は渡しません。参照型のオブジェクトを格納しても、そのオブジェクトが自動的に並行アクセスに対して安全になるわけではありません。

単独で実行できるテストパッケージを作る

クラウド Mac の通常の作業ディレクトリで、次のコマンドを実行します。最初にツールチェーンのバージョンを記録しておくと、後でテストが失敗したときに、コードの変更とコンパイラの変更を切り分けられます。

mkdir TraceScopeLab
cd TraceScopeLab
swift --version
swift package init --type library --name TraceScopeLab
mkdir -p Tests/TraceScopeLabTests

テストディレクトリに ContextTests.swift を作成し、次のコード全体を記述します。デフォルトで生成されたテストファイルは残してかまいません。両者に依存関係はありません。この例ではツールチェーン付属のテストフレームワークをそのまま使い、追加の依存関係は導入しません。

import XCTest

private enum TraceContext {
    @TaskLocal static var requestID: String?
}

private enum ProbeError: Error {
    case expected
}

final class ContextTests: XCTestCase {
    func testNestedScopeRestoresAfterThrow() {
        XCTAssertNil(TraceContext.requestID)

        TraceContext.$requestID.withValue("outer") {
            XCTAssertEqual(TraceContext.requestID, "outer")

            XCTAssertThrowsError(
                try TraceContext.$requestID.withValue("inner") {
                    XCTAssertEqual(TraceContext.requestID, "inner")
                    throw ProbeError.expected
                }
            )

            XCTAssertEqual(TraceContext.requestID, "outer")
        }

        XCTAssertNil(TraceContext.requestID)
    }

    func testTaskBoundaries() async {
        let observed = await TraceContext.$requestID
            .withValue("request-a") {
                let child = Task {
                    TraceContext.requestID
                }
                let detached = Task.detached {
                    TraceContext.requestID
                }

                let inherited = await child.value
                let absent = await detached.value

                let groupValue = await withTaskGroup(
                    of: String?.self,
                    returning: String?.self
                ) { group in
                    group.addTask {
                        TraceContext.requestID
                    }

                    var result: String?
                    for await value in group {
                        result = value
                    }
                    return result
                }

                return (inherited, absent, groupValue)
            }

        XCTAssertEqual(observed.0, "request-a")
        XCTAssertNil(observed.1)
        XCTAssertEqual(observed.2, "request-a")
        XCTAssertNil(TraceContext.requestID)
    }
}

ここでは、先に await で結果を取得してからアサーションに渡しています。同期的なアサーションの式に、非同期の読み取りを直接含めないためです。また、作成したタスクはすべて完了を待ち、テスト終了後に処理がバックグラウンドで残らないようにしています。

分離タスクのコンテキストは明示的に選ぶ

Task {} はタスクローカル値を継承しますが、非構造化タスクです。ライフサイクルが作成元のスコープに自動で結び付くわけではありません。コンテキストの継承と、完了まで自動的に待機することは別の仕組みです。

そのため、親のスコープを抜けた後も、先に作成されたタスクが元の ID を保持している場合があります。これは値の復元に失敗したわけではありません。リクエスト内の処理では構造化並行処理を優先し、非構造化タスクが本当に必要な場合は、ハンドルを保持して明示的に完了を待つか、キャンセルする必要があります。

独立したバックグラウンド処理では、デフォルトでリクエストのコンテキストを持たないほうが、むしろ意図が明確です。追跡のために関連付ける必要がある場合は、伝播を許可するフィールドを先にキャプチャし、分離タスクの内部で値を設定します。

let capturedID = TraceContext.requestID
let worker = Task.detached {
    TraceContext.$requestID.withValue(capturedID) {
        TraceContext.requestID
    }
}
let observedID = await worker.value

このコードは、前述のテストファイル内にある非同期テストメソッドに記述します。キャプチャするのは作成元で、値を設定するのは新しいタスクの中です。分離タスクに入ってから元のコンテキストを読み取ろうとしてはいけません。定期的なクリーンアップなど、リクエストと無関係な処理では nil を保ち、一連のログが誤って 1 回のユーザーリクエストに紐付かないようにします。

実行が交錯しても値が分離されることを検証する

1 件のリクエストで成功するだけでは不十分です。非同期テストメソッドを追加し、2 つのタスクを同時に作成して、それぞれに request-a と request-b を設定します。各タスクは値を設定したスコープ内で await Task.yield() を呼び、再開後に現在の ID を返します。最後に、それぞれの結果をアサーションで確認します。

2 つのタスクが決まった順序で完了することを条件にしてはいけません。Task.yield() は実行の機会を譲るためのヒントにすぎず、交錯する順序を保証しません。検証すべきなのは「どちらが先に完了しても、それぞれの値が正しいこと」であって、「最初のログが必ず A のものであること」ではありません。

両方の非同期スコープが終了した後も、デフォルト値が nil のままであることを確認します。共有配列で結果を集めたり、グローバルカウンタで順序を調整したりしないでください。そうすると、テスト自体が競合を持ち込むおそれがあります。

原因を特定できる失敗レポートにする

対象のテストを実行し、続いて並列実行を有効にして再確認します。2 回目の実行は、明示的な分離のアサーションの代わりにはなりませんが、テスト間で共有状態が混入する問題を見つける助けになります。

swift test --filter ContextTests
swift test --parallel --filter ContextTests

通常の子タスクが nil を読み取った場合は、まずタスクが本当に withValue の内部で作成されているかを確認します。分離タスクが ID を読み取った場合は、明示的な値の設定や、追加のラッパーがないかを調べます。入れ子のスコープを抜けても値が戻らない場合は、読み取っているものが同名のグローバルプロパティではなく、実際に TaskLocal であることを確認してください。

失敗レポートには、ツールチェーンのバージョン、テストケース名、期待値、実際の値を残せば十分です。本番ログの実際のリクエスト ID をテストフィクスチャにコピーする必要はありません。

最後に、追跡と認可は分けて考えます。コンテキストは 1 回の操作を関連付ける助けにはなりますが、呼び出し元にその操作を実行する権限があることは証明しません。小さな値を明確なタスク境界に沿って伝播させ、権限は明示的な入力として検証する。この設計によって、並列テストの結果を長期にわたって信頼できるものにできます。

よくある質問

Task.detached は TaskLocal を継承しますか?

継承しません。必要な識別子を作成元で読み取り、分離タスク内の withValue に明示的に渡して新しいスコープを作ります。

TaskLocal だけで利用者の権限を判断できますか?

できません。追跡用の補助情報として使い、機密操作に必要な認可情報は明示的に渡して検証してください。

次のビルドは専用クラウドMacで

MacFTPでは、macOSをネイティブ実行するApple Silicon搭載の専用物理マシンをご利用いただけます。日・週・月・四半期からレンタル期間をお選びください。まず構成を確認し、お使いのツールチェーンをノードに接続します。実際の稼働状況はコントロールパネルにリアルタイムで表示されます。

クラウドMacを今すぐレンタル

記事からプロジェクトへ

構成を確認して、納品の準備を

モデルとレンタル期間を比較するか、ブログで技術情報を引き続きご覧ください。