《Java 并发编程·线程与内存模型》13.3 上下文传递与线程池隔离

异步把执行线程换掉了,ThreadLocal 里的请求 ID 也就断了。本节实测 commonPool 与裸线程池里读到的上下文都是 null,给出在提交时捕获、执行时安装的 Executor 装饰器,并用 1 线程池的 418ms 对 20ms 说明为什么三个下游要各自隔离线程池。

承接的问题

13.1 把三路通知扇出到线程池,13.2 给每一路加了超时和降级。到这里代码是「对」的,但排查问题时会发现一个尴尬的事实:push 的日志里没有 requestId。

原因是执行线程换了。HTTP 请求进来时,过滤器把 requestId、traceId、会员 ID 放进 ThreadLocal;同步代码里任何一层都能读到。一旦 supplyAsync 把任务交给另一个线程,读到的就是那个线程自己的 ThreadLocalMap——空的。

这不是 CompletableFuture 的缺陷,而是 ThreadLocal 的定义:它绑定线程,不绑定逻辑调用链。这一节解决两件事:上下文怎么跨线程传过去,以及为什么每个下游要用自己的池。

默认执行器与上下文丢失

先把「丢了」量出来。上下文体是一个 record,存在 ThreadLocal 里:

record Ctx(String requestId, String traceId) {}

static final ThreadLocal<Ctx> CTX = new ThreadLocal<>();

static String current() {
    Ctx c = CTX.get();
    return c == null ? "null" : c.requestId() + "/" + c.traceId();
}

在 main 里设置之后,分别用三种方式执行:

== 提交线程 ==
  main -> REQ-1001/TRACE-7
== 默认 commonPool ==
  ForkJoinPool.commonPool-worker-1 -> null
== 自定义池,无包装 ==
  bare-worker -> null

两种「换线程」都丢,原因不同但结果一样:

  • 默认执行器是 ForkJoinPool.commonPool()(13.1 实测 parallel=9)。commonPool 的 worker 是全局共享的,不可能带着某个请求的上下文。
  • 自定义池的线程是预先创建的,创建时 main 还没设置上下文。所以 InheritableThreadLocal 也救不了:它只在线程创建时复制一次父线程的值,池线程早就创建好了(7.2 已经完整复现过这个断裂)。

ScopedValue 对普通线程和线程池任务同样不继承——只有 StructuredTaskScope.fork(JDK 25 preview)派生的子任务会自动继承(7.3 实测过)。没有任何一种上下文机制能自动跟随「任务」走,因为「任务」这个概念对 JVM 不可见。

装饰器:提交时捕获,执行时安装

正确的位置只有一个:在提交任务的线程上捕获,在执行任务的线程上安装。

这和 7.1 的 MemberScopedTask 是同一个思路:一个逐任务包装,一个装饰执行器。后者覆盖面更全,因为 CompletableFuture 的提交入口不经过你的任务对象。

/** Executor 装饰器:在 execute 时捕获提交线程的上下文。 */
static final class ContextExecutor implements Executor {
    private final Executor delegate;

    ContextExecutor(Executor delegate) {
        this.delegate = delegate;
    }

    @Override
    public void execute(Runnable command) {
        Ctx captured = CTX.get();               // 必须在提交线程上读
        delegate.execute(() -> {
            Ctx previous = CTX.get();
            CTX.set(captured);
            try {
                command.run();
            } finally {
                if (previous == null) {
                    CTX.remove();               // 不留给下一个任务
                } else {
                    CTX.set(previous);          // 嵌套时恢复外层
                }
            }
        });
    }
}

