本节目标:让请求身份、租户、追踪号和截止时间安全地跨过事件循环与业务线程的边界,并说清哪些共享是安全的、哪些会串数据。
适用版本:JDK 25(Temurin 25.0.5+8);ThreadLocal、ScopedValue、record。
承接的问题
12.2 把业务处理挪到了 biz-* 线程池。请求在事件循环上解析,在业务线程上路由、鉴权、编码,最后再回到事件循环写回。这条路径上有三样东西必须跟着请求走:请求 ID(日志串联)、用户或租户(鉴权与数据隔离)、截止时间(超时与降级)。它们统称为请求上下文。
最直觉的做法是 ThreadLocal:在事件循环线程 set,业务线程 get。但业务线程来自池子,池子里的线程会复用;事件循环线程和业务线程也不是同一条。ThreadLocal 不会自动跨线程,池化线程还会把上一个请求的值留给下一个请求。一个租户隔离的系统里,这不是日志串号,而是用户 A 的请求读到了用户 B 的数据。
这一节按三层处理:先看 ThreadLocal 为什么会残留,再看 JDK 25 的 ScopedValue 如何做有界绑定和恢复,最后把上下文显式传过线程边界,并用不可变对象和 happens-before 建立可见性。
本机口径:Apple M1 Pro,10 核,32 GB,macOS 26.3 (25D125);Temurin 25.0.5+8。下面的输出来自这台机器,单次运行,未做统计。
ThreadLocal:复用线程上的残留
ThreadLocal 的值存在每个 Thread 自己的 ThreadLocalMap 里。线程池的线程活到进程结束,值也就一直活着,直到被覆盖或 remove()。常见写法是:
private static final ThreadLocal<RequestContext> LEGACY = new ThreadLocal<>();
void handle(RequestContext context) {
LEGACY.set(context);
try {
route();
} finally {
LEGACY.remove();
}
}
finally 里的 remove() 不是可选项。少了它,任务结束、线程回到池子,ThreadLocalMap 里的 RequestContext 仍然被这条线程强引用;下一个任务如果忘了 set,get() 就会拿到上一个请求的用户。ThreadLocal 的 key 是弱引用,value 是强引用;key 被回收后 value 还可能留在这个线程的 map 里,直到下一次清理。请求上下文里如果有类加载器、缓冲区或大对象,泄漏的就不只是几个字段。
InheritableThreadLocal 也不是解药。它只在线程创建时从父线程复制一次;池子里的线程通常在第一批请求时就创建好了,后面的请求再 set 父线程的值,子线程看不到。更糟的是,第一条请求的值会被这条池化线程永久继承,成为所有后续任务的默认上下文。
ScopedValue:有界绑定与自动恢复
JDK 25 的 ScopedValue 已经正式可用(JEP 506)。它把「值绑定到一段代码的作用域」做成语言级结构:绑定只在 run 的回调里可见,回调结束自动恢复上一个绑定,不需要 remove(),也不会把值留在线程上。
public static final ScopedValue<RequestContext> CURRENT = ScopedValue.newInstance();
public static Runnable wrap(RequestContext context, Runnable task) {
return () -> ScopedValue.where(CURRENT, context).run(task);
}
ScopedValue.where(CURRENT, context).run(task) 在当前线程上建立绑定,task 执行期间 CURRENT.get() 返回 context;task 返回或抛异常,绑定都自动撤销。嵌套绑定按栈恢复:内层 where 覆盖外层,内层结束后外层仍然可见。CURRENT.isBound() 可以判断当前线程有没有绑定,CURRENT.orElse(null) 可以给缺省值。
关键限制:ScopedValue 的绑定是线程本地的,不会自动传给 ExecutorService.submit() 的任务。把 wrap 过的任务提交到业务池,是因为 wrap 在业务线程上重新建立了绑定;如果直接提交一个普通 lambda,业务线程上 CURRENT.isBound() 仍然是 false。结构化并发(JDK 25 仍是预览 API)的 StructuredTaskScope fork 出的子任务会继承绑定,这是它和普通线程池的区别;linkd 主线不用预览 API,所以统一用 wrap。
两者的取舍可以压成一张表:
| 维度 | ThreadLocal | ScopedValue |
|---|---|---|
| 绑定范围 | 线程生命周期,直到 remove | 词法作用域,回调结束自动撤销 |
| 清理义务 | 调用方负责 | 运行时负责 |
| 普通线程池传播 | 不传播 | 不传播,仍需 wrap |
| 嵌套绑定 | 手动保存和恢复 | 按栈自动恢复 |
| 适用场景 | 线程独占的长期状态 | 每请求一份的短生命周期数据 |
显式参数传递当然也能解决传播问题,而且最容易被编译器检查。代价是每个需要 requestId 的深层方法都要多一个参数,日志、鉴权、限流这些横切逻辑会把签名弄得很长。ScopedValue 是在「隐式」和「有界」之间取平衡:读的时候不用传参,但绑定只在明确的作用域内有效,不会像 ThreadLocal 那样留到下一个请求。
从事件循环到业务线程:显式传播
上下文应该在解析完请求头之后创建,在进入业务池之前捕获,在业务线程上重新绑定。不要依赖「事件循环线程设了 ThreadLocal,业务线程自然能读到」。
RequestContext context = RequestContext.of(requestId, userId, traceId, deadline);
business.execute(RequestContext.wrap(context, () -> {
HttpResponse response = route(request);
byte[] body = encode(response);
loop.execute(() -> connection.enqueue(ByteBuffer.wrap(body)));
}));
这段代码里有四个决策:
- 上下文在事件循环上创建。
requestId、userId、traceId都来自已经解析好的请求,创建过程只读,不碰连接状态。 wrap在提交时捕获,不在业务线程里查。 业务线程没有事件循环的ThreadLocal,也不该反向依赖循环线程的状态。- 写回任务不携带上下文。
loop.execute里的 lambda 只捕获connection和body;如果写回日志也需要requestId,在提交前把字符串取出来,或者再用RequestContext.wrap包一层。不要捕获整个RequestContext后又去读它的可变字段。 - 截止时间在业务线程入口检查。
context.remainingMillis()小于下游 p99 时直接降级,不再发起新的远程调用;已经过期的请求回 504。
if (context.remainingMillis() < downstreamP99Millis) {
byte[] body = encode(gatewayTimeout());
loop.execute(() -> connection.enqueue(ByteBuffer.wrap(body)));
return;
}
这个检查要放在访问下游之前,而不是等下游超时再判断;否则业务线程已经被占住,截止时间只剩日志意义。
虚拟线程场景下,每个任务一条虚拟线程,ThreadLocal 仍然可用,但每个虚拟线程都有自己的 map,数量大时内存占用更明显;ScopedValue 的绑定随作用域消失,更适合「每请求一份」的短生命周期数据。两者都不解决「跨线程自动传播」,显式 wrap 仍然要做。
不可变消息与可见性
跨线程传递的上下文必须是不可变的。可变对象即使安全发布,也会在后续并发读写中产生竞态;HashMap 在同时 put 时可能损坏,SimpleDateFormat 一类非线程安全对象更不能共享。
RequestContext 用 record 加 Map.copyOf 固定下来:
package com.plume.linkd.http;
import java.time.Instant;
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
public record RequestContext(String requestId, String userId, String traceId,
Instant deadline, Map<String, String> attributes) {
public static final ScopedValue<RequestContext> CURRENT = ScopedValue.newInstance();
public RequestContext {
Objects.requireNonNull(requestId);
Objects.requireNonNull(userId);
Objects.requireNonNull(traceId);
Objects.requireNonNull(deadline);
attributes = attributes == null ? Map.of() : Map.copyOf(attributes);
}
public static RequestContext of(String requestId, String userId,
String traceId, Instant deadline) {
return new RequestContext(requestId, userId, traceId, deadline, Map.of());
}
public static Runnable wrap(RequestContext context, Runnable task) {
return () -> ScopedValue.where(CURRENT, context).run(task);
}
public RequestContext withAttribute(String key, String value) {
Map<String, String> copy = new HashMap<>(attributes);
copy.put(key, value);
return new RequestContext(requestId, userId, traceId, deadline, copy);
}
public long remainingMillis() {
return Math.max(0L, deadline.toEpochMilli() - System.currentTimeMillis());
}
}
record 的字段是 final,构造完成后不会被重新赋值;Map.copyOf 返回不可修改的副本,调用方拿到的 attributes() 不能 put。withAttribute 不改原对象,而是复制一份再构造新记录;这样多个线程读到的是同一个不可变快照,不需要锁。
安全发布靠线程池的队列。ThreadPoolExecutor.execute 把任务放进 BlockingQueue,工作线程取出执行;JMM 用队列的发布语义建立「提交前的写」happens-before「任务开始执行」,所以 context 的 final 字段和 Map.copyOf 的结果对业务线程可见。自定义的无锁队列、数组传递如果缺少 volatile 或 VarHandle 的发布语义,就没有这个可见性约束;linkd 不自己造这种队列。
HttpRequest 的 byte[] body 是另一个边界。record 只固定引用,数组内容仍可能被解析器复用。跨线程前复制 body 和 headers,或者让解析器每次分配新数组;不要因为「它是个 record」就默认可安全共享。
在 linkd 里落地:ContextPropagationDemo
下面的程序把三种情况放在一条池化线程上:ThreadLocal 不清理、ScopedValue 显式包装、未包装的任务。包名 com.plume.linkd.http。
package com.plume.linkd.http;
import java.time.Instant;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public final class ContextPropagationDemo {
private static final ThreadLocal<RequestContext> LEGACY = new ThreadLocal<>();
public static void main(String[] args) throws Exception {
RequestContext alice = RequestContext.of(
"req-A", "alice", "trace-A", Instant.now().plusSeconds(2));
RequestContext bob = RequestContext.of(
"req-B", "bob", "trace-B", Instant.now().plusSeconds(2));
ExecutorService pool = Executors.newSingleThreadExecutor(r -> new Thread(r, "biz-0"));
pool.submit(() -> {
LEGACY.set(alice);
System.out.println("taskA threadlocal=" + LEGACY.get().userId() + " removed=false");
}).get();
pool.submit(() -> {
RequestContext leaked = LEGACY.get();
System.out.println("taskB threadlocal=" + (leaked == null ? "null" : leaked.userId()));
}).get();
pool.submit(RequestContext.wrap(bob, () ->
System.out.println("wrapped scoped=" + RequestContext.CURRENT.get().userId()))).get();
pool.submit(() ->
System.out.println("unwrapped bound=" + RequestContext.CURRENT.isBound())).get();
ScopedValue.where(RequestContext.CURRENT, alice).run(() -> {
System.out.println("outer=" + RequestContext.CURRENT.get().userId());
ScopedValue.where(RequestContext.CURRENT, bob).run(() ->
System.out.println("inner=" + RequestContext.CURRENT.get().userId()));
System.out.println("restored=" + RequestContext.CURRENT.get().userId());
});
pool.submit(LEGACY::remove).get();
pool.shutdown();
}
}
本机输出(单次运行,未做统计):
taskA threadlocal=alice removed=false
taskB threadlocal=alice
wrapped scoped=bob
unwrapped bound=false
outer=alice
inner=bob
restored=alice
逐行看:
taskA把alice放进 ThreadLocal 后没有remove;taskB是另一个请求、另一个用户,却在同一条biz-0上读到了alice。这就是租户串数据的路径。wrapped scoped=bob:RequestContext.wrap在业务线程上重新绑定了bob,CURRENT.get()拿到的是本次请求。unwrapped bound=false:普通提交没有绑定,ScopedValue不会从提交线程自动带过来。outer/inner/restored:嵌套绑定按作用域恢复,内层结束后外层仍是alice。
程序最后在池化线程上 LEGACY.remove(),是为了演示结束后清理;真实代码应该把 remove() 放在每个任务的 finally,或者直接用 ScopedValue 消除这个清理义务。
常见错误与边界
错误一:在事件循环上 ThreadLocal.set,在业务线程上 get。 两条线程不同,业务线程读到的是 null 或上一次任务的值。必须显式传参或用 wrap 重新绑定。
错误二:ThreadLocal 用完不 remove()。 池化线程复用后,上一个请求的用户、租户、追踪号会留给下一个请求。上面 taskB 读到 alice 就是最小复现;在鉴权场景里这是越权。
错误三:用 InheritableThreadLocal 传递请求上下文。 它只在线程创建时复制,池化线程创建一次后就固定了;后续请求的父线程值不会更新,反而把第一批请求的值永久继承。
错误四:以为 ScopedValue 会自动传播到线程池。 ScopedValue 的绑定是线程本地的;ExecutorService.submit 不会继承。要么 wrap,要么用会继承绑定的结构化并发 API。
错误五:把可变 RequestContext 共享给多个线程。 一个线程 withAttribute 改字段,另一个线程正在读,结果取决于时序。上下文要么全不可变,要么每个线程持有自己的副本。
错误六:把 Connection、SelectionKey 放进上下文。 上下文是数据,不是连接句柄;业务线程拿到它就会越界操作循环本地状态。连接相关的东西留在事件循环,业务线程只拿不可变数据和写回回调。
边界:ScopedValue 不是 ThreadLocal 的全面替代。 需要跨方法隐式传值、作用域清晰的场景用它;需要在线程生命周期内长期保存、且明确由该线程独占的状态,ThreadLocal 仍然合适。两者都不应该用来缓存请求之间的共享数据。
边界:上下文不等于日志 MDC。 用 System.Logger 时没有现成的 MDC 绑定;要么在日志消息里显式带 requestId,要么写一层薄封装从 CURRENT 取值。不要为了日志引入第三方日志框架,本卷只用 JDK。
小结
- 请求上下文必须显式跨线程:事件循环创建,提交业务任务时捕获,业务线程重新绑定。
ThreadLocal在池化线程上会残留,finally里的remove()是硬要求;InheritableThreadLocal只在线程创建时复制,不能解决池化复用。ScopedValue在 JDK 25 正式可用,绑定有作用域、结束自动恢复,但不会自动传播到普通线程池,需要wrap。- 上下文对象用
record加Map.copyOf做成不可变快照;修改用withAttribute返回新对象,不做原地更新。 - 安全发布依赖线程池队列的 happens-before;自定义传递路径必须自己提供
volatile或VarHandle语义。 byte[]字段的 record 不是深度不可变,跨线程前复制数组;连接句柄不要放进上下文。
上下文解决了「请求是谁」,下一章换一个更底层的问题:数据从内核到用户态要复制几次。13.1 从 FileChannel.transferTo 和系统调用边界讲零拷贝,先分清「少一次复制」和「完全不复制」的差别。
阅读导航:上一节:12.2 业务线程池与阻塞隔离 · 下一节:13.1 零拷贝的系统调用边界 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。