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 важны именно в такой последовательности: дефект возникает не в момент выполнения неправильного кода. Он возникает раньше — при отсутствии шага очистки.
Значения, привязанные к области видимости
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. Исключения не выбрасываются. Дефект проявляется только при нагрузочном тестировании в продакшене.