Flux / Mono 自定义线程调度
线程调度主要讲(Schedulers / publishOn / subscribeOn)
前置:你已经学过
handle、generate、create、buffer、limitRate、doOnXXX、onXxx、Sinks。本文聚焦「响应式代码跑在哪些线程上」------这是把响应式程序从「能跑」变成「跑得对、跑得稳」的分水岭。
1. 一句话定位:Scheduler 是什么
Reactor 默认在调用线程 上同步执行。一旦要「异步」「并行」「把阻塞 IO 隔离」,就必须把部分链路交给一个 Scheduler。
Scheduler= 响应式世界里的「线程池抽象」。它不直接暴露Thread,而是提供Worker(一个可调度任务的执行单元)。你通过publishOn(scheduler)/subscribeOn(scheduler)把链路贴到某个调度器上。
关键认知:Reactor 不会自动帮你切线程。你不调 publishOn/subscribeOn,一切都还在当前线程(通常是 main 或 Netty EventLoop)上跑------在 EventLoop 上 sleep/阻塞会直接拖垮整个服务。
2. 内置 Schedulers 速查表
| 调度器 | 线程模型 | 适合场景 | 备注 |
|---|---|---|---|
Schedulers.immediate() |
当前线程(不切) | 调试、想「就在这线程跑」 | 不是新线程 |
Schedulers.single() |
全局单条 Worker 线程 | 需要严格串行化的任务 | 进程内共享单例 |
Schedulers.parallel() |
固定 N=CPU核数 条线程 |
CPU 密集型计算 | 默认并行度=Runtime.getRuntime().availableProcessors() |
Schedulers.boundedElastic() |
弹性线程池(有上限) | 阻塞 IO(DB/HTTP/文件) | 默认上限 10×核数线程 + 10万任务队列,防 OOM |
Schedulers.fromExecutor(Executor) |
用你给的线程池 | 接入既有线程池 | 自定义调度器入口 |
Schedulers.fromExecutorService(ExecutorService) |
同上,可关闭 | 接入既有线程池 | 同上 |
Schedulers.newParallel(name, n) |
新建独立 parallel 池 | 隔离某类计算 | 用 dispose() 释放 |
Schedulers.newBoundedElastic(...) |
新建独立弹性池 | 隔离某类 IO | 用 dispose() 释放 |
Schedulers.newSingle(name) |
新建独立单线程池 | 隔离某类串行任务 | 用 dispose() 释放 |
黄金法则:
- CPU 密集 / 纯计算 →
parallel(别在它上面阻塞)。 - 阻塞 IO / 等待外部 →
boundedElastic(别在它上面跑重计算,浪费线程)。 - 不同业务线要隔离线程池(避免 A 业务 IO 拥塞把 B 业务拖死)→ 自定义调度器。
3. 自定义调度器(本文重点)
3.1 基于普通线程池:Schedulers.fromExecutorService
最常用场景:接入项目里已有的 ExecutorService,或给线程池命名(排查问题时一眼看出是哪条业务线)。
3.2 基于 Reactor 自带的 newXxx
当你需要独立、可控、用完可释放的池子时用它们(而不是污染全局共享池)。
3.3 命名线程工厂(排查利器)
默认线程名是 parallel-1、boundedElastic-3 这种,看不出业务。自定义 ThreadFactory 给线程加前缀,线上排查线程 dump 时能直接定位。
4. publishOn vs subscribeOn(位置语义,最重要)
这是全文最容易被搞混的点,务必结合第 5 节的样例亲手跑一遍。
4.1 publishOn(scheduler) ------ 改变「它之后」的线程
publishOn作用在**它下游(代码顺序中它下方)**的所有操作符,直到遇到下一个publishOn。- 每遇到一个
publishOn,后续链路就被切到那个调度器。 - 可多次叠加,每加一个就再切一次。
4.2 subscribeOn(scheduler) ------ 决定「订阅与源」发生在哪
subscribeOn决定订阅动作本身 + 上游源的产生在哪个线程上发生。- 它影响的是「链的起点」,对「下游已经被
publishOn接管的处理线程」没有影响。 - 多次
subscribeOn时,最靠近 source(上游)的那一个生效(代码里最靠上的那个)。
记忆法:
publishOn:从它往下换线程(下游视角)。subscribeOn:从它往上(含源)换线程(上游视角)。
5. 完整可运行样例
样例 1:内置调度器线程名一览
java
import reactor.core.publisher.Flux;
import reactor.core.scheduler.Schedulers;
public class BuiltinSchedulers {
public static void main(String[] args) throws InterruptedException {
show("immediate", Schedulers.immediate());
show("single", Schedulers.single());
show("boundedElastic", Schedulers.boundedElastic());
show("parallel", Schedulers.parallel());
Thread.sleep(300);
}
static void show(String name, reactor.core.scheduler.Scheduler s) {
Flux.range(1, 1)
.publishOn(s)
.doOnNext(i -> System.out.println(
name + " -> 线程: " + Thread.currentThread().getName()))
.blockLast();
}
}
预期输出(顺序可能因调度略有差异,线程名前缀稳定):
immediate -> 线程: main
single -> 线程: single-1
boundedElastic -> 线程: boundedElastic-1
parallel -> 线程: parallel-1
注意
immediate在当前main线程------它不开新线程,只是「就在调用线程跑」。
样例 2:位置语义最直观对照
java
import reactor.core.publisher.Flux;
import reactor.core.scheduler.Schedulers;
public class PublishOnVsSubscribeOn {
static int log(String tag, int i) {
System.out.println(tag + " @" + Thread.currentThread().getName() + " 值=" + i);
return i * 10;
}
public static void main(String[] args) throws InterruptedException {
System.out.println("=== publishOn:逐个切换下游线程 ===");
Flux.range(1, 3)
.map(i -> log("map1(源端)", i)) // 订阅线程:main
.publishOn(Schedulers.parallel())
.map(i -> log("map2(parallel后)", i)) // parallel 线程
.publishOn(Schedulers.boundedElastic())
.map(i -> log("map3(elastic后)", i)) // boundedElastic 线程
.blockLast();
System.out.println("\n=== subscribeOn:决定订阅/源发生的线程 ===");
Flux.range(1, 3)
.subscribeOn(Schedulers.parallel()) // 订阅+源都搬到 parallel
.map(i -> log("A(parallel订阅)", i)) // parallel 线程
.map(i -> log("B", i)) // 仍在 parallel
.blockLast();
System.out.println("\n=== 多个 subscribeOn:最靠近 source 的生效 ===");
Flux.range(1, 3)
.subscribeOn(Schedulers.parallel()) // 最靠近 source
.map(i -> log("X", i)) // 在 parallel
.subscribeOn(Schedulers.boundedElastic()) // 下游,被上游覆盖
.blockLast();
Thread.sleep(200);
}
}
预期输出:
=== publishOn:逐个切换下游线程 ===
map1(源端) @main 值=1
map1(源端) @main 值=2
map1(源端) @main 值=3
map2(parallel后) @parallel-1 值=10
map2(parallel后) @parallel-1 值=20
map2(parallel后) @parallel-1 值=30
map3(elastic后) @boundedElastic-1 值=100
map3(elastic后) @boundedElastic-1 值=200
map3(elastic后) @boundedElastic-1 值=300
=== subscribeOn:决定订阅/源发生的线程 ===
A(parallel订阅) @parallel-1 值=1
A(parallel订阅) @parallel-1 值=2
A(parallel订阅) @parallel-1 值=3
B @parallel-1 值=10
B @parallel-1 值=20
B @parallel-1 值=30
=== 多个 subscribeOn:最靠近 source 的生效 ===
X @parallel-1 值=1
X @parallel-1 值=2
X @parallel-1 值=3
关键观察:最后的
X跑在parallel而非boundedElastic------证明「多个 subscribeOn,最靠近 source 的赢」。
样例 3:自定义命名线程池 + newBoundedElastic
java
import reactor.core.publisher.Flux;
import reactor.core.scheduler.Schedulers;
import reactor.core.scheduler.Scheduler;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.ThreadFactory;
import java.util.concurrent.atomic.AtomicInteger;
public class CustomScheduler {
public static void main(String[] args) throws InterruptedException {
// 1) 自定义命名固定线程池(接入既有池 / 排查友好)
ThreadFactory tf = new ThreadFactory() {
private final AtomicInteger n = new AtomicInteger(1);
public Thread newThread(Runnable r) {
Thread t = new Thread(r, "biz-pool-" + n.getAndIncrement());
t.setDaemon(true);
return t;
}
};
ExecutorService pool = Executors.newFixedThreadPool(4, tf);
Scheduler customPool = Schedulers.fromExecutorService(pool);
System.out.println("=== 自定义固定线程池(fromExecutorService)===");
Flux.range(1, 6)
.publishOn(customPool)
.doOnNext(i -> System.out.println(
"线程: " + Thread.currentThread().getName() + " 值=" + i))
.blockLast();
pool.shutdown(); // 用普通线程池记得关掉
// 2) Reactor 自带 newBoundedElastic(带上限、TTL、命名,用完 dispose)
Scheduler elastic = Schedulers.newBoundedElastic(
8, // 最多 8 个线程
1000, // 任务队列上限 1000
"my-elastic",
60, // 空闲 60s 回收
false);
System.out.println("\n=== newBoundedElastic ===");
Flux.range(1, 4)
.publishOn(elastic)
.doOnNext(i -> System.out.println(
"线程: " + Thread.currentThread().getName() + " 值=" + i))
.blockLast();
elastic.dispose(); // 释放,避免线程泄漏
Thread.sleep(200);
}
}
预期输出:
=== 自定义固定线程池(fromExecutorService)===
线程: biz-pool-1 值=1
线程: biz-pool-2 值=2
线程: biz-pool-3 值=3
线程: biz-pool-4 值=4
线程: biz-pool-1 值=5
线程: biz-pool-2 值=6
=== newBoundedElastic ===
线程: my-elastic-1 值=1
线程: my-elastic-2 值=2
线程: my-elastic-3 值=3
线程: my-elastic-4 值=4
线程名前缀
biz-pool-/my-elastic-是由你的ThreadFactory/name参数决定的------线上排查一眼可定位业务线。
样例 4:实战组合:IO 用弹性池、计算用并行池
java
import reactor.core.publisher.Mono;
import reactor.core.scheduler.Schedulers;
public class IoThenCompute {
static String blockingIo(int id) {
try { Thread.sleep(200); } catch (InterruptedException ignored) {}
return "io-result-" + id;
}
static int compute(String s) { return s.hashCode(); }
public static void main(String[] args) {
String result = Mono.fromCallable(() -> blockingIo(1))
.subscribeOn(Schedulers.boundedElastic()) // ① 阻塞 IO 放进弹性池
.map(IoThenCompute::compute) // ② 仍在弹性池(IO 刚结束)
.publishOn(Schedulers.parallel()) // ③ 切到并行池做 CPU 计算
.map(h -> "hash=" + h)
.block();
System.out.println("done -> " + result);
}
}
预期输出:
done -> hash=逻辑值
这条链是 WebFlux / R2DBC 业务里最经典的写法:① 把阻塞调用用
subscribeOn(boundedElastic)搬离 EventLoop;③ 计算阶段publishOn(parallel)利用多核。
6. 常见坑
- 在 EventLoop /
main上阻塞 :响应式框架(WebFlux)的 IO 线程是 EventLoop,上面sleep/DB 调用会拖垮所有请求。任何阻塞都要subscribeOn(boundedElastic)。 parallel上做阻塞 IO :parallel线程数=CPU 核数,被阻塞就再无线程做计算了 → 全服务卡死。阻塞一律用boundedElastic。boundedElastic上跑重计算 :弹性池线程多,跑纯计算既浪费又可能和其他 IO 争抢。计算用parallel。- 每次请求都
newBoundedElastic/newParallel:会不断新建线程池、泄漏线程。应声明为static final常量复用 ,或明确dispose()释放。 publishOn/subscribeOn位置写反 :想切「处理线程」却写了subscribeOn(它只管源);想切「源线程」却写了publishOn。记住 4.1 / 4.2 的位置语义。- 以为
subscribeOn能多线程并行发元素 :subscribeOn只是把订阅搬到单条线程,不会把一个 Flux 并行化处理(那要用parallel()操作符或flatMap的concurrency)。
7. 速查表(收藏级)
| 你想做 | 用哪个 |
|---|---|
| 纯计算、并行处理 | Schedulers.parallel() + publishOn |
| 阻塞 IO(DB/HTTP/文件) | Schedulers.boundedElastic() + subscribeOn |
| 严格串行某类任务 | Schedulers.single() / newSingle(name) |
| 接入已有线程池 | Schedulers.fromExecutorService(pool) |
| 给业务线隔离独立池 | Schedulers.newBoundedElastic(...) / newParallel(...) |
| 切换「下游处理」线程 | publishOn(scheduler) |
| 切换「源/订阅」线程 | subscribeOn(scheduler) |
| 给线程命名便于排查 | 自定义 ThreadFactory 或 name 参数 |
8. 所以呢?
- 调度不是「锦上添花」,是「正确性」 :在响应式框架里,阻塞放错线程 = 生产事故。
parallel算、boundedElastic堵,是铁律。 - 自定义调度器用于「隔离 + 可排查」 :给线程池命名、给不同业务线独立池,线上出问题看线程 dump 能秒定位。语法就是
Schedulers.fromExecutorService(pool)或Schedulers.newBoundedElastic(...)。 publishOn管下游、subscribeOn管源:位置一错,看到的现象完全两样------务必跑一遍第 5 节样例,把「线程名」印在脑子里。