先别急着写:大多数场景已经有现成调度器
自定义 Scheduler 的维护成本很高:要自己保证串行性、取消语义、生命周期和拒绝策略。下面这些需求用工厂方法就能覆盖:
| 需求 | 现成入口 | 关键行为 |
|---|---|---|
| 独立单线程、严格串行 | Schedulers.newSingle("name") | 默认非守护线程,必须 dispose() 才能退出 VM |
| 固定数量的计算线程 | Schedulers.newParallel("name", n) | 线程标记为非阻塞,对不会立即完成的源调用 block() 会抛 IllegalStateException |
| 有界阻塞任务池 | Schedulers.newBoundedElastic(...) | 第 6.2 节的上限与队列 |
包装现成 ExecutorService | Schedulers.fromExecutorService(es, "name") | dispose() 会 shutdownNow() 这个线程池 |
包装普通 Executor | Schedulers.fromExecutor(executor) | 不保证 Worker 内 FIFO/串行;取消后任务仍留在队列里 |
| 测试时间相关操作 | VirtualTimeScheduler(reactor-test) | 手动或由 StepVerifier 推进虚拟时钟 |
只有需要「按 key 路由到固定线程」「给每次任务统一打点」这类额外行为时,才值得写装饰器或自定义实现。手写之前,先把接口契约看清楚。
Scheduler 与 Worker 的真实接口
很多资料把接口简化成两个方法,实际签名更完整(省略方法体细节):
public interface Scheduler extends Disposable {
Disposable schedule(Runnable task); // 唯一必须实现的调度方法
default Disposable schedule(Runnable task, long delay, TimeUnit unit) {
throw Exceptions.failWithRejectedNotTimeCapable();
}
default Disposable schedulePeriodically(Runnable task,
long initialDelay, long period, TimeUnit unit) {
throw Exceptions.failWithRejectedNotTimeCapable();
}
default long now(TimeUnit unit) { /* 默认用 System.currentTimeMillis/nanoTime */ }
Worker createWorker();
@Override default void dispose() { } // 默认空实现
default Mono<Void> disposeGracefully() { /* 默认调用 dispose() */ }
@Deprecated default void start() { }
default void init() { start(); }
interface Worker extends Disposable {
Disposable schedule(Runnable task);
default Disposable schedule(Runnable task, long delay, TimeUnit unit) {
throw Exceptions.failWithRejectedNotTimeCapable();
}
default Disposable schedulePeriodically(Runnable task,
long initialDelay, long period, TimeUnit unit) {
throw Exceptions.failWithRejectedNotTimeCapable();
}
}
}
有几个容易被忽略的点:
Scheduler和Worker都继承Disposable,所以isDisposed()是实现类的必选方法。只写schedule和createWorker编译不过。- 延迟和周期调度是默认方法,默认实现直接抛
RejectedExecutionException。实测异常类型是内部子类Exceptions$StaticRejectedExecutionException,消息为Scheduler is not capable of time-based scheduling。 - 如果拿这种「只会立即调度」的调度器去跑
Flux.interval(..., scheduler),错误会被包装成Exceptions$ReactorRejectedExecutionException: Scheduler unavailable。 Scheduler.dispose()有默认空实现,不代表可以不管生命周期;自定义实现必须自己关闭线程资源。start()已标@Deprecated,新实现应关注init()。
两者在操作符链里的分工也不同:
Scheduler.schedule | Worker.schedule | |
|---|---|---|
| 执行位置 | 调度器的任意可用执行槽 | 该 Worker 专属的串行上下文 |
| 顺序 | 接口明确写着 no ordering guarantees | Reactor 实现通常 FIFO;接口措辞是 “should usually” |
| 取消 | 返回的 Disposable 取消单个任务 | 单个任务的 Disposable,外加 worker.dispose() 取消该 Worker 的全部任务 |
| 谁在用 | subscribeOn | publishOn 等需要串行边界的操作符 |
publishOn 依赖 Worker 的串行性:如果 Worker 把任务并发跑在多个线程上,下游就会收到并发的 onNext,违反 Reactive Streams 的信号串行要求。自己实现 Worker 时,这一点必须用单线程执行器保证。
手写一个 LoggingScheduler
下面的实现只做立即调度(不实现延迟/周期),重点展示三条契约:直接调度可以并发、Worker 内严格串行、dispose() 之后拒绝新任务并回收线程。
import reactor.core.Disposable;
import reactor.core.scheduler.Scheduler;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.RejectedExecutionException;
import java.util.concurrent.ScheduledThreadPoolExecutor;
import java.util.concurrent.ThreadFactory;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
public final class LoggingScheduler implements Scheduler {
private final ExecutorService pool;
private final AtomicInteger threadSeq = new AtomicInteger();
private final AtomicInteger workerSeq = new AtomicInteger();
private final Set<LoggingWorker> workers = ConcurrentHashMap.newKeySet();
private final AtomicBoolean disposed = new AtomicBoolean();
public LoggingScheduler(String name, int threads) {
ThreadFactory factory = r ->
new Thread(r, name + "-pool-" + threadSeq.incrementAndGet());
this.pool = Executors.newFixedThreadPool(threads, factory);
}
@Override
public Disposable schedule(Runnable task) {
if (disposed.get()) {
throw new RejectedExecutionException("scheduler disposed");
}
Future<?> future = pool.submit(() -> {
System.out.println("[direct] on "
+ Thread.currentThread().getName());
task.run();
});
return disposable(future);
}
@Override
public Worker createWorker() {
if (disposed.get()) {
throw new RejectedExecutionException("scheduler disposed");
}
LoggingWorker worker = new LoggingWorker(workerSeq.incrementAndGet());
workers.add(worker);
return worker;
}
@Override
public void dispose() {
if (disposed.compareAndSet(false, true)) {
for (LoggingWorker worker : workers) {
worker.dispose();
}
workers.clear();
pool.shutdownNow();
}
}
@Override
public boolean isDisposed() {
return disposed.get();
}
private static Disposable disposable(Future<?> future) {
return new Disposable() {
@Override public void dispose() { future.cancel(true); }
@Override public boolean isDisposed() { return future.isDone(); }
};
}
private final class LoggingWorker implements Worker {
private final int id;
private final ScheduledThreadPoolExecutor executor;
private final AtomicBoolean workerDisposed = new AtomicBoolean();
LoggingWorker(int id) {
this.id = id;
this.executor = new ScheduledThreadPoolExecutor(1,
r -> new Thread(r, "worker-" + id));
this.executor.setRemoveOnCancelPolicy(true);
}
@Override
public Disposable schedule(Runnable task) {
if (workerDisposed.get()) {
throw new RejectedExecutionException("worker disposed");
}
Future<?> future = executor.submit(() -> {
System.out.println("[worker " + id + "] on "
+ Thread.currentThread().getName());
task.run();
});
return disposable(future);
}
@Override
public void dispose() {
if (workerDisposed.compareAndSet(false, true)) {
executor.shutdownNow();
workers.remove(this);
}
}
@Override
public boolean isDisposed() {
return workerDisposed.get();
}
}
}
三个设计点:
- 直接调度用固定线程池:
Scheduler.schedule的契约本来就不保证顺序,两个任务落在custom-pool-1、custom-pool-2上都合法。 - 每个 Worker 一个单线程
ScheduledThreadPoolExecutor:保证同一 Worker 的任务串行且 FIFO。setRemoveOnCancelPolicy(true)让被取消的排队任务及时移出队列,否则它会一直占着位置。 workers集合由 Scheduler 统一回收:Worker 线程默认是非守护线程,Scheduler.dispose()如果不关闭它们,JVM 会一直挂住。这一步在实际调试中很容易漏掉。
运行验证
import reactor.core.scheduler.Scheduler;
public class CustomSchedulerDemo {
public static void main(String[] args) throws Exception {
LoggingScheduler scheduler = new LoggingScheduler("custom", 2);
for (int i = 1; i <= 4; i++) {
int id = i;
scheduler.schedule(() -> {
sleep(120);
System.out.println("direct " + id + " done");
});
}
Scheduler.Worker worker = scheduler.createWorker();
for (int i = 1; i <= 4; i++) {
int id = i;
worker.schedule(() -> {
sleep(60);
System.out.println("worker task " + id + " done");
});
}
Thread.sleep(500);
Scheduler.Worker doomed = scheduler.createWorker();
doomed.schedule(() -> {
try {
Thread.sleep(500);
System.out.println("long task finished");
} catch (InterruptedException e) {
System.out.println("long task interrupted");
}
});
doomed.schedule(() -> System.out.println("queued task ran"));
Thread.sleep(50);
doomed.dispose();
try {
doomed.schedule(() -> System.out.println("after dispose"));
} catch (RuntimeException e) {
System.out.println("after dispose: "
+ e.getClass().getSimpleName());
}
Thread.sleep(600);
scheduler.dispose();
System.out.println("scheduler disposed=" + scheduler.isDisposed());
}
static void sleep(long millis) {
try {
Thread.sleep(millis);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}
一次实测输出:
[direct] on custom-pool-1
[direct] on custom-pool-2
[worker 1] on worker-1
worker task 1 done
[worker 1] on worker-1
direct 1 done
[direct] on custom-pool-1
direct 2 done
[direct] on custom-pool-2
worker task 2 done
[worker 1] on worker-1
worker task 3 done
[worker 1] on worker-1
direct 3 done
direct 4 done
worker task 4 done
[worker 2] on worker-2
long task interrupted
after dispose: RejectedExecutionException
scheduler disposed=true
对号入座:
direct 1..4分布在custom-pool-1和custom-pool-2,完成顺序由线程调度决定,多跑几次可能不同;worker task 1..4全部在worker-1,且严格按提交顺序打印;doomed.dispose()中断了正在运行的worker-2任务,队列里的queued task ran没有执行;- 关闭后的 Worker 再
schedule抛RejectedExecutionException; - 最后只调用
scheduler.dispose(),没有单独调用worker.dispose(),JVM 仍能正常退出,因为 Scheduler 回收了自己创建的 Worker。
虚拟时间:测试时间相关操作
VirtualTimeScheduler 在 reactor-test 里,包名是 reactor.test.scheduler,不是 reactor-core。最常见用法是交给 StepVerifier 注入:
import reactor.core.publisher.Flux;
import reactor.test.StepVerifier;
import java.time.Duration;
@Test
void intervalUsesVirtualClock() {
StepVerifier.withVirtualTime(() ->
Flux.interval(Duration.ofMinutes(1))
.map(i -> "tick" + i)
.take(3))
.expectSubscription()
.expectNoEvent(Duration.ofMinutes(1))
.expectNext("tick0")
.thenAwait(Duration.ofMinutes(1))
.expectNext("tick1")
.thenAwait(Duration.ofMinutes(1))
.expectNext("tick2")
.verifyComplete();
}
这段测试不需要真的等 3 分钟。关键是所有依赖时间的操作符都要在 lambda 内创建:withVirtualTime 会在验证期间替换 Schedulers 工厂,lambda 外创建的操作符拿不到注入的虚拟时钟。
一个常见的错误写法是先在外部创建调度器和流,再只把流交给 withVirtualTime:
VirtualTimeScheduler vts = VirtualTimeScheduler.create();
Flux<String> flux = Flux.interval(Duration.ofMinutes(1), vts)
.map(i -> "tick" + i)
.take(3);
// 错误:注入的是另一个 VirtualTimeScheduler,推进时钟不会影响 vts
StepVerifier.withVirtualTime(() -> flux)
.expectSubscription()
.thenAwait(Duration.ofMinutes(3))
.expectNextCount(3)
.verifyComplete();
实测这个写法 10 秒仍未返回:thenAwait 推进的是注入的调度器,而 flux 用的是外部那个 vts。如果确实要传入已有的调度器,必须用三参数重载,并保证两边是同一个实例:
StepVerifier.withVirtualTime(
() -> Flux.interval(Duration.ofMinutes(1), vts).take(3),
() -> vts,
Long.MAX_VALUE) // 第三个参数是初始 request,不能小于断言需要的元素数
.expectSubscription()
.thenAwait(Duration.ofMinutes(3))
.expectNextCount(3)
.verifyComplete();
这里第三个参数如果写成 1,expectNextCount(3) 会直接报错:
The scenario will hang at expectNextCount(3) due to too little request
being performed for the expectations to finish; request remaining since last step: 1, expected: 3
不用 StepVerifier 时,也可以手动推进时钟:
VirtualTimeScheduler scheduler = VirtualTimeScheduler.create();
AtomicReference<Long> value = new AtomicReference<>();
AtomicInteger completed = new AtomicInteger();
Mono.delay(Duration.ofSeconds(10), scheduler)
.flatMap(i -> Mono.delay(Duration.ofSeconds(5), scheduler))
.subscribe(value::set, error -> error.printStackTrace(),
completed::incrementAndGet);
System.out.println("before: value=" + value.get()
+ " completed=" + completed.get());
scheduler.advanceTimeBy(Duration.ofSeconds(10));
System.out.println("+10s: value=" + value.get()
+ " completed=" + completed.get());
scheduler.advanceTimeBy(Duration.ofSeconds(5));
System.out.println("+15s: value=" + value.get()
+ " completed=" + completed.get());
实测输出:
before: value=null completed=0
+10s: value=null completed=0
+15s: value=0 completed=1
advanceTimeBy(10s) 只执行 10 秒内到期的任务:外层 Mono.delay(10s) 触发并订阅内层 Mono.delay(5s),内层要到 t=15s 才到期,所以第一次推进后还没有值。第二次再推 5 秒,整条链才完成。VirtualTimeScheduler 还提供 advanceTimeTo(Instant) 和 getScheduledTaskCount(),后者可以断言「时钟推进后是否还有挂起的定时任务」。
按 key 路由到固定线程
分区调度器适合「同一个用户/订单的操作必须串行,不同 key 之间可以并行」的场景:
import reactor.core.scheduler.Scheduler;
import reactor.core.scheduler.Schedulers;
public final class PartitionedScheduler {
private final Scheduler[] partitions;
private final int partitionCount;
public PartitionedScheduler(int partitionCount, String name) {
if (partitionCount <= 0) {
throw new IllegalArgumentException("partitionCount must be > 0");
}
this.partitionCount = partitionCount;
this.partitions = new Scheduler[partitionCount];
for (int i = 0; i < partitionCount; i++) {
partitions[i] = Schedulers.newSingle(name + "-" + i);
}
}
public Scheduler forKey(String key) {
int idx = Math.floorMod(key.hashCode(), partitionCount);
return partitions[idx];
}
public void dispose() {
for (Scheduler scheduler : partitions) {
scheduler.dispose();
}
}
}
PartitionedScheduler ps = new PartitionedScheduler(4, "user");
for (String key : Arrays.asList(
"user123", "user456", "user123", "user789", "user123")) {
Mono.fromCallable(() -> {
System.out.println(key + " on "
+ Thread.currentThread().getName());
return key;
}).subscribeOn(ps.forKey(key)).block();
}
ps.dispose();
实测输出:
user123 on user-3-1
user456 on user-2-2
user123 on user-3-1
user789 on user-1-3
user123 on user-3-1
同一个 user123 三次都落在 user-3-1;不同 key 分散到不同分区。线程名末尾的编号来自 Reactor 的全局计数器,分区编号在前缀里,别用末尾编号推断分区数。
几个约束:
Math.floorMod处理负数 hash;直接% partitionCount会得到负下标。- 分区数变化会重新映射所有 key,运行中的流不能靠「改配置」重新平衡。
Schedulers.newSingle(name)默认是非守护线程,dispose()必须执行,否则 JVM 退不出去。subscribeOn只把订阅和请求放到分区线程上;如果内层操作符又用publishOn切走,串行性就不再由分区保证。- 同一个 key 的高并发请求会在该分区的单线程上排队,分区数太少会形成热点。
包装现有调度器:别忘了转发延迟与周期方法
如果只是给任务统一计时,优先写装饰器,而不是从零实现 Scheduler。但装饰器必须把三类 schedule 方法都转发出去:
import reactor.core.Disposable;
import reactor.core.scheduler.Scheduler;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.atomic.LongAdder;
public final class TimingScheduler implements Scheduler {
private final Scheduler delegate;
private final LongAdder executions = new LongAdder();
private final AtomicLong totalNanos = new AtomicLong();
public TimingScheduler(Scheduler delegate) {
this.delegate = delegate;
}
@Override
public Disposable schedule(Runnable task) {
return delegate.schedule(timed(task));
}
@Override
public Disposable schedule(Runnable task, long delay, TimeUnit unit) {
return delegate.schedule(timed(task), delay, unit);
}
@Override
public Disposable schedulePeriodically(Runnable task,
long initialDelay, long period,
TimeUnit unit) {
return delegate.schedulePeriodically(
timed(task), initialDelay, period, unit);
}
@Override
public Worker createWorker() {
Worker worker = delegate.createWorker();
return new Worker() {
@Override
public Disposable schedule(Runnable task) {
return worker.schedule(timed(task));
}
@Override
public Disposable schedule(Runnable task, long delay, TimeUnit unit) {
return worker.schedule(timed(task), delay, unit);
}
@Override
public Disposable schedulePeriodically(Runnable task,
long initialDelay, long period,
TimeUnit unit) {
return worker.schedulePeriodically(
timed(task), initialDelay, period, unit);
}
@Override
public void dispose() {
worker.dispose();
}
@Override
public boolean isDisposed() {
return worker.isDisposed();
}
};
}
@Override
public long now(TimeUnit unit) {
return delegate.now(unit);
}
@Override
public void dispose() {
delegate.dispose();
}
@Override
public boolean isDisposed() {
return delegate.isDisposed();
}
private Runnable timed(Runnable task) {
return () -> {
long start = System.nanoTime();
try {
task.run();
} finally {
totalNanos.addAndGet(System.nanoTime() - start);
executions.increment();
}
};
}
public long executions() {
return executions.sum();
}
public double averageMillis() {
long count = executions.sum();
return count == 0 ? 0 : totalNanos.get() / 1_000_000.0 / count;
}
}
用自建的 newParallel 验证(不要包装全局 Schedulers.parallel() 后再调用 dispose(),那会关掉全进程共享的池):
TimingScheduler timing =
new TimingScheduler(Schedulers.newParallel("timed", 2));
Flux.interval(Duration.ofMillis(50), timing)
.take(4)
.blockLast();
Mono.fromCallable(() -> "ok").subscribeOn(timing).block();
System.out.println("executions=" + timing.executions());
timing.dispose();
实测 executions=5:4 次 interval tick 加 1 次 subscribeOn 的订阅任务。averageMillis() 的值随机器和任务内容变化,只适合做趋势观察,不适合写精确断言。
如果漏掉 schedulePeriodically 的转发,Flux.interval(..., timing) 会立刻失败:
reactor.core.Exceptions$ReactorRejectedExecutionException: Scheduler unavailable
因为 Worker/Scheduler 的默认周期实现本身就会抛「not time capable」,包装层不转发就等于把它关掉了。
常见错误
| 错误 | 后果 | 正确做法 |
|---|---|---|
只实现 schedule、createWorker | 编译失败,缺 isDisposed() | Scheduler 继承 Disposable,两个方法都要实现 |
| Worker 共用 Scheduler 的线程池 | 同一 Worker 的任务可能并发,破坏 publishOn 的串行假设 | 每个 Worker 用独立单线程执行器 |
Scheduler.dispose() 不关闭 Worker | 非守护线程泄漏,JVM 不退出 | 在 Scheduler 内登记并回收所有 Worker |
认为 Scheduler.schedule 有序 | 任务完成顺序与提交顺序不一致 | 需要顺序就用同一个 Worker,或 concatMap |
| 自定义调度器不实现延迟/周期 | interval/delayElements 报 Scheduler unavailable | 转发到支持定时的执行器,或明确只用立即调度 |
包装 fromExecutor(executor) 的 Worker | 接口不保证 FIFO 和串行 | 用 fromExecutor(executor, true) 开启 trampoline,或 newSingle |
包装全局调度器并转发 dispose() | 关掉全进程共享的 parallel/single | 明确所有权:借用的调度器不转发 dispose |
小结
| 关注点 | 结论 |
|---|---|
| 是否要手写 | 优先用 newSingle/newParallel/newBoundedElastic/fromExecutorService |
| 必须实现 | schedule(Runnable)、createWorker()、isDisposed() |
| 延迟/周期 | 默认抛 RejectedExecutionException,需要时显式转发 |
| Worker 串行 | 由实现保证,单线程执行器是最直接的做法 |
| 生命周期 | Scheduler.dispose() 负责它创建的 Worker 与线程池 |
| 测试 | StepVerifier.withVirtualTime 或手动 advanceTimeBy |
| 分区 | newSingle + floorMod(key.hashCode(), n),并处理热点 |
| 装饰器 | 三类 schedule 方法都要转发,明确是否拥有 delegate |
下一章进入背压策略——响应式编程最核心的生产问题。
阅读导航
下一篇:7.1 背压策略:Buffer
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。