Python contextvars: ส่ง Request ID ข้าม async โดยไม่ปนกัน
ตัวแปร global เก็บ request_id ใช้ได้ในสคริปต์ทีละงาน แต่พอ coroutine สองตัวสลับกัน ค่าใหม่จะทับของเก่าครับ ContextVar ให้ข้อมูลระดับ context ซึ่ง asyncio.Task คัดลอกและดูแลแยกกัน จึงเหมาะกับ trace ID, locale หรือ tenant ที่ต้องไหลตาม call chain โดยไม่เพิ่ม parameter ทุกฟังก์ชัน
WHY: task สลับกันบน thread เดียว
ปัญหาไม่ใช่ thread race อย่างเดียว Event loop สามารถหยุด request A ที่ await แล้วรัน B บน thread เดิม ถ้าใช้ threading.local() หรือ global ทั้งสองงานอาจเห็นช่องเดียวกัน เอกสาร contextvars ออกแบบ API นี้สำหรับ context ของ async โดยตรง
ตั้งค่าแล้วเก็บ token เพื่อ reset ใน finally คล้ายยืมป้ายชื่อจากหน้าห้อง: ใส่ก่อนเข้า เก็บใบรับ และคืนป้ายเดิมเสมอแม้มี exception
import asyncio
import unittest
from contextvars import ContextVar
request_id = ContextVar("request_id", default="missing")
async def handle(name, gate):
token = request_id.set(name)
try:
await gate.wait()
await asyncio.sleep(0)
return request_id.get()
finally:
request_id.reset(token)
class ContextVarTests(unittest.IsolatedAsyncioTestCase):
async def test_concurrent_tasks_keep_their_own_values(self):
gate = asyncio.Event()
tasks = [asyncio.create_task(handle(name, gate)) for name in ("A", "B")]
gate.set()
self.assertEqual(await asyncio.gather(*tasks), ["A", "B"])
self.assertEqual(request_id.get(), "missing")
if __name__ == "__main__":
unittest.main()
Context ไม่ใช่ถังใส่ทุกอย่าง
เก็บเฉพาะ metadata เล็กและ immutable-ish อย่าใส่ database session, response object หรือ cache ใหญ่ เพราะ dependency จะซ่อนและอายุ object อาจยาวเกินคาด งานที่ต้องการค่าเพื่อความถูกต้องของ domain ควรส่ง parameter ตรง ๆ ส่วน ContextVar เหมาะกับ cross-cutting concern เช่น logging
เมื่อส่งงานไป thread pool ต้องตรวจ API ที่ใช้ว่า copy context ให้หรือไม่ asyncio.to_thread() เผยแพร่ context ปัจจุบัน แต่ executor/custom thread อื่นอาจต้องใช้ copy_context().run() เอง และ context ไม่ได้ข้าม process หรือ network อัตโนมัติ ต้อง serialize trace header ตาม protocol
Recap
ContextVarแยกค่าตาม async context แม้อยู่ thread เดียว- เก็บ token แล้ว reset ใน
finally - ใช้กับ metadata เล็ก ไม่ซ่อน dependency หลัก
- ตรวจ boundary ของ thread, process และ network แยกกัน