ThreadLocal: 正确的成语,错误的时代
Java EE Servlet 容器,约1999年:一个线程处理一个请求,从开始到结束,然后终止。ThreadLocal以当前线程作为键存储一个值。有一个线程处理请求,ThreadLocal存储的值属于一个请求。这个成语:正确。
线程池改变了合同。一个线程处理请求A,存储主体A在ThreadLocal,完成请求A,然后返回到线程池。线程池不重置线程状态。ThreadLocal.remove()清理,但调用它需要明确的自律。当自律失败时,请求B在同一个线程上运行并读取ThreadLocal中的主体A。
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以错误的身份运行。
为什么测试会错过它
单元测试在孤立环境下运行:没有线程池,没有重用。集成测试使用新线程或在测试之间重置状态。负载测试在正确的用户和低并发下预先运行。这个缺陷只在线程池重用和重叠请求出现时在生产环境中出现,而不是在任何检查它的测试配置中。
安全后果
用户A的主体渗透到用户B的请求。不是一个崩溃。不是一个异常。一个静默的安全边界违规:用户B执行用户A的操作,读取用户A的数据,或者绕过用户B的权限。系统产生没有错误。日志显示请求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 明确携带值;范围=function调用链
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()返回,函数返回,任务完成),值也结束。没有需要忘记的清理。没有可能被损坏的池子。
识别与替换
一个Java EE应用在请求开始时使用ThreadLocal存储租户ID。在高负载下,租户A的ID会出现在租户B的请求中。租户B的查询会返回租户A的数据。没有异常被抛出。这一缺陷只有在生产负载测试中才会出现。