Alice 与 Bob 的合成请求先在执行器A处理,再到执行器B;Bob的第二步先执行并抛错,Alice随后成功。每个阶段记录的用户、请求id、Request属性和MDC均一致。两条工作线程再被复用时,只剩各自原有的 worker 标记,没有上一次请求的身份。

另一组对照只使用 Play 的 ClassLoaderExecution:工作线程看到被捕获的类加载器,却没有看到调用线程的 callerOnly MDC。执行上下文传播了哪些值,需要按实现与实验逐项说明。

显式请求值与线程状态

Request是业务方法的显式输入;MDC是与当前线程相关的日志状态;线程上下文类加载器又是另一种线程状态。切换执行器后,Request引用仍可传给回调,但线程相关状态不会仅因引用存在就自动复制。

实验把两个合成身份关联到从原请求派生的新请求,并构造不可变上下文记录:

1
2
private record RequestContext(Http.Request request, String requestId,
String user, Map<String, String> mdc) {}

Request中的 TypedKey 与 record字段互相校验,MDC快照使用 Map.copyOf。这里的身份字符串由实验固定生成,不构成认证,也不来自用户可伪造的Header。第26篇再验证身份与对象权限的关联。

当前实现并没有持有整个请求作为长期后台任务的通用上下文。真实业务可只捕获后续步骤确实需要的不可变字段,避免把body、session或大型引用保留到请求生命周期之外。

addAttr 派生请求,不修改原请求

Java Request实现通过新属性映射构造请求。实验分别添加 USER 与 REQUEST_ID,再确认原请求仍不含这两个键。

1
Http.Request decorated = original.addAttr(REQUEST_ID, id).addAttr(USER, user);

这个外层不可变操作不提供属性值的深复制。若属性里放的是可变 List 或共享对象,多个请求仍可能看到同一个对象的修改。当前属性采用String,MDC快照采用不可变Map,才使本例的值语义明确。

跨线程可用也不意味着每个输入都值得继续保留。请求已返回后,捕获的字段可能仍存在,但授权、库存或连接资源是否仍有效,是各自独立的问题。

每一步设置并恢复 MDC

ContextLab在调用线程为每个合成身份建立快照,再把快照作为普通值传给回调。执行回调时保存当前工作线程原状态,安装当前请求快照,finally恢复:

1
2
3
4
5
6
7
8
9
private static <T> T withMdc(Map<String, String> captured, Supplier<T> work) {
Map<String, String> previous = MDC.getCopyOfContextMap();
MDC.setContextMap(captured);
try { return work.get(); }
finally {
if (previous == null) MDC.clear();
else MDC.setContextMap(previous);
}
}

恢复原值与一律clear不同。本例预先在工作线程A放 worker=a,B放 worker=b;若每次回调只clear,会删除此前属于工作线程的标记,虽然不会残留请求身份,却不满足完整恢复契约。

try/finally覆盖正常与异常退出。Bob第二步故意抛出 IllegalStateException,恢复操作仍执行;随后复用该线程检查其MDC,才能观察到异常后是否遗留状态,而不是只检查请求输出。

用受控交错暴露串值

两个身份分别提交第一步到A,第二步到B。第一步记录后等待属于自己的release Future;驱动先释放Bob,等其失败终态,再释放Alice:

事件顺序 执行位置 身份结果
Alice:first lab-context-a Request/record/MDC均Alice
Bob:first lab-context-a Request/record/MDC均Bob
Bob:second lab-context-b 三者均Bob,随后失败
Alice:second lab-context-b 三者均Alice,成功

这是同一有限实验中两条派生请求链,受控复用相同两条线程;不是两个真实登录用户的认证测试。线程先处理谁由明确释放协议确定,避免依赖随机sleep“碰到”并发错误。

最终Alice结果ok,Bob结果failed;A/B复用后分别读到 {worker:a} 与 {worker:b}。同时确认原请求属性、调用线程MDC与类加载器均保持原状态。每条观测对应一项断言,不能只写“两个请求都返回成功,所以没有串值”。

类加载器包装的边界

ClassLoaderExecutionContext保存目标线程的旧上下文类加载器,设为捕获值,执行Runnable,finally恢复旧值。这段包装没有MDC复制逻辑。

实验构造标记类加载器,在调用线程调用 ClassLoaderExecution.fromThread(guarded),然后先恢复调用线程状态,再提交工作。工作线程观察标记可见、callerOnly MDC不可见、原worker标记仍在。

正常和抛错两个分支都在同一工作线程上运行。异常被包装器外面的guard捕获,因此Play包装器自己的finally已经执行,工作线程也存活,可以继续检查其旧类加载器恢复情况。

包装器对照 当前结果
capturedLoaderSeen true
callerMdcCopied false
workerLoaderRestored,正常/失败 true / true
workerMdcRestored,正常/失败 true / true

最后一项MDC恢复在这里只表示类加载器包装没有改变原工作线程MDC,不表示它曾安装过调用线程MDC。把这个结果描述成“Play自动传播全部上下文”,会与callerMdcCopied=false矛盾。

CustomExecutionContext.current()捕获当前类加载器,文档要求短期使用,不应把这个捕获执行器长期保存为所有请求的共享上下文。命名dispatcher可以长期作为应用资源,某次调用的捕获值则应按其作用域处理。

传播范围与日志证据

传播应明确字段、捕获时点、安装范围和恢复责任。给回调传入正确requestId,能够支持日志关联;它不等于遥测上下文已跨全部WS、Scala Future与流边界,也不证明接收端已经收到了span。第28篇与E06分别验证日志计数和真实遥测接收。

MDC内容也不应随意包含完整body、密钥或用户凭据。本例只用合成用户和请求id,并且没有把调用者原有MDC序列化到实验响应中。

重跑与练习

累计工程的ContextLab与AsyncTest保存快照、交错和复用检查。按第00篇RUN.md准备环境,执行:

1
2
3
bash sbtw test stage
python3 lab/async_checks.py --dev
python3 lab/async_checks.py

12–14隔离副本累计26项JUnit通过;共享累计工程34项测试与stage通过,开发、生产模式各198次HTTP检查通过。/async/context输出上述检查字段,原始证据保存于batch12-17。快照、安装和清理都是被测对象,不把源代码阅读代替本次运行。

反例题:回调结束后统一MDC.clear,是否满足“恢复进入前的工作线程状态”?它会删除原有worker标记;本例复用断言能发现这类错误。

改动练习:将一次MDC恢复从finally移到正常返回之前,再执行Bob异常路径与线程复用检查。用失败断言定位遗漏,随后恢复finally;不要只依靠正常路径日志看起来正确来通过验证。

上一篇:线程池与阻塞。下一篇:超时取消与重试。累计源码:最小应用。