非同期およびマルチスレッドバックエンドにおけるコンテキスト伝搬
Emily Parker
Product Engineer · Leapcell

現代のバックエンドシステム、特にマイクロサービス間の通信、豊富な非同期処理、パフォーマンス向上のためのマルチスレッドが常態化する世界では、ユーザーリクエストの旅全体を一貫して把握し続けることは、顔に目隠しをして迷宮を歩き回るような感覚になるかもしれません。単一のユーザーリクエストが、データベースとの対話、他のマイクロサービスの呼び出し、データの並列処理といった一連のイベントを引き起こす様子を想像してみてください。この全フローを追跡するための信頼できるメカニズムがなければ、デバッグは悪夢となり、パフォーマンスのボトルネックは隠れたままとなり、システムの動作を正確に理解することは困難になります。まさにここで、リクエストコンテキスト伝搬の概念が輝きを放つのです。観測可能性、トラブルシューティング、さらにはセキュリティにとって、ユニークなTrace IDのような重要な情報が、すべてのホップ、スレッドスイッチ、非同期境界を越えてリクエストについていくことを保証することは極めて重要です。この記事では、これらの複雑な環境におけるリクエストコンテキストを安全かつ確実に伝搬するための課題とさまざまな戦略を探ります。
主要概念
「どのように」に飛び込む前に、議論の基礎を形成する主要な用語について共通の理解を確立しましょう。
- リクエストコンテキスト: 特定の着信リクエストに関連付けられたデータのコレクションを指します。これには、Trace ID(サービス間でリクエスト全体の一意の識別子)、Span ID(リクエスト内の単一操作の一意の識別子)、ユーザー認証の詳細、テナント ID、言語設定、またはその特定のリクエストの処理に関連するその他のデータが含まれる場合があります。
- Trace ID: どのサービスやスレッドが関与しているかに関わらず、単一のエンドツーエンドユーザーリクエストに属するすべての操作(スパン)をリンクする、グローバルに一意の識別子です。分散トレーシングに不可欠です。
- 非同期環境: 操作の完了を待たずに開始できるシステム。これには、コールバック、プロミス、フューチャー、またはメッセージキューがしばしば含まれ、ノンブロッキング実行とリソース利用率の向上が可能になります。
- マルチスレッド環境: 単一プロセス内で複数の実行スレッドが並行して実行されるシステム。スレッドはメモリを共有しますが、競合状態を防ぎ、データ整合性を確保するためには、適切な同期とデータ分離が不可欠です。
- コンテキスト伝搬: 1つの実行単位(例:スレッド、関数呼び出し、サービス)から別の実行単位へ、特に非同期境界やスレッドスイッチを越えて、リクエストコンテキストを転送または利用可能にすること。
- スレッドローカルストレージ (TLS): 各スレッドが変数の独自のインスタンスを持つことができるメカニズム。単純なスレッド固有のデータには便利ですが、注意深い管理なしでは、複雑なコンテキスト伝搬を async/await またはスレッドプール全体で効果的に行うことは限定的です。
- 構造化された並行処理: 並行タスクのライフサイクルを管理するための構造を提供するプログラミングパラダイムであり、実行フローについての推論を容易にし、コンテキストが正しく伝搬されることを保証します。例としては、Java の
StructuredTaskScopeや Go のcontextパッケージがあります。
コンテキスト伝搬の課題
従来のデータ渡し方法(関数の引数)は、実行がスレッド間をジャンプしたり、非同期的に延期されたりすると壊れるため、根本的な課題が生じます。リクエストがシステムに入ると、通常は1つのスレッドから始まります。後続の操作がスレッドプールにオフロードされたり、async/await パターンが使用されたりすると、明示的に引き継がれない限り、元のスレッドのローカルコンテキストは失われます。
単純なシナリオを考えてみましょう。Webサーバーがリクエストを受け取ります。HTTPヘッダーから Trace-ID を抽出します。次に、2つのデータベースクエリを並列で実行する必要があります。各クエリは、スレッドプールから別のスレッドで実行されます。クエリが完了した後、結果が集計され、応答が返されます。Trace-ID がデータベースクエリのスレッドに明示的に渡されない場合、そのクエリで生成されたログは重要な識別子を欠き、元のリクエストにリンクすることが不可能になります。
コンテキスト伝搬の戦略
いくつかの戦略がこの課題に対処しており、それぞれにトレードオフがあります。
1. 明示的なパラメータ渡し
最も簡単で、しばしば冗長な方法ですが、コンテキストオブジェクトを必要とするすべての関数またはメソッドに引数として明示的に渡すことです。
原則: コンテキストオブジェクトは、関数シグネチャの明示的な一部になります。
例 (Python - 簡略化):
import uuid def process_request(trace_id, user_data): print(f"[{trace_id}] Starting request processing for {user_data}") db_result_1 = perform_db_query(trace_id, "query_A") db_result_2 = perform_external_call(trace_id, "service_B") return f"[{trace_id}] Processed: {db_result_1}, {db_result_2}" def perform_db_query(trace_id, query_string): print(f"[{trace_id}] Executing DB query: {query_string}") # Simulate DB operation return f"DB_Result_for_{query_string}" def perform_external_call(trace_id, service_name): print(f"[{trace_id}] Calling external service: {service_name}") # Simulate external API call return f"External_Result_from_{service_name}" # Incoming request incoming_trace_id = str(uuid.uuid4()) response = process_request(incoming_trace_id, {"username": "Alice"}) print(response)
長所:
- 非常に明示的で理解しやすい。
- 隠された魔法はなく、データフローは明確です。
短所:
- 特に深いコールスタックで、関数シグネチャの「コンテキスト汚染」につながる可能性があります。
- コンテキストを渡すのを忘れることが容易で、バグを引き起こします。
- コンテキストパラメータを期待しないライブラリ呼び出しを自動的に処理しません。
2. スレッドローカルストレージ (TLS)
TLS は、各スレッドが変数の独自のコピーを持てるようにします。これは、「現在の」スレッドに固有のコンテキストを保存するために使用できます。
原則: コンテキストはスレッドローカル変数に保存され、その同じスレッドで実行されるコードによって必要に応じて取得されます。
例 (Java):
import java.util.UUID; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.Callable; public class ThreadLocalContext { private static final ThreadLocal<String> currentTraceId = new ThreadLocal<>(); public static void setTraceId(String traceId) { currentTraceId.set(traceId); } public static String getTraceId() { return currentTraceId.get(); } public static void clearTraceId() { currentTraceId.remove(); } public static void businessLogicA() { System.out.printf("[%s] Executing businessLogicA%n", getTraceId()); // ... } public static void main(String[] args) throws InterruptedException { String mainTraceId = UUID.randomUUID().toString(); setTraceId(mainTraceId); System.out.printf("[%s] Main thread started%n", getTraceId()); ExecutorService executor = Executors.newFixedThreadPool(2); // This task will run on a new thread from the pool executor.submit(() -> { // Here, getTraceId() would return null by default on a new thread // unless explicitly set or inherited. System.out.printf("[%s] Async task 1 started (expected null if not propagated)%n", getTraceId()); // How do we get mainTraceId here? // manual propagation needed: String current = getTraceId(); // null setTraceId("ASYNC-" + mainTraceId.substring(0, 8)); // New trace ID for async context System.out.printf("[%s] Async task 1 trace ID set%n", getTraceId()); clearTraceId(); // Clean up }); // Another direct thread use (similar issue) Thread t2 = new Thread(() -> { System.out.printf("[%s] Thread 2 started (expected null)%n", getTraceId()); setTraceId("THREAD2-" + mainTraceId.substring(0, 8)); System.out.printf("[%s] Thread 2 trace ID set%n", getTraceId()); clearTraceId(); }); t2.start(); t2.join(); // After async tasks, main thread's context should still be there System.out.printf("[%s] Main thread finished async calls%n", getTraceId()); clearTraceId(); executor.shutdown(); } }
長所:
- パラメータの汚染を回避します。コードは暗黙的にコンテキストにアクセスできます。
- リクエストごとの同期、シングルスレッド実行には比較的単純です。
短所:
- 非同期/マルチスレッド環境における主な欠点: TLS 変数は、本質的に現在のスレッドに結び付けられています。操作がスレッドを切り替えるとき(例:スレッドプール、async/await、またはリアクティブプログラミング)、元のスレッドの TLS に保存されたコンテキストは、新しいスレッドに自動的に転送されません。これは、明示的な「継承」メカニズムが使用されない限り、コンテキストの喪失につながります。
- スレッドが再利用される場合のメモリリークやコンテキストの漏洩を防ぐために、注意深いクリーンアップ(
remove()またはclear())が必要です。
3. コンテキストデータ構造 (例: Go の context.Context)
Go のような言語は、専用の型を通じてコンテキスト伝搬のためのファーストクラスサポートを持っています。このパターンは、コンテキストを渡すための明示的でありながら、より簡潔な方法を推奨します。
原則: Context オブジェクトがコールチェーンを下に渡されます。これは不変であり、任意のキーと値のペアを保持できます。既存のコンテキストから新しいコンテキストを派生させ、値を継承したり、新しい値を追加したりできます。
例 (Go):
package main import ( "context" "fmt" "time" ) // コンテキストキーの衝突を回避するためのカスタム型 type traceIDKey string const keyTraceID traceIDKey = "traceID" func generateTraceID() string { return fmt.Sprintf("trace-%d", time.Now().UnixNano()) } func logWithContext(ctx context.Context, message string) { if traceID := ctx.Value(keyTraceID); traceID != nil { fmt.Printf("[%s] %s\n", traceID, message) } else { fmt.Printf("[No-Trace] %s\n", message) } } func dbOperation(ctx context.Context, query string) { logWithContext(ctx, fmt.Sprintf("Executing DB query: %s", query)) // Simulate async DB call time.Sleep(50 * time.Millisecond) logWithContext(ctx, fmt.Sprintf("DB query %s finished", query)) } func externalServiceCall(ctx context.Context, service string) { logWithContext(ctx, fmt.Sprintf("Calling external service: %s", service)) // Simulate async external call time.Sleep(100 * time.Millisecond) logWithContext(ctx, fmt.Sprintf("External service %s call finished", service)) } func processRequest(parentCtx context.Context, userData string) { ctx := context.WithValue(parentCtx, keyTraceID, generateTraceID()) // Add a new trace ID to context logWithContext(ctx, fmt.Sprintf("Starting request processing for %s", userData)) // Simulate concurrent operations using goroutines done := make(chan struct{}) go func() { defer func() { done <- struct{}{} }() dbOperation(ctx, "SELECT * FROM users") // Pass context to goroutine }() go func() { defer func() { done <- struct{}{} }() externalServiceCall(ctx, "UserService") // Pass context to goroutine }() // Wait for concurrent operations to complete <-done <-done logWithContext(ctx, "Request processing finished") } func main() { // Create a background context for the application's lifetime appCtx := context.Background() processRequest(appCtx, "Alice") time.Sleep(200 * time.Millisecond) // Give time for goroutines to finish fmt.Println("---") processRequest(appCtx, "Bob") }
長所:
- 明示的で読みやすいですが、
WithValueパターンのおかげで、完全な明示的渡しよりも冗長ではありません。 - 並行処理を自然に処理します。コンテキストはゴルーチン/関数の引数です。
- キャンセルとデッドラインをサポートしているため、複雑なフローを管理するのに強力です。
- Go では規約によって強制され、慣用的になっています。
短所:
- 言語レベルのサポートまたはライブラリの採用が必要です。
- コンテキストオブジェクトを渡す必要があるため、多くの個別のパラメータよりも手間がかかりません。
- 誤って使用された場合、注意しないと深くネストされた
context.WithValue呼び出しにつながる可能性があります。
4. 非同期コンテキストライブラリ/構造化された並行処理 (例: Java の StructuredTaskScope、Project Loom ScopedValue、Kotlin CoroutineContext、Python contextvars)
これらのアプローチは、実行境界を越えてコンテキストを自動的に伝搬することにより、非同期およびマルチスレッド環境のための TLS 問題を解決することを目指しています。
原則: これらのライブラリまたは言語機能は、現在の実行コンテキストをキャプチャし、それを子タスク、スレッドプールからのスレッド、または非同期操作の継続に自動的に伝搬するメカニズムを提供します。
例 (Python contextvars):
import asyncio import contextvars import uuid # Trace ID 用の ContextVar を定義 current_trace_id = contextvars.ContextVar('trace_id', default='no_trace_id') async def db_operation_async(query_string): trace_id = current_trace_id.get() # コンテキストを自動的に取得 print(f"[{trace_id}] Async DB query: {query_string}") await asyncio.sleep(0.05) # 非同期 DB 呼び出しをシミュレート print(f"[{trace_id}] Async DB query {query_string} finished") async def external_call_async(service_name): trace_id = current_trace_id.get() # コンテキストを自動的に取得 print(f"[{trace_id}] Async external service: {service_name}") await asyncio.sleep(0.1) # 非同期外部呼び出しをシミュレート print(f"[{trace_id}] Async external service {service_name} call finished") async def process_request_async(user_data): # 現在の非同期タスクの実行のために Trace ID を設定 token = current_trace_id.set(str(uuid.uuid4())) trace_id = current_trace_id.get() print(f"[{trace_id}] Starting async request processing for {user_data}") # これらの非同期呼び出しは、現在の current_trace_id を自動的に継承します await asyncio.gather( db_operation_async("SELECT * FROM users_async"), external_call_async("AsyncUserService") ) print(f"[{trace_id}] Async request processing finished") current_trace_id.reset(token) # コンテキストをクリーンアップ async def main(): await process_request_async("Alice") print("---") await process_request_async("Bob") if __name__ == '__main__': asyncio.run(main())
例 (Java Project Loom ScopedValue - 概念理解のための簡略化):
import java.util.UUID; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import jdk.incubator.concurrent.ScopedValue; // Java 21+ with Loom が必要 public class ScopedValueContext { // ScopedValue を定義 private static final ScopedValue<String> TRACE_ID = ScopedValue.newInstance(); public static void dbOperation() { System.out.printf("[%s] Executing DB operation%n", TRACE_ID.get()); // Simulate DB call } public static void externalServiceCall() { System.out.printf("[%s] Calling external service%n", TRACE_ID.get()); // Simulate external API call } public static void main(String[] args) throws InterruptedException { ExecutorService executor = Executors.newFixedThreadPool(2); // Process request 1 String traceId1 = UUID.randomUUID().toString(); // このタスクおよびその中で起動されるすべてのサブタスクの ScopedValue をバインド ScopedValue.where(TRACE_ID, traceId1).run(() -> { System.out.printf("[%s] Starting request 1%n", TRACE_ID.get()); executor.submit(() -> { // この Callable は、Loom が正しく使用されていればコンテキストを自動的に継承します dbOperation(); }); executor.submit(() -> { externalServiceCall(); }); // 実際の Loom アプリケーションでは、Virtual Threads は自然にコンテキストを継承します。 // 従来の Thread Pool では、Executor が ScopedValue と直接統合されていない場合、 // ラッピング/手動伝搬が依然として必要になる場合があります。 // structured concurrency (StructuredTaskScope) は、Pooled Thread にとってもこれをはるかにきれいにします。 }); Thread.sleep(200); // タスクが実行される時間を与える // Process request 2 String traceId2 = UUID.randomUUID().toString(); ScopedValue.where(TRACE_ID, traceId2).run(() -> { System.out.printf("[%s] Starting request 2%n", TRACE_ID.get()); executor.submit(() -> dbOperation()); }); executor.shutdown(); executor.awaitTermination(1, java.util.concurrent.TimeUnit.SECONDS); } }
長所:
- 自動伝搬: 最大の利点は、コンテキストが async/await 境界を越えて、または構造化された並行処理スコープ(例:Java の
StructuredTaskScope、Python のcontextvars)内で起動された新しいスレッドに自動的に伝搬されることです。これにより、手動での渡しや明示的な継承の必要性が大幅に排除されます。 - コードがクリーンになります。ボイラープレートを削減し、可読性を向上させます。
- エラーが発生しにくくなります。コンテキストを渡すのを忘れる可能性を減らします。
短所:
- 言語/ランタイムサポートまたは成熟したライブラリエコシステムが必要です。
- さまざまな並行処理プリミティブとの相互作用を理解するための学習曲線がある場合があります。
- コンテキストスイッチと管理のために、明示的な渡しよりもわずかに高いオーバーヘッドがある場合がありますが、ほとんどのアプリケーションでは無視できることが多いです。
ベストプラクティスと推奨事項
-
言語/フレームワークに適したツールを選択する:
- Go: 常に
context.Contextを使用します。慣用的で堅牢です。 - Python: 非同期コードには
contextvarsを活用します。マルチスレッド コードの場合、スレッドプールがcontextvarsとどのように相互作用するかを注意してください。opentelemetry-pythonのようなライブラリはこれをうまく処理します。 - Java: 従来のマルチスレッドでは、コンテキストを明示的にコピーするカスタム Executor ラッパーで
ThreadLocalを強化します。最新の Java (21+) では、構造化された並行処理と自動コンテキスト伝搬のために、StructuredTaskScopeを使用したScopedValueが推奨されるパスです。リアクティブフレームワークは、独自のコンテキストメカニズム(例:Reactor のContext)をしばしば使用します。
- Go: 常に
-
コンテキストをクリーンアップする: TLS または明示的なセットアップ/ティアダウンが必要なメカニズムを使用する場合、リクエストまたはタスクの終了時にコンテキストが確実にクリアされるようにしてください。これにより、再利用されたスレッド間のコンテキストの漏洩を防ぎ、メモリリークを回避できます。
-
エッジでコンテキストを開始する: コンテキスト(特に
Trace ID)をリクエストライフサイクルの早い段階、通常は API ゲートウェイまたはサービスのエントリーポイントで導入します。 -
コンテキストを段階的にリッチにする: 処理中に利用可能になるにつれて、関連情報(例:ユーザー詳細、テナント ID)をコンテキストに追加します。
-
コンテキストキーを標準化する: ジェネリックコンテキストマップを使用している場合は、コードベース全体およびサービス全体で一貫性を確保するために、標準キーを定義および文書化してください。
-
オブザーバビリティツールと統合する: コンテキスト伝搬戦略が分散トレーシングシステム(例:OpenTelemetry、Zipkin、Jaeger)と整合していることを確認します。これらのシステムは、しばしば上記で議論されたコンテキスト伝搬メカニズムとシームレスに統合されるライブラリを提供します。
結論
非同期およびマルチスレッドバックエンド環境でリクエストコンテキストを安全かつ確実に渡すことは、単なる良い実践ではなく、観測可能で保守可能でデバッグ可能なシステムを構築するための基本的な要件です。明示的なパラメータ渡しはシンプルさを提供しますが、Go の context.Context や Python の contextvars のような、構造化された並行処理と非同期コンテキスト管理専用の最新のプログラミングパラダイムとライブラリは、はるかに堅牢で、より侵襲性の低いソリューションを提供します。テクノロジースタックに適したコンテキスト伝搬戦略を採用することにより、バックエンドサービスが各リクエストの完全な旅を理解できるようになり、複雑なシステムインタラクションを透明で追跡可能な操作に変えます。コンテキスト伝搬を正しく行うことは、分散システムの迷宮をリクエストのための明確にマッピングされた旅に変えることを意味します。