四个细节,每一个都对应一类线上 bug:

  • 捕获必须在 execute 里、在 delegate.execute 之前。如果把 CTX.get() 写进 lambda 里,它会在工作线程执行,读到 null。
  • finally 里要恢复而不是无脑 remove。工作线程可能被嵌套包装(比如两层装饰器),直接 remove 会把外层的上下文也弄丢。
  • 没有旧值时必须 remove。池线程是复用的,留着上下文会让下一个任务读到上一个请求的 requestId——这就是「串值」。
  • 装饰 Executor 而不是逐个任务包装。CompletableFuture.supplyAsync(s, executor)、execute、submit 最终都走 execute,装饰一层就全覆盖;逐个包任务总会漏掉某个入口。

实测(同一个 main 线程设置 REQ-1001/TRACE-7,任务提交两次):

== 自定义池,提交时捕获 ==
  task1: ctx-worker-1 -> REQ-1001/TRACE-7
  task2: ctx-worker-1 -> REQ-1001/TRACE-7
== 不清理造成的串值 ==
  第一个任务 set 后读到 REQ-A/TRACE-A(未 remove)
  第二个任务没 set,读到 REQ-A/TRACE-A
== 包装后池内残留检查 ==
  包装任务跑完后,同一池线程读到 null(裸提交,不经过包装)

第三段专门验证「清理」:包装任务跑完后再用裸提交同一个池线程,读到的是 null,说明 finally 里的 remove 生效了。第二段是反例——任务里自己 set 却不 remove,下一个任务直接读到了别人的请求 ID。

非 Async 回调带的是谁的上下文

这里有一个比「丢失」更隐蔽的问题:thenApply 这类非 Async 回调由完成者线程执行(future 已完成时除外,那一支在注册线程上跑,13.1 实测),它带的是完成者线程的上下文,而不是注册回调那个线程的。

在按请求独立提交的场景里,这两个上下文通常是同一个,看不出问题。但只要有跨请求共享的 future(缓存、批量聚合、全局单例),回调就可能带着 A 请求的 requestId 去写 B 请求的日志。无法确认时,跨请求的回调一律用 thenApplyAsync(fn, contextExecutor)。

实测两种情况:主线程装的是 REQ-B,另起一个线程装 REQ-A 去完成 future。

已完成 future,注册者 REQ-B 上的 thenApply 看到 = REQ-B
未完成 future,完成者 REQ-A 触发的回调看到 = sms:ok@REQ-A

第一条符合直觉:future 已完成,回调就在注册者线程上同步跑。第二条是坑:注册者是 REQ-B,回调却打印了 REQ-A——它跑在完成者的线程上。共享 future 时,日志里的 requestId 会指向另一个请求。

上下文里该放什么

不是所有东西都适合塞进 ThreadLocal。判断标准是「它是不是横切关注点」:

字段放不放理由
requestId / traceId放排查与链路追踪的索引
tenantId放鉴权与数据隔离,每层都要用
剩余预算(绝对时刻)放13.2 的超时需要逐层递减
memberId 等业务参数不放应该显式传参,隐式依赖难以测试
可变对象(集合、计数器)不放池线程复用会串,且难以清理

「剩余预算」值得单独说。超时不该在每一层重新给一个常量,而应该由最外层给一个绝对截止时刻,子任务只算剩余。给 Ctx 加上第三个字段:

record Ctx(String requestId, String traceId, long deadlineNanos) {}

static long remainingMillis(Ctx ctx) {
    return Math.max(0, (ctx.deadlineNanos() - System.nanoTime()) / 1_000_000);
}

// 提交侧:用上下文里的剩余预算,而不是新的 200ms
Ctx ctx = CTX.get();
CompletableFuture<String> f = CompletableFuture
        .supplyAsync(() -> gateway.send("push", memberId, message), pushExec)
        .orTimeout(remainingMillis(ctx), TimeUnit.MILLISECONDS);

System.nanoTime() 是 JVM 内的单调时钟,跨线程比较是安全的(只在同一 JVM 内有效),所以传绝对时刻比传「还剩多少毫秒」更准——后者在排队时就已经开始计时了。这样上层给了 300ms,排队花掉 120ms,下游只剩 180ms,而不是又拿到 300ms。

