Программирование PythonКонкурентность и asyncioPython-разработчик, создающий асинхронные сервисы

За счёт чего значение ContextVar не смешивается между asyncio задачами в одном потоке?

За счёт чего значение ContextVar не смешивается между asyncio-задачами в одном потоке?

Проходите собеседования с ИИ помощником Hintsage

Краткий ответ

ContextVar хранит значение не просто в потоке, а в текущем контексте выполнения. При создании asyncio-задачи ей передаётся копия контекста текущей задачи, поэтому изменения переменной в одной задаче не видны другой, даже если обе выполняются в одном потоке.

Исторический контекст

Обычные глобальные переменные плохо подходят для хранения контекста запроса: при конкурентном выполнении одна задача может перезаписать данные другой. threading.local решает проблему для потоков, но не для нескольких asyncio-задач, работающих в одном потоке.

Механизм contextvars появился в Python 3.7 как способ хранить контекст, корректно работающий с асинхронными задачами и переключениями между ними. Он используется, например, для идентификаторов запросов, настроек локали, трассировки и другой информации, относящейся к текущему потоку выполнения.

Постановка проблемы

Предположим, приложение обрабатывает несколько запросов в одном event loop. Каждая корутина должна получать собственный идентификатор запроса, но передавать его явно через все вызовы неудобно и повышает связанность кода.

Глобальная переменная приведёт к гонкам логического состояния: после переключения на другую задачу значение может относиться уже к другому запросу. Использование threading.local также не поможет, потому что все задачи event loop обычно работают в одном потоке и разделяют его локальное хранилище.

Подробное решение

У каждой asyncio-задачи есть текущий контекст, содержащий значения ContextVar. Когда задача создаётся, она получает копию контекста, действовавшего в момент создания. Поэтому две задачи могут начать с одинакового значения, но последующие присваивания выполняются независимо.

При переключении на await event loop сохраняет контекст приостанавливаемой задачи. Когда задача возобновляется, её контекст снова становится текущим. Это и обеспечивает изоляцию значений между задачами в одном потоке.

import asyncio from contextvars import ContextVar request_id = ContextVar("request_id", default=None) async def worker(name): print(name, request_id.get()) request_id.set(name) await asyncio.sleep(0) print(name, request_id.get()) async def main(): request_id.set("parent") await asyncio.gather(worker("A"), worker("B")) print("parent:", request_id.get()) asyncio.run(main())

Обе задачи первоначально видят значение parent, потому что получают копию контекста родительской задачи. После присваивания задача A видит A, задача BB, а контекст main сохраняет parent.

Для временного изменения значения существует операция set, возвращающая токен. С его помощью можно восстановить предыдущее значение в блоке finally; это важно при вложенных контекстах и повторном использовании кода.

Изоляция распространяется на значение переменной, но не делает автоматически изолированным сам изменяемый объект. Если в ContextVar хранится общий список или словарь, разные задачи могут изменить один и тот же объект. Для полной независимости нужно создавать отдельные объекты либо использовать синхронизацию.

При передаче работы в поток через asyncio.to_thread текущий контекст обычно копируется в рабочий поток. Это удобно для контекстных идентификаторов, но изменения в рабочем потоке не являются способом изменить контекст уже выполняющейся asyncio-задачи. Для run_in_executor автоматическая передача контекста не гарантируется так же, как для to_thread, поэтому при необходимости контекст передают явно.

Ситуация из практики

В веб-сервисе нужно добавлять request_id в логи всех функций обработки запроса. Рассматривались три варианта.

Передача идентификатора явным аргументом наиболее прозрачна и хорошо подходит для чистых функций, но требует менять большое количество сигнатур. Глобальная переменная проще, однако при конкурентной обработке запросов приводит к смешиванию идентификаторов. threading.local изолирует данные по потокам, но не по asyncio-задачам одного потока.

Выбран ContextVar: идентификатор устанавливается на границе обработки запроса, а функции логирования получают его без протягивания аргумента через весь стек вызовов. При создании дочерних задач контекст наследуется, а изменения локализуются внутри конкретной задачи. Это даёт корректные логи без блокировок и без привязки к числу потоков.

Важно определить границу контекста и восстанавливать прежнее значение после обработки запроса. Иначе при повторном использовании той же задачи или при вложенных вызовах можно получить утечку контекстных данных.

Что кандидаты часто упускают

  1. Вопрос: Наследует ли уже созданная asyncio-задача изменения ContextVar из родительской задачи?

    Ответ: Нет. При создании задачи контекст копируется, а не связывается с родительским контекстом. Если родитель изменит ContextVar после создания дочерней задачи, дочерняя задача этого изменения не увидит; аналогично, изменения дочерней задачи не изменят значение родителя.

  2. Вопрос: Гарантирует ли ContextVar независимость, если его значение — изменяемый словарь?

    Ответ: Нет. Копируется структура контекста и ссылка на значение, но не выполняется глубокое копирование объекта. Поэтому две задачи могут получить ссылки на один словарь и конфликтовать при его изменении. ContextVar изолирует привязку переменной к значению, а не внутреннее состояние произвольного изменяемого объекта.

  3. Вопрос: Что произойдёт с ContextVar при запуске синхронной функции в отдельном процессе?

    Ответ: Контекст процесса не является общим с контекстом родительского процесса. Значение нельзя считать автоматически доступным в другом процессе: передача зависит от механизма запуска и сериализации аргументов, а изменённый контекст дочернего процесса не возвращается родителю. Если значение нужно рабочему процессу, его следует передать явно как данные задачи и учитывать требования к сериализуемости.