【多线程】同组数据交给多线程中同一线程顺序执行

63 阅读7分钟

背景

有如下多轮对话数据,单线程顺序执行不会有问题,但比较费时间。希望改成多线程,但多线程的需要按照规则执行:同一caseId需要由线程池的同一线程执行,且每个caseId要按照round的顺序执行。

caseIdroundquery
11你好
12请问故宫怎么去?
13故宫门票多少钱?
21上海好玩吗?
22怎么去上海?

技术调研

派发器Dispatch、事件循环EventLoop

派发器(Dispatcher)和 EventLoop 是事件驱动系统中两个紧密协作但职责不同的核心组件。它们共同支撑了异步、非阻塞的程序模型,广泛应用于 GUI 框架(Qt、WPF)、Web 服务器(Node.js、Netty)、游戏引擎等场景。

EventLoop 是“生产者”,派发器是“消费者”的调度员。 EventLoop 把事件从世界(内核/网络/定时器)拉到用户空间; 派发器决定拉到的事件该进哪个房间(回调/Handler/Observer)。

派发器(Dispatcher)

  • “事件路由器”,决定事件下一步去哪(路由)。
  • 将事件分发给对应的处理器/回调/观察者。
  • 不处理事件本身,只负责调度。

典型的实现

场景派发器行为
浏览器 DOMclick → 捕获 → 目标 → 冒泡,由 EventDispatcher 按路径投递。
Spring ReactorFlux.dispatchOn() 把数据信号路由到指定线程的 EventLoop。
Android HandlerLooper.myQueue() 里的 Message.next() 取出后,由 Handler.dispatchMessage() 派发到用户实现的 handleMessage()
NettyChannelPipeline 本身就是一个链式派发器,fireChannelRead()ByteBuf 按序传递给每个 ChannelHandler

接口定义:

interface Dispatcher {
    void dispatch(Event e);          // 只负责“送”
    void register(EventType t, Handler h);
}

EventLoop 事件循环

  • 持续等待事件(I/O、定时器、用户任务)。
  • 把事件从内核/队列里取出来。
  • 调用派发器把事件交给用户代码。
  • 阻塞/非阻塞地循环,直到程序退出。

典型实现:

场景EventLoop 行为
Node.jslibuvuv_run(loop, UV_RUN_DEFAULT) 不断轮询 epoll/kqueue,拿到就绪 fd 后把 JS 回调扔进 V8。
Netty单线程的 NioEventLoopselect()processSelectedKeys()pipeline.fireChannelRead()
QtQCoreApplication::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