池隔离:一个慢下游不该拖垮另外两个

上下文解决的是「日志对不对」,池隔离解决的是「一个下游挂了,另外两个还活不活」。

先用一个最小实验看清共享池的代价:池只有 1 个线程,先提交一个 400ms 的 push,10ms 后提交一个 20ms 的 mail,量 mail 自己的延迟。

共享 1 线程池:mail 自身延迟 = 418ms / 421ms / 425ms
隔离 1+1 线程池:mail 自身延迟 = 20ms / 23ms / 22ms

三组数据几乎一致:共享池里 mail 要等 push 占着的 400ms,自己的 20ms 完全被埋掉;隔离之后它只花自己的 20ms。

真实系统里这个数字会放大:push 卡 30 秒,共享池的 20 个线程会在 30 秒内被 20 个 push 请求占满,mail 和 sms 一起饿死。这就是**线程池作为舱壁(bulkhead)**的意义——把故障限制在一个下游的池子里。

隔离的粒度按「故障域」划分,不按「代码模块」:

隔离方式一个池每个下游一个池
故障传播慢下游拖垮所有下游只影响自己
线程总量一个上限各自上限,总和是新的成本
上下文串值风险高,必须严格清理每池独立,仍需清理
排障看线程名分不清是谁线程名直接标出下游

代价是线程变多。三个下游各 8 个线程就是 24 个,还要另外算排队队列的内存。所以池大小不是越大越好:每个池按该下游的并发度设定,超过就排队,排队比无限扩线程便宜得多(10.2 的拒绝策略在这里同样适用)。

在 libraryd-conc 里落地

NotificationDispatcher 把两件事合起来:三个下游三个池,每个池外面套一层上下文装饰器。

public final class NotificationDispatcher {
    private final Executor mailExec;
    private final Executor smsExec;
    private final Executor pushExec;
    private final NotificationGateway gateway;

    public NotificationDispatcher(ExecutorService mailPool, ExecutorService smsPool,
                                  ExecutorService pushPool, NotificationGateway gateway) {
        this.mailExec = new ContextExecutor(mailPool);
        this.smsExec = new ContextExecutor(smsPool);
        this.pushExec = new ContextExecutor(pushPool);
        this.gateway = gateway;
    }

    public NotifyReport dispatch(String memberId, String message) {
        Map<String, CompletableFuture<ChannelResult>> futures = new LinkedHashMap<>();
        futures.put("mail", submit("mail", mailExec, memberId, message));
        futures.put("sms", submit("sms", smsExec, memberId, message));
        futures.put("push", submit("push", pushExec, memberId, message));

        CompletableFuture.allOf(futures.values().toArray(new CompletableFuture[0])).join();
        return new NotifyReport(memberId, futures.values().stream()
                .map(CompletableFuture::join).toList());
    }

    private CompletableFuture<ChannelResult> submit(String channel, Executor pool,
                                                    String memberId, String message) {
        return CompletableFuture.supplyAsync(() -> {
            Ctx ctx = CTX.get();                // 由 ContextExecutor 安装
            String receipt = gateway.send(channel, memberId, message);
            return ChannelResult.ok(channel, receipt, ctx);
        }, pool).handle((r, e) -> e == null
                ? r
                : ChannelResult.failed(channel, e));
    }
}

三点说明:

  • ContextExecutor 是字段,不是每次调用 new 一个。它无状态,复用没有额外开销;每次新建只是浪费对象。
  • 池的创建与关闭在装配层(conc-core 的启动代码),NotificationDispatcher 只接收 ExecutorService。这样测试时能塞同步执行器,也能在关闭时按 10.3 的两段式依次 shutdown。
  • ChannelResult 里带上了 ctx,异步结果回到调用线程后,日志、指标、审计都还能拿到原始 requestId。只靠线程上下文是不够的——结果一旦离开工作线程,上下文就又断了。

