背景
有如下多轮对话数据,单线程顺序执行不会有问题,但比较费时间。希望改成多线程,但多线程的需要按照规则执行:同一caseId需要由线程池的同一线程执行,且每个caseId要按照round的顺序执行。
| caseId | round | query |
|---|---|---|
| 1 | 1 | 你好 |
| 1 | 2 | 请问故宫怎么去? |
| 1 | 3 | 故宫门票多少钱? |
| 2 | 1 | 上海好玩吗? |
| 2 | 2 | 怎么去上海? |
技术调研
派发器Dispatch、事件循环EventLoop
派发器(Dispatcher)和 EventLoop 是事件驱动系统中两个紧密协作但职责不同的核心组件。它们共同支撑了异步、非阻塞的程序模型,广泛应用于 GUI 框架(Qt、WPF)、Web 服务器(Node.js、Netty)、游戏引擎等场景。
EventLoop 是“生产者”,派发器是“消费者”的调度员。 EventLoop 把事件从世界(内核/网络/定时器)拉到用户空间; 派发器决定拉到的事件该进哪个房间(回调/Handler/Observer)。
派发器(Dispatcher)
- “事件路由器”,决定事件下一步去哪(路由)。
- 将事件分发给对应的处理器/回调/观察者。
- 不处理事件本身,只负责调度。
典型的实现
| 场景 | 派发器行为 |
|---|---|
| 浏览器 DOM | click → 捕获 → 目标 → 冒泡,由 EventDispatcher 按路径投递。 |
| Spring Reactor | Flux.dispatchOn() 把数据信号路由到指定线程的 EventLoop。 |
| Android Handler | Looper.myQueue() 里的 Message.next() 取出后,由 Handler.dispatchMessage() 派发到用户实现的 handleMessage()。 |
| Netty | ChannelPipeline 本身就是一个链式派发器,fireChannelRead() 把 ByteBuf 按序传递给每个 ChannelHandler。 |
接口定义:
interface Dispatcher {
void dispatch(Event e); // 只负责“送”
void register(EventType t, Handler h);
}
EventLoop 事件循环
- 持续等待事件(I/O、定时器、用户任务)。
- 把事件从内核/队列里取出来。
- 调用派发器把事件交给用户代码。
- 阻塞/非阻塞地循环,直到程序退出。
典型实现:
| 场景 | EventLoop 行为 |
|---|---|
| Node.js | libuv 的 uv_run(loop, UV_RUN_DEFAULT) 不断轮询 epoll/kqueue,拿到就绪 fd 后把 JS 回调扔进 V8。 |
| Netty | 单线程的 NioEventLoop 做 select() → processSelectedKeys() → pipeline.fireChannelRead()。 |
| Qt | QCoreApplication::exec() 内部 while (!quit) { QCoreApplication::processEvents(); } |
| 浏览器 Tab | 渲染进程的主线程合并了 GUI、定时器、网络、微任务、RAF 等多个队列的 EventLoop。 |
伪代码
class EventLoop {
void run() { // 永不返回,直到 quit
while (!quit) {
Event e = waitEvent(); // 可能阻塞在 epoll/select
dispatcher.dispatch(e); // 交给派发器
}
}
}
EventLoop退出方案
1、标志位法(单线程最常用)
原理:在循环条件里放一枚 volatile boolean quit,外部线程把它置 true,下一轮循环自然退出。
适用:Netty、手写轻量级循环、游戏引擎。
注意事项:如果 poll 阻塞时间太长,可换成 offer(E, timeout) 或 wake-up 信号(见方式 3)。 结束后务必排空剩余任务(drainRemainingTasks),保证“优雅”。
class SingleThreadLoop {
private final BlockingQueue<Runnable> tasks = new LinkedBlockingQueue<>();
private volatile boolean quit = false;
public void run() {
while (!quit) { // ① 检查标志
Runnable r = tasks.poll(1, TimeUnit.SECONDS);
if (r != null) r.run();
}
// ② 清理逻辑
drainRemainingTasks();
}
public void shutdown() { quit = true; } // ③ 外部调用
}
2、中断法(打断阻塞点)
调用 Thread.interrupt(),让 wait()/sleep()/Selector.select() 抛出 InterruptedException,循环捕获后退出。
适用:Java 旧版 NIO、自己写的 wait/notify 循环。
注意:中断只是唤醒,循环里仍需手动判断中断标志,否则可能“假死”。不要把中断用于其他业务语义,避免冲突。
Thread loopThread = new Thread(() -> {
try {
while (!Thread.currentThread().isInterrupted()) {
doSelect(); // 内部是 selector.select()
}
} catch (InterruptedException swallowed) {
// 重新中断状态,防止屏蔽
Thread.currentThread().interrupt();
} finally {
closeSelector();
}
});
// 外部结束
loopThread.interrupt();
3、Wakeup + 标志位(Netty 精华)
原理:Selector.select() 会阻塞,外部线程调用 selector.wakeup() 立即返回,再检查 volatile 标志。
适用:任何基于 Java NIO Selector 的框架(Netty、Mina、自研网关)。wakeup() 一次即可,无竞态。 超时 + 双检查,即使 wakeup 丢失也能退出。
private final AtomicBoolean wakenUp = new AtomicBoolean();
private volatile int state = ST_STARTED; // ST_SHUTDOWN = 1
void run() {
for (;;) {
try {
int selected = selector.select(100); // 有限超时
if (state == ST_SHUTDOWN) break; // ① 双检查
processSelectedKeys(selected);
} catch (IOException e) { rebuildSelector(); }
}
// ② 真正关闭
closeAllChannels();
}
public void shutdown() {
if (!state.compareAndSet(ST_STARTED, ST_SHUTDOWN)) return;
if (wakenUp.compareAndSet(false, true)) {
selector.wakeup(); // ③ 立即唤醒
}
}
4、任务毒丸(Poison Pill)
原理:向队列里塞一个特殊对象,消费者拿到后自杀。
适用:生产者-消费者模型、线程池、Akka Mailbox。
注意:毒丸数量 = 消费者线程数,少了会死不全。队列必须是有界时,先扩容或先 offer 避免阻塞。
enum PoisonPill { INSTANCE; }
class Worker implements Runnable {
private final BlockingQueue<Runnable> q;
public void run() {
try {
for (;;) {
Runnable r = q.take();
if (r == PoisonPill.INSTANCE) break; // 自杀
r.run();
}
} catch (InterruptedException ignore) {}
}
}
// 外部结束:每个Worker塞1颗毒丸
for (Worker w : workers) q.put(PoisonPill.INSTANCE);
5、计数器门闩(Latch / semaphore)
原理:维护待处理任务计数,计数为 0 且收到“结束信号”时退出。
适用:批量任务、Map-Reduce 风格、游戏帧同步。自然等待所有任务做完再退出,零残留。 比毒丸更灵活,可支持动态任务。
class CountingLoop {
private final AtomicInteger pending = new AtomicInteger(0);
private volatile boolean shutdown = false;
public void submit(Runnable r) {
pending.incrementAndGet();
execute(() -> {
try { r.run(); }
finally { if (pending.decrementAndGet() == 0 && shutdown) quit(); }
});
}
public void shutdown() {
shutdown = true;
if (pending.get() == 0) quit();
}
}
6、进程级信号(优雅退出二步曲)
原理:JVM 收到 SIGINT(Ctrl+C)或 SIGTERM(k ill)→ 注册 Shutdown Hook → 回调 EventLoopGroup.shutdownGracefully()。
适用:生产环境容器(Docker/K8s)、Spring Boot、Netty 服务端。
K8s 配合: 设置 terminationGracePeriodSeconds: 30;Pod 收到 SIGTERM 后,代码里最多有 30 s 时间完成方式 3/5 的收尾。
钩子内可返回 HTTP 503、拒绝新连接、等待存量请求结束。
代码(Netty 官方模板):
EventLoopGroup boss = new NioEventLoopGroup(1);
EventLoopGroup worker = new NioEventLoopGroup(0);
try {
ServerBootstrap b = new ServerBootstrap()
.group(boss, worker)
.childHandler(new ChannelInitializer<>(){...});
ChannelFuture f = b.bind(8080).sync();
f.channel().closeFuture().sync(); // ① 阻塞主线程
} finally {
// ② 优雅关闭:先停 boss,再停 worker
boss.shutdownGracefully().syncUninterruptibly();
worker.shutdownGracefully().syncUninterruptibly();
}
6种方法总结:
| 方式 | 信号手段 | 阻塞点如何醒来 | 优雅度 | 典型框架 |
|---|---|---|---|---|
| 1. 标志位 | volatile boolean | 超时轮询 | ★★☆ | 自研、游戏循环 |
| 2. 中断 | Thread.interrupt() | 抛 InterruptedException | ★★☆ | 旧 NIO |
| 3. Wakeup+标志 | selector.wakeup() | 立即返回 | ★★★ | Netty |
| 4. 毒丸 | 特殊对象 | take() 返回毒丸 | ★★★ | Akka、线程池 |
| 5. 计数器门闩 | AtomicInteger | 任务计数到 0 | ★★★ | 批处理 |
| 6. 进程信号 | SIGTERM → Hook | 调用 shutdownGracefully | ★★★ | K8s、Spring Boot |
实现
Query定义
public class Query {
public final String caseId;
public final String text;
public final long seq;
public Query(String caseId, String text, long seq) {
this.caseId = caseId;
this.text = text;
this.seq = seq;
}
@Override
public String toString() {
return "Query{" + caseId + ", seq=" + seq + '}';
}
}
OrderedDispatcher
采用Dispatcher+EventLoop实现,使用毒药法控制EventLoop结束
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.LinkedBlockingQueue;
/**
* 有序分发器
* Query投递到分发器,分发器用caseId路由到对应的线程池,保证caseId有序
*/
public class OrderedDispatcher {
private final EventLoop[] loops;
private final ConcurrentHashMap<String, EventLoop> routeCache = new ConcurrentHashMap<>();
private final Query POISON_QUERY = new Query("", "", -1);
/**
* @param loopCount 线程池数量
* @param processor Query处理器
*/
public OrderedDispatcher(int loopCount, QueryProcessor processor) {
loops = new EventLoop[loopCount];
for (int i = 0; i < loopCount; i++) {
loops[i] = new EventLoop("event-loop-" + i, processor);
loops[i].start();
}
}
/**
* 提交Query,根据caseId投递到EventLoop
*
* @param q Query
*/
public void submit(Query q) {
// caseId映射EventLoop,做缓存
EventLoop loop = routeCache.computeIfAbsent(q.caseId,
k -> loops[Math.abs(k.hashCode() % loops.length)]);
// 缓存后提交query
loop.submit(q);
}
/**
* 优雅关闭
*/
public void shutdown() {
for (EventLoop l : loops) {
l.shutdownGracefully();
}
}
/**
* 等待执行结束
*/
public void join() {
// 给每个EventLoop丢一个毒丸
for (EventLoop l : loops) {
System.out.println("===>push poison query");
l.submit(POISON_QUERY);
}
}
/**
* 事件循环
*/
private final class EventLoop extends Thread {
/* 阻塞队列,存储query */
private final BlockingQueue<Query> queue = new LinkedBlockingQueue<>();
/* Query处理器 */
private final QueryProcessor processor;
/* 运行标记 */
private volatile boolean running = true;
/**
* @param name 线程名称
* @param processor Query处理器
*/
EventLoop(String name, QueryProcessor processor) {
super(name);
this.processor = processor;
}
/**
* 外部并发调用
*
* @param q query
*/
void submit(Query q) {
// 永不阻塞,队列可设边界再细化
queue.offer(q);
}
/**
* 优雅停止
*/
void shutdownGracefully() {
running = false;
interrupt();
}
/**
* 事件循环
*/
@Override
public void run() {
System.out.println("开始事件循环");
while (running) {
try {
// 阻塞,顺序消费
Query q = queue.take();
if (q == POISON_QUERY) {
break;
}
processor.process(q);
} catch (InterruptedException e) {
// 被中断后继续把剩余的处理完
}
}
// 把剩余的处理完
System.out.println("开始处理剩余任务");
Query q;
while ((q = queue.poll()) != null) {
if (q == POISON_QUERY) {
break;
}
processor.process(q);
}
System.out.println("结束处理剩余任务");
}
}
}
测试
import java.util.Random;
public class Demo {
static class MyTask implements QueryProcessor {
@Override
public void process(Query q) {
System.out.println(
String.format("[%s] caseId=%s, round=%s, query=%s",
Thread.currentThread().getName(),
q.caseId,
q.seq, // 这里复用 seq 字段存 round
q.text));
// 随机sleep时间,模拟接口响应间隔
try {
Thread.sleep(new Random().nextInt(5000));
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
}
public static void main(String[] args) {
// 1. 创建分发器:4 个线程,打印式处理器
OrderedDispatcher dispatcher = new OrderedDispatcher(
4,
new MyTask()
);
// 2. 把表格数据一次性丢进去
dispatcher.submit(new Query("1", "你好", 1));
dispatcher.submit(new Query("1", "请问故宫怎么去?", 2));
dispatcher.submit(new Query("1", "故宫门票多少钱?", 3));
dispatcher.submit(new Query("2", "上海好玩吗?", 1));
dispatcher.submit(new Query("2", "怎么去上海?", 2));
// 等待执行完成
dispatcher.join();
}
}
演示:
开始事件循环
开始事件循环
开始事件循环
开始事件循环
===>push poison query
===>push poison query
===>push poison query
开始处理剩余任务
===>push poison query
结束处理剩余任务
开始处理剩余任务
结束处理剩余任务
[event-loop-1] caseId=1, round=1, query=你好
[event-loop-2] caseId=2, round=1, query=上海好玩吗?
[event-loop-1] caseId=1, round=2, query=请问故宫怎么去?
[event-loop-2] caseId=2, round=2, query=怎么去上海?
[event-loop-1] caseId=1, round=3, query=故宫门票多少钱?
开始处理剩余任务
结束处理剩余任务
开始处理剩余任务
结束处理剩余任务
Process finished with exit code 0