Reactor Context是如何做到"ThreadLocal"的?

13 阅读10分钟

Reactor 的 Context 设计确实非常反直觉,因为它颠覆了我们习惯的“命令式编程”中自上而下的传参思维。

为了回答这个问题,我们需要穿过代码表象,去探究 Reactor 核心团队(如 Stéphane Maldini 和 Simon Baslé)在设计 Context 时的核心矛盾和取舍。


第一部分:架构师视角 —— 为什么要这么设计?

“逻辑定义在 Publisher 里,为什么要依赖下游(Subscriber)来设置 Context?谁能保证下游一定设置了?”

要理解这一点,必须接受响应式流的一个核心公理:Publisher 只是蓝图(Blueprint),Subscription 才是运行时(Runtime)。

1. 解耦“定义”与“执行” (Decoupling Definition from Execution)

想象一下 ThreadLocal。当你写一个方法 void saveData() 时,你并不把 Connection 传进去,而是假定“当前运行这个方法的线程”里已经有了 Connection。

  • ThreadLocal 逻辑: 运行环境(线程)决定了上下文。
  • Reactor 逻辑: 谁订阅(Subscribe),谁就是运行环境。

设计思考:

如果我们在 Publisher 里硬编码设置 Context,那么这个 Publisher 就“脏”了,它被绑定死在一个具体的环境里了。

Reactor 希望 Publisher 是无状态的、可复用的

举例:

假设你有一个 Mono<Void> saveOp = repository.save(user);

  • 场景 A(生产): Controller 调用 saveOp,通过 WebFilter 在下游注入了“生产库连接”。
  • 场景 B(测试): 单元测试调用 saveOp,在下游注入了“Mock 连接”。

如果 Context 是在 Publisher 内部定义的(自上而下),你就必须修改 saveOp 的定义才能切换环境。而通过自下而上(Upstream Propagation) ,同一个 saveOp 实例可以被不同的下游订阅,每次订阅自动获得不同的 Context。

2. 作用域的本质:Context = Subscription Scope

在响应式流中,没有“全局”的概念,只有“订阅链”的概念。

  • 数据流(Data Flow) :是从上游流向下游的(OnNext)。
  • 控制流(Control Flow) :是从下游流向上游的(Subscribe / Request)。

核心矛盾: 事务(Transaction)或安全令牌(Token)这种东西,它的生命周期应该覆盖整个业务处理过程

  • 当我们在链条最末端(WebFlux 框架层)收到 HTTP 请求时,我们才确定了“这是一个什么用户的请求,需要什么事务”。
  • 这个时刻,是在 subscribe() 发生的瞬间确定的。
  • 因此,上下文必须伴随着 subscribe() 动作,逆着数据流的方向,一层层传给上游的数据库操作符
3. 回答你的疑问:谁保证 ContextWrite 一定被调用?

你担心:“如果上游依赖 Context,但下游没人写 contextWrite 怎么办?”

答案是:框架层保证。

在 Spring WebFlux 中,你几乎看不到手写 contextWrite 的代码。

  • Spring Security:在 WebFilter 里,在整个处理链的最外层(也就是最下游),偷偷帮你执行了 contextWrite(SecurityContext)
  • Spring TX:在 @Transactional 切面中,通过操作符拦截,帮你执行了 contextWrite(TransactionContext)

对于业务开发者来说,Context 是透明的。你只管在上游用 deferContextual 取,框架负责在下游(入口处)存。这和 ThreadLocal 在 Tomcat 线程池入口处赋值是一样的道理。


第二部分:源码级别解析 —— 架构与流转

接下来我们深入 Reactor Netty / Core 的源码,看看 Context 到底长什么样,以及它是怎么“逆流而上”的。

1. 数据结构:Context 是什么?

reactor-core 中,Context 本质上是一个高度优化的、不可变的 LinkedHashMap

由于 Context 的读写频率极高(每个操作符都会透传它),Reactor 团队没有直接用 HashMap(太重,且线程不安全),而是设计了 ContextN

  • Context0: 空上下文。
  • Context1: 存 1 个 KV。
  • Context2: 存 2 个 KV。
  • ...
  • ContextN: 超过 5 个 KV 时使用 Map 实现。

源码特征(Immutable):

Java

// 伪代码演示设计思想
interface Context {
    // 写入不是修改当前对象,而是返回一个新的!
    Context put(Object key, Object value); 
    <T> T get(Object key);
}

每次调用 contextWrite,都会像洋葱一样包一层,生成一个新的 Context 对象,旧的对象保持不变。这保证了在并发分支(比如 flatMap 里的多线程)中,Context 是绝对线程安全的。

2. 存储位置:CoreSubscriber

Context 存在哪里?它不是存在 Thread 里,而是存在 Subscriber 对象里。

