Программирование RustКонкурентность и asyncРазработчик Rust с опытом создания async-сервисов

В async коде нужно определить, является ли значение из thread local локальным для задачи. Какой результат д...

В async-коде нужно определить, является ли значение из thread_local! локальным для задачи. Какой результат допустим после передачи задачи между рабочими потоками?

use std::cell::Cell;
use tokio::task::yield_now;

thread_local! {
    static POLLS: Cell<u32> = Cell::new(0);
}

#[tokio::main(flavor = "multi_thread", worker_threads = 2)]
async fn main() {
    let task = tokio::spawn(async {
        POLLS.with(|p| p.set(p.get() + 1));
        yield_now().await;
        POLLS.with(|p| p.get())
    });
    println!("{}", task.await.unwrap());
}
Проходите собеседования с ИИ помощником Hintsage

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

Гарантированного результата нет: программа может вывести 1 или 0. thread_local! создаёт отдельное значение для каждого OS-потока, а не для каждой async-задачи. После await задача может продолжить работу на другом потоке, где счётчик ещё равен 0.

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

Thread-local storage появился для хранения состояния, связанного с потоком исполнения: например, локального буфера, идентификатора или контекста библиотеки. В синхронной модели поток обычно выполняет функцию последовательно, поэтому состояние потока естественно воспринимается как состояние текущего исполнителя.

Async runtime изменил эту модель: множество задач по очереди исполняется на небольшом числе потоков. Точка await может приостановить задачу, после чего runtime возобновит её на том же или другом рабочем потоке. Поэтому потоковая локальность не совпадает с локальностью задачи.

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

В примере первое обращение к POLLS происходит на одном рабочем потоке и увеличивает его копию счётчика. После yield_now().await задача возвращается планировщику и может быть поставлена на другой поток.

Если продолжение произойдёт на другом потоке, оно увидит независимую копию POLLS, равную 0. Если задача останется на исходном потоке, результатом будет 1. Код не содержит ошибки синхронизации, но его результат зависит от планирования и потому не должен использоваться для хранения состояния конкретной async-задачи.

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

thread_local! логически предоставляет по одному экземпляру значения на каждый OS-поток. Вызов POLLS.with(...) обращается к экземпляру, принадлежащему потоку, который в данный момент выполняет замыкание.

async-блок компилируется в future. При вызове poll runtime исполняет часть future на текущем потоке до await или другого возврата управления. После yield_now().await future возвращает управление планировщику; при многопоточном runtime её следующий poll может выполнить другой worker.

Само значение Cell<u32> не переносится между потоками внутри future. На каждом вызове with заново выбирается thread-local экземпляр текущего потока. Именно поэтому результат может быть 0, несмотря на предыдущее увеличение счётчика.

Для состояния, принадлежащего задаче, нужен task-local storage, предоставляемый конкретным runtime, либо явная передача состояния через аргументы future. Например, в Tokio для этого существует tokio::task_local!; его значение привязано к контексту задачи, а не к OS-потоку.

tokio::task_local! { static REQUEST_ID: u64; } async fn handler() { REQUEST_ID.with(|id| println!("запрос {id}")); } async fn run() { REQUEST_ID.scope(42, handler()).await; }

Потоковое состояние всё же уместно, когда данные действительно принадлежат worker-потоку и не должны следовать за задачей. Однако такой подход плохо подходит для идентификатора запроса, трассировки или транзакционного контекста, который обязан сохраняться через await.

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

Сервис записывал идентификатор запроса в thread_local!, а затем использовал его в нескольких async-функциях. На однопоточном runtime всё выглядело корректно, но после перехода на многопоточную конфигурацию часть логов стала получать пустой или чужой идентификатор.

Первый вариант — оставить thread_local!. Его плюс — быстрый доступ без явной передачи, но он не сохраняет контекст при миграции задачи и создаёт трудно воспроизводимые ошибки.

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

Выбранным решением стал task-local контекст runtime: значение следует за задачей при смене потока, а граница его действия задаётся через scope. Для небольшого сервиса это устранило ошибки в логах без добавления общего изменяемого состояния.

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

1. Требует ли обращение к thread_local! реализации Sync у самого значения?

Нет, это не означает, что одно и то же значение безопасно совместно используется потоками. Механизм предоставляет каждому потоку отдельный экземпляр, поэтому совместного доступа к одному Cell нет. Однако это не делает Cell потокобезопасным и не превращает thread-local состояние в task-local.

2. Всегда ли многопоточный runtime переносит задачу на другой поток после await?

Нет. Задача может продолжить работу на том же потоке, поэтому в конкретном запуске часто наблюдается 1. Важна не обязательность миграции, а отсутствие гарантии, что её не будет; корректный код не должен зависеть от этого решения планировщика.

3. Будет ли thread_local! безопасным заменителем task-local на однопоточном runtime?

Он может выглядеть эквивалентным, если все задачи действительно исполняются на одном OS-потоке. Но состояние всё равно является общим для этого потока: две задачи могут последовательно видеть и изменять одну копию, а вложенный или повторный запуск может нарушить ожидаемые границы контекста. Если данные принадлежат именно задаче, следует использовать task-local механизм или явную передачу состояния даже при однопоточном исполнении.