StreamHandlerContext 用 ThreadLocal 解决的问题是:多阶段生成流水线里,标题、大纲、正文、配图四个阶段的代码都要往同一个 SSE 连接推送事件,如果不做上下文传递,SseEmitter 就要从 Controller 一路透传到最深层的方法,每个方法签名都得多带一个参数。项目用 StreamHandlerContext 这个 ThreadLocal 包装器把流处理器放进线程上下文,任何阶段的代码通过静态方法即可拿到当前请求的流处理器,顺带统一了发送、完成、错误、清理的生命周期管理。但它有边界:生成任务跑在异步线程池,普通 ThreadLocal 跨线程取不到值,这里用了提前捕获引用进闭包的方案。下面把解决的问题、实现、异步陷阱和清理细节讲透。
一、问题:流处理器参数透传
流水线的方法链是这样的:Controller 调 pipeline.run(),pipeline 调 titleAgent.generate(),再传 outlineAgent、contentAgent、imageAgent。如果每个方法都带一个 StreamHandler 参数,接口签名会变成这样:
String generate(String topic, StreamHandler handler);
String generateByOutline(String outline, StreamHandler handler);
List<ImageResult> generateForContent(String content, StreamHandler handler);
看起来只多一个参数,实际写下去全是问题。业务方法被迫感知流式细节,测试要构造 handler,重构时少传一处就编译不过,代码里到处是透传样板。把 handler 放进上下文后,接口恢复纯净:
String generate(String topic); // 内部 StreamHandlerContext.get()
String generateByOutline(String outline);
List<ImageResult> generateForContent(String content);
1.1 透传带来的三类代价
第一,可测性下降。单元测试要为一个业务方法构造 handler 桩,测试代码比业务代码还啰嗦。第二,接口漂移。智能体实现换成别的模型、别的渠道,参数列表跟着变,改动面扩大。第三,误用风险。透传参数在中间层被改、被覆盖,出了问题难定位。ThreadLocal 方案把这些代价收敛到一处:进入流水线时设置,结束时清理,中间任意深度的代码都能取到。
二、StreamHandlerContext 的实现
实现很薄,一个静态 ThreadLocal 加四个静态方法:
public class StreamHandlerContext {
private static final ThreadLocal<StreamHandler> HOLDER = new ThreadLocal<>();
public static void set(StreamHandler handler) { HOLDER.set(handler); }
public static StreamHandler get() { return HOLDER.get(); }
public static void send(Object data) {
StreamHandler h = HOLDER.get();
if (h != null && h.isOpen()) {
h.send(data);
}
}
public static void clear() { HOLDER.remove(); }
}
Controller 在提交任务前设置,任务执行完毕清理。各阶段智能体不再关心 handler 从哪来,只调用 StreamHandlerContext.send(...),事件自动流向当前请求的 SSE 连接。多个用户同时生成文章时,ThreadLocal 的线程隔离保证各请求互不串流,A 用户的事件不会推到 B 用户的连接上。
2.1 生命周期收口
done 事件、error 事件、连接关闭全部由 Context 统一触发,业务代码不直接碰 emitter.complete()。发送前判 isOpen(),连接已断开就跳过,避免往死连接上写抛 IOException。这样生命周期只有一套出口,谁没清理、谁忘了关闭,看 Context 一眼就知道。
2.2 使用顺序固定成模板
Context 的使用顺序在项目里固定成五步,所有接入点照抄:
- Controller 创建 StreamHandler 并调用
StreamHandlerContext.set(handler); - 提交生成任务前,把 handler 引用捕获进闭包变量;
- 异步线程内先
set重建上下文,再执行流水线; - 各阶段通过
StreamHandlerContext.send推送事件,不直接碰 handler; - finally 中调用
clear()清理,防止线程复用串扰。
模板写进代码评审 checklist,任何新增的异步边界都必须按这五步走。
三、ThreadLocal 在异步线程下的失效问题
SSE 的流式推送天然要求生成任务在独立线程池执行,而这正是 ThreadLocal 的雷区:ThreadLocal 只在写入它的那个线程里有效,任务切换到异步线程后,StreamHandlerContext.get() 返回 null。直接沿用会静默丢事件,连报错都没有,排查成本极高。
| 场景 | ThreadLocal 是否有效 | 原因 |
|---|---|---|
| 同步 Servlet 请求 | 有效 | 全程同一 Tomcat 线程 |
| SSE + 手动开线程 | 失效 | 切换到新线程 |
| WebFlux + SSE | 失效 | Reactor 操作符触发线程切换 |
项目里的解法是闭包捕获:在提交任务时把 handler 引用抓进 lambda,异步线程内显式 StreamHandlerContext.set(handler) 重建上下文,用完再 clear()。不用 InheritableThreadLocal,它只能覆盖一层父子线程,线程池复用场景下二次传递依然丢失,属于看着能行、实则脆弱的方案。
StreamHandler handler = StreamHandlerContext.get();
generationExecutor.execute(() -> {
StreamHandlerContext.set(handler); // 进入异步线程重建上下文
try {
pipeline.run(task);
} finally {
StreamHandlerContext.clear();
}
});
3.1 每个异步边界都套同一套模板
配图阶段用 CompletableFuture 并行,回调线程同样取不到 Context。每张图完成的回调里先 set 再 send 再 clear,模板和主流水线一致。项目把这段抽成一个小工具方法,传入 handler 和业务逻辑,内部统一重建、执行、清理,杜绝漏写的可能。
3.2 虚拟线程下同样要重建
用虚拟线程的线程池执行生成任务,Context 依旧取不到。虚拟线程的执行体不在创建它的线程上,ThreadLocal 值不会跟着过去,闭包捕获的模板照常适用。项目里评估过把生成线程池换成虚拟线程,结论是重建 Context 的代码一行不用改,迁移成本集中在别处。
四、清理与内存泄漏
handler 的生命周期收敛到 Context 里统一处理,清理放在 finally 里,ThreadLocal.remove() 防止线程池复用导致的上下文串扰和内存泄漏。池里的线程不清理,下一次任务可能读到上一次残留的 handler,这是 ThreadLocal 最常见的生产事故。泄漏不只影响逻辑:ThreadLocal 持有对象引用,对象里又挂着 SseEmitter,连接不释放,内存和连接资源双流失。
五、三种传递方案对比
| 方案 | 透传侵入 | 异步线程可用 | 风险点 |
|---|---|---|---|
| 方法参数透传 | 高 | 可用 | 签名膨胀、易漏传 |
| ThreadLocal + 闭包捕获 | 低 | 可用 | 每个异步边界需手动重建 |
| InheritableThreadLocal | 低 | 单层可用 | 线程池复用二次传递丢失 |
ThreadLocal 加闭包捕获的组合在复杂度、侵入性、正确性之间取到了平衡,异步边界虽多,但每处代码模式统一,review 时一眼能看出有没有漏掉 set 和 clear。
常见问题(FAQ)
Q1:为什么不用 InheritableThreadLocal 传递?
它只能传一层子线程,线程池复用后二次传递会丢,不如闭包捕获可靠。
Q2:异步线程里 ThreadLocal 为什么取不到?
ThreadLocal 按线程隔离,任务切到新线程后原线程的值不可见,必须重建。
Q3:忘记清理 ThreadLocal 会怎样?
线程池复用线程会读到残留上下文,导致事件串流或内存泄漏。