Reactor 定义了一个扩展接口 CoreSubscriber,它是所有 Reactor 内部 Subscriber 的基类:

Java

// reactor.core.CoreSubscriber
public interface CoreSubscriber<T> extends Subscriber<T> {
    
    // 【核心】每个订阅者必须携带一个 Context
    default Context currentContext() {
        return Context.empty();
    }
}

这意味着,Reactor 调用链上的每一个节点(Operator Subscriber) ,都持有 Context。

3. 运行流程:逆流而上的传递

我们通过一个最简单的链路来追踪:

Source -> map -> contextWrite -> Subscriber

代码:

Java

Mono.just("data")              // 1. Source
    .map(d -> d)               // 2. MapOperator
    .deferContextual(ctx -> ..)// 3. Access (读取)
    .contextWrite(k, v)        // 4. Write (写入)
    .subscribe();              // 5. Downstream

Step 1: 组装阶段(Assembly)

创建了一串包装对象:ContextWrite(Map(Source))

Step 2: 订阅阶段(Subscribe - 自下而上)

这是 Context 传递的关键时刻!

  1. 用户调用 subscribe()

  2. ContextWriteSubscriber 被激活。

    • 它的构造函数接受了下游传来的 Context(可能是空的)。
    • 关键动作: 它执行 put(k, v),生成了一个新的 Context(包含 k=v)。
    • 它调用上游的 subscribe(this),并重写了 currentContext() 方法,返回这个新 Context
  3. MapSubscriber 被激活。

    • 它调用 actual.currentContext()。因为它的 actual 是上面的 ContextWriteSubscriber,所以它拿到了包含 k=v 的 Context。
    • 它继续向上调用 source.subscribe(this),透传这个 Context。
  4. MonoDeferContextual 被激活。

    • 这是一个特殊的 Source。当它被 subscribe 时,它不发数据,而是直接看 actual.currentContext()
    • Bingo! 它看到了 k=v。于是它执行你的 Lambda 逻辑,把 Context 里的 Connection 拿出来。

源码模拟(简化版):

Java

// 代表 contextWrite 操作符的 Subscriber
class ContextWriteSubscriber<T> implements CoreSubscriber<T> {
    
    final CoreSubscriber<? super T> actual; // 下游
    final Context context; // 当前层合并后的 Context

    public ContextWriteSubscriber(CoreSubscriber<? super T> actual, Context toMerge) {
        this.actual = actual;
        // 【关键】合并下游的 Context 和当前要写入的 Context
        this.context = actual.currentContext().putAll(toMerge);
    }

    @Override
    public void onSubscribe(Subscription s) {
        actual.onSubscribe(s);
    }

    @Override
    public Context currentContext() {
        // 【关键】当上游问我要 Context 时,我给它这个合并后的
        return this.context;
    }
}

当手动切换线程时,Context是否依然能保证多线程间传递正确性

事实上, “在线程频繁切换时依然能保持上下文传递” ,正是 Reactor 放弃 ThreadLocal 而发明 Context根本原因

我们通过三个层面来彻底解开这个疑惑:


一、 核心原理:Context 绑定的是“对象”,而不是“CPU 核心”

在 Java 堆内存中,对象(Object) 是跨线程共享的。

  • ThreadLocal 是绑定在 Thread.currentThread() 这个“动态变量”上的。
  • Context 是绑定在 Subscriber 这个“静态对象”上的(注:指引用关系上的静态,非 static 关键字)。

形象的比喻:

  • ThreadLocal(本地坐席) :你把钱包放在了**“1 号会议室”**。如果你被调度去“2 号会议室”开会,你就拿不到钱包了。

  • Context(随身背包) :你把钱包放在了你的**“背包”**里。

    • 即使 publishOn 把你(Subscriber)从“1 号会议室”赶到了“2 号会议室”。
    • 即使 subscribeOn 让你一开始就在“3 号会议室”排队。
    • 不管你在哪个房间,背包(Context)是背在你身上的,你走到哪,钱包就在哪。

二、 场景推演:线程切换时发生了什么?

假设我们有这样一段代码:

Java

Mono.deferContextual(ctx -> {
        // 3. 在这里获取 Connection
        System.out.println("执行线程:" + Thread.currentThread().getName());
        return Mono.just(ctx.get("conn"));
    })
    // 2. 强制切换线程!
    .publishOn(Schedulers.boundedElastic()) 
    // 1. 写入 Context
    .contextWrite(ctx -> ctx.put("conn", "MyConnection")) 
    .subscribe();
1. 订阅阶段 (Subscription Phase) —— Context 的传递

