un

guest
1 / ?
back to lessons

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的数据。没有异常被抛出。这一缺陷只有在生产负载测试中才会出现。

这个描述了哪个MOAD?哪个承运商使缺陷可能?它被什么替换,替换物具有什么特性防止泄漏?