テクノロジー · 実用ガイド
Python の ContextVar がスレッドプールで消える?伝播と変更の受け渡しを確認する
Task、to_thread、スレッドとプロセスの違いを整理。投入のたびにコンテキストをコピーし、変更は戻り値で渡します。

ContextVar の伝わり方は、処理が越える並行実行の境界によって異なります。asyncio Task はタスクのコンテキストを保持し、asyncio.to_thread は現在の Context を作業スレッドへコピーします。一方、ThreadPoolExecutor への直接投入や loop.run_in_executor では、フレームワークが処理しない限り明示的なコピーが必要です。
作業スレッドへコンテキストを渡すことと、そこでの変更を呼び出し元へ戻すことは別です。投入ごとに新しいコピーを取り、その中で一回の呼び出しを実行します。変更は戻り値として渡し、プロセス間の転送は別に扱い、二つの同時リクエストで分離を確かめます。
1. まず実行境界を特定する
呼び出しが asyncio Task 内に留まるのか、asyncio.to_thread、loop.run_in_executor、ThreadPoolExecutor への直接投入を使うのか、別プロセスへ移るのかを記録します。関数名やフレームワーク名だけで挙動を判断しません。
境界の両側で、秘密情報を含まないリクエスト識別子を記録します。値の欠落は伝播の失敗、前のリクエストの値はスレッドに状態が残ったことを示します。明示的なコピー後にだけ期待する値が見えるなら、伝播を担うのはそのラッパーです。
2. スレッドへ投入するたびにコピーする
直接スレッドへ投入するときは、呼び出し元のリクエスト内で ctx=contextvars.copy_context() を実行し、ctx.run と対象関数、その引数を渡します。長く使うプールの作成時に一度だけではなく、投入のたびに取得します。
同じ Context を二つの作業スレッドで同時に実行しないでください。すでに実行中の Context へ入ると RuntimeError になります。呼び出しごとの新しいコピーなら、起動時ではなく、そのリクエスト時点の状態を保持できます。
3. 伝播と変更の受け渡しを分ける
asyncio.to_thread は作業スレッドへの伝播を提供します。フレームワークの補助関数も run_in_executor を同様に包む場合があります。しかし、作業側で ContextVar に代入しても、待機中の Task に自動で書き戻されるわけではありません。
作業側で新しいテナント、トレースのスパン、認可結果を求めたら、通常のデータとして返します。呼び出し元が自分の ContextVar を設定・復元するか判断します。一時的な変更では set() の token を保存し、try/finally で reset() を呼びます。
4. スレッドとプロセスを区別する
CPython の議論で run_in_executor に一律の自動伝播を導入しなかった理由の一つは、ProcessPoolExecutor も使える一方、Context は pickle で直列化できないことでした。スレッド専用のラッパーをプロセスにも適用しないでください。
別プロセスには小さく直列化可能なリクエスト情報を明示的に渡し、作業側で検証して結果を返します。認証情報や大きな可変オブジェクトは、プロセス API の安全性と所有権のルールに従って扱います。
5. 実際の呼び出し経路で分離を確かめる
本番で使う補助関数を通して、識別子 A と B の二つの呼び出しを重ねて実行し、同期バリアで両スレッドを同時に動かします。A は A だけ、B は B だけを参照し、ラッパーなしの対照呼び出しは文書に記載された既定値を得ることを確認します。
各作業スレッドで異なるローカル値を設定して返します。await または Future.result() の後も、呼び出し元が明示的に変更・復元するまでは元の識別子を保持するか確かめます。エラー時と終了時も繰り返し、前のリクエストの値を残さないようにします。