Context 是在 subscribe()从下往上传递的。

  • Step A (subscribe) : 触发。

  • Step B (contextWrite) : 创建了包含 "MyConnection" 的新 Context。

  • Step C (publishOn) :

    • publishOn 也是一个 Operator,它内部有一个 PublishOnSubscriber
    • 当下游把 Context 传给它时,它把这个 Context 存在了自己的字段里(或者它持有下游 Subscriber 的引用,通过引用能找到 Context)。
    • 它继续向上游 subscribe,把这个 Context 递上去。
  • Step D (deferContextual) : 此时 Context 已经传递到了顶层。

结论: 在“组装管道”的阶段(订阅流),Context 已经顺着对象引用链(Linked List)传到了最上游。这个过程完全不受线程调度的影响,因为这只是对象之间的方法调用。

2. 运行阶段 (Execution Phase) —— 跨线程读取

现在,数据开始流动,或者逻辑开始执行。

  • publishOn 发挥作用了。它告诉上游:“嘿,把任务交给我,我要换个线程去跑”。
  • Reactor 的调度器(Scheduler)启动一个新的线程(比如 boundedElastic-1)。
  • 这个新线程开始执行 deferContextual 里的 Lambda 表达式。

关键时刻:

在新线程里,代码执行 ctx.get("conn")

这个 ctx 对象是从哪里来的?

  • 它是 deferContextual 在订阅阶段就拿到的那个对象。
  • 这个对象存在于 Java 堆内存(Heap) 中。
  • 所有线程都能访问堆内存里的对象。

只要 Subscriber 对象的引用还在,新线程就能通过引用访问到里面的 Context。不需要做任何“线程间数据拷贝”的操作(不像 InheritableThreadLocal 那样需要复制),因为对象本身就是共享的


三、 源码视角的证据

如果我们看 publishOn 的源码(FluxPublishOn),会发现它的逻辑大致如下(简化版):

Java

// PublishOnSubscriber.java
static class PublishOnSubscriber<T> implements CoreSubscriber<T>, Runnable {
    
    final CoreSubscriber<? super T> actual; // 下游订阅者
    final Scheduler.Worker worker;          // 新线程的调度器

    @Override
    public void onNext(T t) {
        // 1. 收到上游数据,不做处理,放入队列
        queue.offer(t);
        // 2. 调度一个任务到新线程去执行
        worker.schedule(this); 
    }

    @Override
    public void run() {
        // --- 这里已经是新线程了 ---
        T data = queue.poll();
        
        // 3. 把数据推给下游
        // 【重点】actual 是一个 Java 对象,它一直在这里,没变过
        actual.onNext(data); 
    }

    @Override
    public Context currentContext() {
        // 4. 如果有人问我要 Context
        // 我就问下游(actual)要。即使我现在运行在新线程,actual 还是那个 actual。
        return actual.currentContext();
    }
}

解析:

run() 方法在新线程执行时,它依然持有 actual 这个成员变量。通过 actual,它就能顺藤摸瓜找到 Context。

总结

  1. Context 依然有意义:它是解决 Reactive 异步环境上下文丢失的唯一正解。
  2. 原理ThreadLocal 依赖线程栈,Context 依赖对象堆。
  3. 切换线程publishOn / subscribeOn 只是改变了**“谁来执行代码” (CPU),并没有改变“代码操作的是哪个对象”**(Memory)。
  4. 只要链条不断,Context 就在:无论中间经过多少次线程跳跃,只要 Subscriber 的引用链是连通的,Context 就能通过 currentContext() 方法沿着对象链逆流而上被找到。

总结:对比传统编码 vs 响应式编码

为了让你更直观地理解,我们可以把 Context 想象成 HTTP 请求头(Headers) ,把 Subscriber 想象成 服务器节点

场景传统编码 (ThreadLocal)响应式编码 (Reactor Context)
存储介质线程 (Thread)订阅者 (Subscriber)
查找方式去“当前房间”(线程)的口袋里找问“我的下家”(Subscriber)要
生命周期即使请求结束,线程还在,数据可能残留(内存泄漏风险)请求处理完,Subscriber 对象被 GC,Context 自动消失(零风险)
传递方向隐式,伴随线程栈帧反向,伴随 subscribe 动作从下往上
本质比喻“全局变量” (环境决定行为)“依赖注入” (调用者决定环境)

思考:

Reactor 团队选择这种设计,是为了在异步、线程切换频繁的场景下,提供一种确定性的、作用域绑定准确的上下文管理机制。

  • ThreadLocal 是隐式的、不稳定的(线程会变)。
  • Context 是显式的、稳定的(订阅链一旦建立就不变)。

虽然它在写代码时(contextWrite 在下,defer 在上)看起来很怪,但在运行时subscribe 阶段),它是唯一能保证数据在整个 Operator 链条中正确传递的方案。