За счёт чего значение ContextVar не смешивается между asyncio-задачами в одном потоке?
ContextVar хранит значение не просто в потоке, а в текущем контексте выполнения. При создании asyncio-задачи ей передаётся копия контекста текущей задачи, поэтому изменения переменной в одной задаче не видны другой, даже если обе выполняются в одном потоке.
Обычные глобальные переменные плохо подходят для хранения контекста запроса: при конкурентном выполнении одна задача может перезаписать данные другой. threading.local решает проблему для потоков, но не для нескольких asyncio-задач, работающих в одном потоке.
Механизм contextvars появился в Python 3.7 как способ хранить контекст, корректно работающий с асинхронными задачами и переключениями между ними. Он используется, например, для идентификаторов запросов, настроек локали, трассировки и другой информации, относящейся к текущему потоку выполнения.
Предположим, приложение обрабатывает несколько запросов в одном event loop. Каждая корутина должна получать собственный идентификатор запроса, но передавать его явно через все вызовы неудобно и повышает связанность кода.
Глобальная переменная приведёт к гонкам логического состояния: после переключения на другую задачу значение может относиться уже к другому запросу. Использование threading.local также не поможет, потому что все задачи event loop обычно работают в одном потоке и разделяют его локальное хранилище.
У каждой asyncio-задачи есть текущий контекст, содержащий значения ContextVar. Когда задача создаётся, она получает копию контекста, действовавшего в момент создания. Поэтому две задачи могут начать с одинакового значения, но последующие присваивания выполняются независимо.
При переключении на await event loop сохраняет контекст приостанавливаемой задачи. Когда задача возобновляется, её контекст снова становится текущим. Это и обеспечивает изоляцию значений между задачами в одном потоке.
Обе задачи первоначально видят значение parent, потому что получают копию контекста родительской задачи. После присваивания задача A видит A, задача B — B, а контекст main сохраняет parent.
Для временного изменения значения существует операция set, возвращающая токен. С его помощью можно восстановить предыдущее значение в блоке finally; это важно при вложенных контекстах и повторном использовании кода.
Изоляция распространяется на значение переменной, но не делает автоматически изолированным сам изменяемый объект. Если в ContextVar хранится общий список или словарь, разные задачи могут изменить один и тот же объект. Для полной независимости нужно создавать отдельные объекты либо использовать синхронизацию.
При передаче работы в поток через asyncio.to_thread текущий контекст обычно копируется в рабочий поток. Это удобно для контекстных идентификаторов, но изменения в рабочем потоке не являются способом изменить контекст уже выполняющейся asyncio-задачи. Для run_in_executor автоматическая передача контекста не гарантируется так же, как для to_thread, поэтому при необходимости контекст передают явно.
В веб-сервисе нужно добавлять request_id в логи всех функций обработки запроса. Рассматривались три варианта.
Передача идентификатора явным аргументом наиболее прозрачна и хорошо подходит для чистых функций, но требует менять большое количество сигнатур. Глобальная переменная проще, однако при конкурентной обработке запросов приводит к смешиванию идентификаторов. threading.local изолирует данные по потокам, но не по asyncio-задачам одного потока.
Выбран ContextVar: идентификатор устанавливается на границе обработки запроса, а функции логирования получают его без протягивания аргумента через весь стек вызовов. При создании дочерних задач контекст наследуется, а изменения локализуются внутри конкретной задачи. Это даёт корректные логи без блокировок и без привязки к числу потоков.
Важно определить границу контекста и восстанавливать прежнее значение после обработки запроса. Иначе при повторном использовании той же задачи или при вложенных вызовах можно получить утечку контекстных данных.
Вопрос: Наследует ли уже созданная asyncio-задача изменения ContextVar из родительской задачи?
Ответ: Нет. При создании задачи контекст копируется, а не связывается с родительским контекстом. Если родитель изменит ContextVar после создания дочерней задачи, дочерняя задача этого изменения не увидит; аналогично, изменения дочерней задачи не изменят значение родителя.
Вопрос: Гарантирует ли ContextVar независимость, если его значение — изменяемый словарь?
Ответ: Нет. Копируется структура контекста и ссылка на значение, но не выполняется глубокое копирование объекта. Поэтому две задачи могут получить ссылки на один словарь и конфликтовать при его изменении. ContextVar изолирует привязку переменной к значению, а не внутреннее состояние произвольного изменяемого объекта.
Вопрос: Что произойдёт с ContextVar при запуске синхронной функции в отдельном процессе?
Ответ: Контекст процесса не является общим с контекстом родительского процесса. Значение нельзя считать автоматически доступным в другом процессе: передача зависит от механизма запуска и сериализации аргументов, а изменённый контекст дочернего процесса не возвращается родителю. Если значение нужно рабочему процессу, его следует передать явно как данные задачи и учитывать требования к сериализуемости.