English· Español· Deutsch· Nederlands· Français· 日本語· ქართული· 繁體中文· 简体中文· Português· Русский· العربية· हिन्दी· Italiano· 한국어· Polski· Svenska· Türkçe· Українська· Tiếng Việt· Bahasa Indonesia

un

гость
1 / ?
назад к урокам

ThreadLocal: правильный идиом, неправильная эпоха

Java EE Servlet-контейнеры, примерно 1999 год: один поток на запрос. Поток обрабатывает ровно один запрос от начала до конца, затем завершается. ThreadLocal хранит значение, привязанное к текущему потоку. При модели «один поток — один запрос» значение в ThreadLocal принадлежит ровно одному запросу. Идиом: корректен.

Пулы потоков изменили контракт. Поток обрабатывает запрос A, сохраняет principal A в ThreadLocal, завершает запрос A и возвращается в пул. Пулы потоков не сбрасывают состояние потока. ThreadLocal.remove() очищает данные, но его вызов требует явной дисциплины. Когда дисциплина нарушается, запрос B выполняется на том же потоке и читает principal A из ThreadLocal.

5-шаговая утечка:

1. Приходит запрос A. Сервер назначает Thread-7.

2. Thread-7 вызывает ThreadLocal.set(principal_A) в начале запроса.

3. Запрос A завершается. Thread-7 возвращается в пул. ThreadLocal.remove() не вызывается.

4. Приходит запрос B. Сервер назначает Thread-7 (повторное использование потока из пула).

5. Thread-7 вызывает ThreadLocal.get(): возвращает principal_A. Запрос B выполняется под неправильной идентичностью.

Почему тесты не обнаруживают проблему

Модульные тесты выполняются изолированно: без пула потоков и повторного использования. Интеграционные тесты используют свежие потоки или сбрасывают состояние между тестами. Нагрузочные тесты прогреваются с правильными пользователями и низкой конкуренцией. Дефект проявляется только при повторном использовании потоков из пула при пересекающихся запросах — условие, возникающее в продакшене при обычной нагрузке, но отсутствующее в тестовых конфигурациях.

Последствия для безопасности

Principal пользователя A «протекает» в запрос пользователя B. Без падения. Без исключения. Тихое нарушение границы безопасности: пользователь B выполняет действия от имени A, читает данные A или обходит свои права. Система не выдаёт ошибку. В логах запрос B выглядит авторизованным. Всё выглядит корректно.

Пять шагов

Пять шагов утечки ThreadLocal важны именно в такой последовательности: дефект возникает не в момент выполнения неправильного кода. Он возникает раньше — при отсутствии шага очистки.

Пройдите по 5 шагам. На каком шаге возникает дефект и почему тестовый набор может его пропустить?

Значения, привязанные к области видимости

ThreadLocal привязывает значение к потоку. Поток живёт дольше запроса. Несоответствие.

Значения, привязанные к области видимости, привязывают значение к единице работы. Когда единица работы завершается, значение завершается вместе с ней. Без явной очистки. Без remove(), чтобы забыть.

Java 21: ScopedValue

// ThreadLocal (носитель ДЕФЕКТА)
static final ThreadLocal<Principal> PRINCIPAL = new ThreadLocal<>();
PRINCIPAL.set(principal);           // установить в начале запроса
// ... обработка запроса ...
PRINCIPAL.remove();                 // ОБЯЗАТЕЛЬНО вызвать; часто забывают

// ScopedValue (ПРАВИЛЬНЫЙ носитель)
static final ScopedValue<Principal> PRINCIPAL = ScopedValue.newInstance();
ScopedValue.where(PRINCIPAL, principal).run(() -> {
// ... обработка запроса ...
// значение автоматически исчезает при возврате из run()
});

Go: context.Context

// context.Context явно переносит значения; область = цепочка вызовов функций
ctx := context.WithValue(r.Context(), principalKey, principal)
handleRequest(ctx)  // ctx передаётся явно; исчезает при возврате из функции

Python asyncio: contextvars.ContextVar

# ContextVar привязан к каждой асинхронной задаче
PRINCIPAL: ContextVar[str] = ContextVar('principal')
token = PRINCIPAL.set(principal)    # установить только для этой задачи
# ... обработка задачи ...
PRINCIPAL.reset(token)              # или: область завершается вместе с задачей

Общее свойство: время жизни совпадает с единицей работы. Когда запрос завершается (run() возвращает управление, функция возвращает результат, задача завершается), значение исчезает. Нечего забывать очищать. Нечего портить в пуле.

Identify & Replace

Java EE-приложение сохраняет ID арендатора в ThreadLocal при старте запроса. Под высокой нагрузкой ID арендатора A появляется в запросах от арендатора B. Запросы арендатора B возвращают данные арендатора A. Исключения не выбрасываются. Дефект проявляется только при нагрузочном тестировании в продакшене.

Какой MOAD описывает эту ситуацию? Какой носитель сделал дефект возможным? Что его заменяет и какое свойство замены предотвращает утечку?