งูสีมิ้นต์ต้นฉบับกำลังแยกลูกปัด request สองสีลงรางคู่ที่ไม่ไหลปนกันบนโต๊ะสว่าง

Python contextvars: ส่ง Request ID ข้าม async โดยไม่ปนกัน

พี่โชว์Engineering

ตัวแปร 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 แยกกัน