В мире потоков всё было просто: threading.local() даёт каждому потоку свои данные. Request ID, текущий пользователь, database connection — положил в thread-local, достал когда нужно. FastAPI, Flask, Django — все так делали.
Потом пришёл asyncio, и эта модель сломалась. В одном потоке выполняются тысячи корутин, и thread-local у них общий. Положил request ID в одной корутине — прочитал чужой в другой. contextvars, появившийся в Python 3.7, решает эту проблему, но механика его работы не очевидна.
Разберём, почему thread-local не работает в async, как устроены contextvars, и какие паттерны использовать.
Проблема: thread-local в asyncio
Thread-local variables — данные, уникальные для каждого потока:
import threading local = threading.local() def handle_request(request_id): local.request_id = request_id process() def process(): print(f"Processing {local.request_id}")
В синхронном сервере каждый request обрабатывается в своём потоке. Thread-local хранит контекст request, все функции в call stack имеют к нему доступ без передачи аргументов.
Теперь asyncio:
import asyncio import threading local = threading.local() async def handle_request(request_id): local.request_id = request_id await asyncio.sleep(0.1) # Симуляция I/O print(f"Request {request_id}, got {local.request_id}") async def main(): await asyncio.gather( handle_request("A"), handle_request("B"), handle_request("C"), ) asyncio.run(main())
Вывод:
Request A, got C Request B, got C Request C, got C
Все три корутины выполняются в одном потоке. Когда корутина A засыпает на await, управление переходит к B, потом к C. Каждая перезаписывает local.request_id. Когда A просыпается, там уже значение от C.
Thread-local привязан к потоку, а не к корутине. В async это бесполезно.
ContextVar: thread-local для корутин
contextvars.ContextVar решает проблему:
import asyncio from contextvars import ContextVar request_id: ContextVar[str] = ContextVar('request_id', default='unknown') async def handle_request(rid): request_id.set(rid) await asyncio.sleep(0.1) print(f"Request {rid}, got {request_id.get()}") async def main(): await asyncio.gather( handle_request("A"), handle_request("B"), handle_request("C"), ) asyncio.run(main())
Вывод:
Request A, got A Request B, got B Request C, got C
Каждая корутина видит своё значение, несмотря на общий поток. Как это вообще работает?
Context и copy-on-write
Основная структура — Context. Это immutable mapping из ContextVar в значения. У каждого таска asyncio свой Context.
При создании Task копируется текущий Context:
async def outer(): request_id.set("outer") # Создаём task — он получает КОПИЮ текущего контекста task = asyncio.create_task(inner()) await task async def inner(): # Видим значение из момента создания task print(request_id.get()) # "outer" # Изменяем — это изменение локально для inner request_id.set("inner") async def main(): await outer() # После завершения outer контекст main не изменился
Но копирование не означает дублирование всех данных. Используется copy-on-write: Context ссылается на родительский, пока не происходит изменение. При set() создаётся новый узел только для изменённой переменной.
Это похоже на persistent data structures: изменение создаёт новую версию, разделяющую неизменённые части со старой.
Структура Context
Посмотрим на Context напрямую:
from contextvars import ContextVar, copy_context var1: ContextVar[int] = ContextVar('var1') var2: ContextVar[str] = ContextVar('var2') var1.set(42) var2.set("hello") # Получаем текущий контекст ctx = copy_context() print(list(ctx.items())) # [(var1, 42), (var2, 'hello')] # Context — это mapping print(ctx[var1]) # 42 print(var1 in ctx) # True
copy_context() возвращает снапшот текущего контекста. Это не живая ссылка, изменения после копирования не видны.
Context можно использовать для запуска функций в изолированном окружении:
def worker(): print(f"var1 = {var1.get()}") var1.set(100) print(f"var1 after set = {var1.get()}") ctx = copy_context() # Запускаем worker в контексте ctx ctx.run(worker) # var1 = 42 # var1 after set = 100 # Изменения остались внутри ctx print(var1.get()) # 42 — оригинальный контекст не изменился print(ctx[var1]) # 100 — но ctx изменился
ctx.run(func) — основной метод. Он временно устанавливает ctx как текущий контекст, запускает функцию, потом восстанавливает предыдущий. Все изменения ContextVar внутри func применяются к ctx.
Как asyncio использует Context
При создании Task asyncio делает примерно следующее:
class Task: def __init__(self, coro): self._coro = coro self._context = copy_context() # Снапшот при создании def _step(self): # Выполняем шаг корутины в её контексте self._context.run(self._coro.send, None)
Каждый Task имеет свой Context. При переключении между задачами Context переключается автоматически.
Именно поэтому asyncio.create_task() захватывает контекст:
async def example(): request_id.set("parent") # Task создаётся с копией текущего контекста task = asyncio.create_task(child()) # Изменения в parent после создания task... request_id.set("parent_modified") await task async def child(): # ...не видны в child — он работает со снапшотом print(request_id.get()) # "parent", не "parent_modified"
Task получает контекст на момент создания, а не на момент первого await.
Token: откат изменений
set() возвращает Token, позволяющий откатить изменение:
token = request_id.set("temporary") try: do_something() finally: request_id.reset(token) # Возвращаем предыдущее значение
Это полезно для временного изменения контекста, например, в middleware:
async def logging_middleware(request, handler): token = request_id.set(request.headers.get("X-Request-ID")) try: return await handler(request) finally: request_id.reset(token)
Без Token пришлось бы запоминать предыдущее значение вручную, что ненадёжно при исключениях.
Логирование с request ID
Такой вот базо��ый пример — добавление request ID во все логи:
import logging from contextvars import ContextVar from typing import Optional request_id: ContextVar[Optional[str]] = ContextVar('request_id', default=None) class RequestIdFilter(logging.Filter): def filter(self, record): record.request_id = request_id.get() or 'no-request' return True # Настройка логгера handler = logging.StreamHandler() handler.setFormatter(logging.Formatter( '%(asctime)s [%(request_id)s] %(message)s' )) handler.addFilter(RequestIdFilter()) logger = logging.getLogger() logger.addHandler(handler) logger.setLevel(logging.INFO) # Использование async def handle_request(rid: str): request_id.set(rid) logger.info("Starting request") await do_work() logger.info("Finished request") async def do_work(): # Не передаём request_id явно — он в контексте logger.info("Doing work") await asyncio.sleep(0.1)
Вывод:
2024-01-15 10:00:00 [req-123] Starting request 2024-01-15 10:00:00 [req-123] Doing work 2024-01-15 10:00:00 [req-123] Finished request
Request ID пробрасывается через весь call stack без явной передачи.
database connection per request
Ещё один частый паттерн — connection pooling с привязкой к request:
from contextvars import ContextVar from typing import Optional import asyncpg db_connection: ContextVar[Optional[asyncpg.Connection]] = ContextVar( 'db_connection', default=None ) class Database: def __init__(self, pool: asyncpg.Pool): self.pool = pool async def get_connection(self) -> asyncpg.Connection: conn = db_connection.get() if conn is None: raise RuntimeError("No database connection in context") return conn async def transaction(self): """Context manager для транзакции""" async with self.pool.acquire() as conn: token = db_connection.set(conn) try: async with conn.transaction(): yield conn finally: db_connection.reset(token) # Использование db = Database(pool) async def handle_request(): async with db.transaction(): await create_user() await send_notification() # Если exception — откат транзакции async def create_user(): conn = await db.get_connection() # Получаем из контекста await conn.execute("INSERT INTO users ...") async def send_notification(): conn = await db.get_connection() # Та же connection await conn.execute("INSERT INTO notifications ...")
Все функции внутри transaction() используют одну connection без явной передачи. Транзакция атомарна.
Интеграция с thread pools
run_in_executor автоматически копирует контекст:
async def example(): request_id.set("async-context") loop = asyncio.get_running_loop() # Контекст копируется в thread result = await loop.run_in_executor( None, blocking_function ) def blocking_function(): # Видим значение из async-контекста print(request_id.get()) # "async-context"
Это работает начиная с Python 3.7. Раньше приходилось передавать контекст явно.
Для ThreadPoolExecutor без asyncio:
from concurrent.futures import ThreadPoolExecutor from contextvars import copy_context def run_with_context(func, *args): ctx = copy_context() return ctx.run(func, *args) executor = ThreadPoolExecutor() request_id.set("main") # Запускаем с копированием контекста future = executor.submit(run_with_context, worker)
Проблемы(куда без них)
Изменения не видны в родительском контексте:
async def parent(): var.set("parent") await asyncio.create_task(child()) print(var.get()) # Всё ещё "parent"! async def child(): var.set("child") # Изменяем копию контекста
Task работает с копией. Изменения в child не влияют на parent. Если нужно передать данные обратно — используйте return или явные структуры (Queue, Event).
Callback в loop.call_soon:
async def example(): request_id.set("example") loop = asyncio.get_running_loop() # call_soon НЕ копирует контекст автоматически (до Python 3.11) loop.call_soon(callback) # callback увидит другой контекст # Явная передача контекста ctx = copy_context() loop.call_soon(callback, context=ctx)
В Python 3.11+ появился параметр context= для call_soon, call_later, etc.
ContextVar в классах:
class Service: # Не делайте так — один экземпляр ContextVar на все объекты request_id = ContextVar('request_id') # Делайте так — ContextVar на уровне модуля request_id: ContextVar[str] = ContextVar('request_id') class Service: def get_request_id(self): return request_id.get()
ContextVar должен быть синглтоном. Создание ContextVar в init каждого объекта — ошибка.
Тяжёлые объекты в контексте:
Context копируется при создании Task. Если в ContextVar лежит большой объект — копируется ссылка, не объект. Но если много ContextVar с маленькими объектами — overhead складывается.
Храните в ContextVar идентификаторы или ссылки, не большие структуры данных.
Сравнение с альтернативами
threading.local:
Работает только в sync-коде
Нет изоляции между корутинами
Проще в использовании, если не нужен async
Явная передача параметров:
Самый явный и понятный способ
Засоряет сигнатуры функций
Нужно протаскивать через весь call stack
Глобальные переменные:
Не thread-safe, не async-safe
Подходят только для read-only конфигурации
contextvars:
Работает в sync и async
Автоматическая изоляция в asyncio
Требует понимания механики копирования
Библиотеки, использующие contextvars
aiohttp —
aiohttp.web.Requestдоступен через ContextVarStarlette/FastAPI — request state
structlog — контекстные переменные для логирования
SQLAlchemy — async session management
OpenTelemetry — trace context propagation
Если пишете async-библиотеку с глобальным состоянием — используйте contextvars.
contextvars решает фундаментальную проблему async Python: как хранить данные, привязанные к логическому потоку выполнения, а не к системному потоку.
Главное — понимать, что изменения в дочернем контексте не видны в родительском.

Если хотите не просто «знать про asyncio», а уверенно проектировать продакшен-код на Python, обратите внимание на курс Python Developer. Professional. На нем разбирают практики и инструменты разработки: асинхронщину, паттерны, метапрограммирование, производительность и безопасность — на задачах, похожих на реальные сервисы. Готовы к серьезному обучению? Пройдите вступительный тест.
А чтобы узнать больше о формате обучения и задать вопросы экспертам, приходите на бесплатные демо-уроки:
5 февраля в 20:00. «Python и Web Scraping: Извлечение данных из интернета для анализа и автоматизации». Записаться
10 февраля в 20:00. «Делаем по красоте: паттерны проектирования в Python-приложениях». Записаться
19 февраля в 20:00. «Kafka без магии: практический разбор для питонистов». Записаться