上下文真正的起点在入口,那里也必须成对地设置与清理:

public final class RequestContextFilter {
    public void around(String requestId, String traceId, long budgetMs, Runnable chain) {
        Ctx previous = CTX.get();
        CTX.set(new Ctx(requestId, traceId,
                System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(budgetMs)));
        try {
            chain.run();
        } finally {
            if (previous == null) {
                CTX.remove();
            } else {
                CTX.set(previous);
            }
        }
    }
}

入口线程本身就是复用的(Web 容器的工作线程、事件循环线程都是池),不清理同样会串值。异步侧用装饰器、同步侧用 try/finally,两端都做对,上下文才真正完整。

Ctx、ChannelResult、NotifyReport、NotificationGateway 是支撑类型与端口,不在 COMMON 的领域契约内;LoanService.reserve 返回的 Reservation 仍然是同步结果,通知编排不改变领域签名。

常见错误与边界

错误后果正确做法
在 lambda 里读 ThreadLocal读到 null,因为已经在工作线程在提交线程捕获后闭包传入
finally 里只 remove 不恢复嵌套装饰时丢掉外层上下文有旧值就恢复,没有才 remove
包装任务却不清理池线程串值,日志张冠李戴finally 保证清理
用 InheritableThreadLocal 传池任务池线程创建于设置之前,永远为 null显式装饰器传递
所有下游共用一个池一个慢下游拖垮全部按故障域分池
每个请求新建一个池线程数爆炸,创建开销吃掉收益池在装配层创建并复用
只传 requestId 不传 traceId跨服务链路断掉上下文整体传递,别挑字段
结果离开工作线程后还想读上下文回到调用线程后又是 null把上下文放进结果对象

什么时候不要用线程上下文传递。 如果一段逻辑的执行线程是确定的(同步调用、单线程事件循环),显式参数比 ThreadLocal 清楚得多,也更难出错。ThreadLocal 适合「横切关注点」——日志、鉴权、租户、超时预算——不适合传业务参数。另外,不要用上下文传递可变状态:一个线程安装的 Ctx 如果被下游修改,会影响同一个池线程上的后续任务;上下文应该是不可变的 record,这也是上面用 record 的原因。虚拟线程场景下这条更严格:线程数量大、生命周期短,ThreadLocal 的清理成本会被放大(14.3 展开)。

小结

  • 异步换线程,ThreadLocal 必丢:实测 commonPool 与裸线程池读到的都是 null;InheritableThreadLocal 对池线程无效,因为线程早已创建(7.2)。
  • 传递的唯一正确位置是「提交时捕获、执行时安装」。ContextExecutor 装饰 Executor,覆盖 supplyAsync、execute、submit 所有入口。
  • 三个必须做对的细节:捕获在提交线程、finally 恢复旧值、无旧值时 remove。实测不清理会让下一个任务读到 REQ-A。
  • 非 Async 回调由完成者线程执行,带的是完成者上下文;跨请求共享 future 时必须用 Async 变体。
  • 池隔离按故障域划分。1 线程共享池里 mail 的延迟是 418–425ms,隔离后是 20–23ms;代价是线程总数上升,池大小要按各下游并发度设定。

第 13 章到此结束。CompletableFuture 解决了「结果怎么拼」,但也暴露了它的天花板:任务与线程没有从属关系,取消不能传播,上下文要手工搬。第 14 章换一个执行模型——虚拟线程;第 15 章再往上一步,用结构化并发把「任务属于谁」写进代码。

阅读导航:上一节 13.2 异常、超时与取消 | 下一节 14.1 虚拟线程的调度模型

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《Java 消息中间件·事件驱动架构》附录 B 架构决策记录模板
  2. 《Java 消息中间件·事件驱动架构》附录 A 术语表
  3. 《Java 消息中间件·事件驱动架构》15.3 全书验收