03 IO 模型与高并发

本篇定位:从五种 IO 模型的定义出发,穿过 select/poll/epoll 的内核实现,落地到 Java 的 BIO/NIO/AIO、Reactor 模式与 Netty 线程模型,最后给出工业采集场景特有的并发模型——为什么"每设备一线程"在车间里往往合理、为什么一条 PLC 连接上必须串行化、事务 ID 流水线化的可能与风险,以及一份可直接改造使用的采集调度器骨架代码。TCP 传输层细节(窗口、重传、Nagle)见 01-TCP协议深入.md,报文边界与拆帧见 02-粘包与拆包.md。

本篇目录


1. 五种 IO 模型:两阶段阻塞对比

一次 recv() 在内核里其实是两个阶段:阶段一等待数据(报文到达并拷入内核缓冲区),阶段二拷贝数据(内核缓冲区 → 用户空间)。POSIX 定义:只要阶段二阻塞了进程就是同步 IO;两阶段都不阻塞才是异步 IO。

  应用进程                     内核
     |  recv()                   |  ← 阶段一:等待数据准备好
     |-------------------------> |    ...报文到达,DMA 拷入内核缓冲...
     |  <----------------------  |  ← 阶段二:内核缓冲 → 用户空间拷贝
模型 阶段一(等待数据) 阶段二(拷贝) 归类 Java 对应
阻塞 IO 阻塞在 recv() 阻塞 同步 Socket + InputStream.read()
非阻塞 IO 立即返回,需轮询 阻塞 同步 SocketChannel.configureBlocking(false)
IO 多路复用 阻塞在 select/epoll_wait(等多个 fd) 阻塞 同步 Selector
信号驱动 IO 不阻塞,等 SIGIO 阻塞 同步 Java 无标准支持
异步 IO 不阻塞 不阻塞(内核拷完再通知) 异步 AsynchronousSocketChannel
  阻塞IO     ====阻塞 recv=====================> 拷贝完成返回
                [等待数据.....][拷贝]
  非阻塞IO   recv=EWOULDBLOCK  recv==> 拷贝完成
                [轮询CPU空转]  [拷贝]
  IO多路复用 ====阻塞 select(多fd)=========> 就绪 ==> recv 拷贝
                [等任一fd就绪...]          [拷贝]
  信号驱动   (继续执行)...收到SIGIO...====> recv 拷贝
  异步IO     (继续执行)........收到"已完成"通知(内核完成等待+拷贝)
  ←──────────────────── 时间轴 ────────────────────→

关键结论:IO 多路复用是同步 IO,它高效的原因不是"不阻塞",而是"一个线程能同时等很多个 fd"。


2. Linux 底层:select / poll / epoll

维度 select poll epoll
数据结构 fd_set 位图 pollfd 数组 红黑树 + 就绪链表
数量限制 1024(FD_SETSIZE) 无硬限制 无硬限制(受 ulimit -n)
每次调用 全量 fd_set 拷入内核 全量数组拷入内核 只 epoll_ctl 注册一次
就绪判定 O(n) 遍历全部 O(n) 遍历全部 O(1) 返回就绪链表
返回后 用户态再 O(n) 找就绪 fd 同样 O(n) 只处理就绪的
触发模式 LT LT LT(默认)/ ET
  select/poll 的 O(n) 问题:连接 n=10000 但每次只有 10 个活跃 → 每次调用仍要
  拷贝 10000 个 fd、内核遍历 10000 次、返回后用户态再遍历 10000 次,开销与 n 成正比。

  epoll 的做法:epoll_create() 建 eventpoll 对象
    ├─ rbr     :红黑树,存所有被监视的 fd(epoll_ctl ADD/DEL/MOD,O(log n))
    └─ rdllist :双向链表,存已就绪的 fd(epoll_wait 直接返回,O(就绪数))
    唤醒路径   :网卡中断 → 协议栈 → 回调把就绪 fd 挂进 rdllist
  用户态                              内核 eventpoll
   epoll_ctl(ADD/MOD/DEL) ───────> ┌─────────┐  ┌──────────────┐
                                   │ 红黑树   │  │ 就绪链表      │
   epoll_wait() <────────────────  │(所有fd) │  │ rdllist(就绪) │
    返回就绪事件数组                └─────────┘  └──────────────┘
                                       ^              ^
                                    epoll_ctl    设备中断回调挂入

2.1 LT 与 ET

模式 含义 使用要求 特点
LT(水平触发,默认) 只要缓冲区还有数据,每次 epoll_wait 都通知 可以一次只读一部分 安全简单,可能重复唤醒
ET(边缘触发,EPOLLET) 只在状态变化时通知一次 必须配非阻塞 fd,且循环读到 EAGAIN 系统调用少,但漏读一次就再也不通知
// ET 模式下的正确读法(NIO 层面对应结构)
while (true) {
    int n = channel.read(buf);
    if (n > 0) { handle(buf); buf.clear(); continue; }
    if (n == 0) break;          // 非阻塞且无数据 → 本次已读干净
    if (n < 0) { close(); break; }
}
// ⚠ ET 下只读一次就返回 → 剩余数据留在缓冲区且不再被通知

2.2 EPOLLONESHOT

EPOLLONESHOT 让 fd 在一次事件被处理后自动从监视中移除,必须显式 epoll_ctl(MOD) 重新武装。价值是防止多线程同时处理同一个 fd:

  无 ONESHOT:线程A 拿到 fd=7 开始读,线程B 同时也拿到 fd=7 → 两线程交错读
              → 数据错序、协议解析崩溃
  有 ONESHOT:fd=7 只投递给一个线程一次,处理完才重新注册 → 天然串行化

Netty 的 NioEventLoop 本身保证一个 Channel 只由一个线程处理(效果等价);原生 epoll 传输下会用到 oneshot(需实测确认版本行为)。补充:EPOLLEXCLUSIVE(Linux 4.5+)用于缓解多 epoll 实例等待同一 fd 的惊群。


3. Java BIO:阻塞模型与完整多线程服务端

阻塞点分布:accept() 等新连接、read() 等数据、write() 等发送缓冲腾出空间、connect() 等握手 → "等待"永远占据一个线程,线程数 ≈ 并发连接数。

package com.example.iot.bio;

import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.ServerSocket;
import java.net.Socket;
import java.net.SocketTimeoutException;
import java.util.concurrent.*;

/** 多线程 BIO 服务端:一个连接一个线程,读取后回显。 */
public final class BioEchoServer {

    private final int port;
    private final ThreadPoolExecutor pool;
    private volatile ServerSocket serverSocket;

    public BioEchoServer(int port, int maxThreads) {
        this.port = port;
        // 有界队列 + CallerRunsPolicy:连接突增时反压而不是堆积到 OOM
        this.pool = new ThreadPoolExecutor(8, maxThreads, 60L, TimeUnit.SECONDS,
                new ArrayBlockingQueue<>(256),
                r -> { Thread t = new Thread(r); t.setDaemon(true); return t; },
                new ThreadPoolExecutor.CallerRunsPolicy());
    }

    public void start() throws IOException {
        serverSocket = new ServerSocket(port, 512);          // backlog 显式指定
        System.out.println("BIO server listen on " + port);
        while (!serverSocket.isClosed()) {
            Socket conn;
            try {
                conn = serverSocket.accept();                 // 阻塞:等新连接
            } catch (IOException e) {
                if (serverSocket.isClosed()) break;
                System.err.println("accept 失败: " + e.getMessage());
                continue;
            }
            pool.execute(() -> handle(conn));                 // 每连接一线程
        }
    }

    private void handle(Socket conn) {
        try (conn) {
            conn.setTcpNoDelay(true);                         // 关 Nagle(见 01 篇)
            conn.setSoTimeout(10_000);                        // 读超时,防线程永久卡死
            InputStream in = conn.getInputStream();
            OutputStream out = conn.getOutputStream();
            byte[] buf = new byte[4096];
            while (true) {
                int n;
                try { n = in.read(buf); }                     // 阻塞:等数据
                catch (SocketTimeoutException e) { continue; } // 空闲超时,继续等
                if (n < 0) break;                             // 对端关闭
                out.write(buf, 0, n);
                out.flush();
            }
        } catch (IOException e) {
            System.err.println("连接处理异常: " + e.getMessage());
        }
    }

    public void stop() throws IOException {
        if (serverSocket != null) serverSocket.close();
        pool.shutdown();
    }

    public static void main(String[] args) throws Exception {
        BioEchoServer server = new BioEchoServer(9000, 200);
        Runtime.getRuntime().addShutdownHook(new Thread(() -> {
            try { server.stop(); } catch (IOException ignored) { } }));
        server.start();
    }
}

优点:代码直观,与"发→收→解析"的协议交互天然契合;超时/重试/断线重连逻辑最好写;异常栈清晰,便于与抓包对照;与"一条连接必须串行"的 PLC 语义天然匹配。 缺点:线程数随连接数线性增长(每线程默认栈 1MB,可用 -Xss 调小);连接多而活跃少时上下文切换浪费;不适合 C10K 级公网服务。

3.1 为什么工业采集场景下"每设备一线程"可以接受

关键在于量级和连接利用率与互联网服务完全不同:

  互联网服务:10万+ 连接,频繁创建销毁,长尾客户端 → 必须 NIO/Netty
  工业采集  :几十 ~ 几百台设备(单采集器通常 < 500);连接长期保持;
              每连接利用率极低(周期 500ms、单次交互 2ms → 占用率 0.4%)

  算账:200 台设备 × 1 线程 = 200 线程 → 内存 200 × 512KB(-Xss512k)= 100MB
        99.6% 时间在阻塞,CPU 开销可忽略;代价远小于"手写 NIO 状态机 + 拆包
        + 重连"带来的 bug 成本

判断阈值(需实测确认):单采集器设备数 < 1000、且不需要同时接收大量设备主动上报连接时,BIO/每设备一线程是最优工程选择;超过该量级或要做"设备主动上报"的服务端时,上 Netty。


4. Java NIO:Buffer / Channel / Selector

组件 作用 关键概念
Buffer 数据容器 position/limit/capacity;flip() 切读、clear() 切写、compact() 保留未读部分
Channel 双向通道,可非阻塞 SocketChannel / ServerSocketChannel / FileChannel
Selector 单线程监视多 Channel OP_ACCEPT / OP_CONNECT / OP_READ / OP_WRITE,select(timeout)

Buffer 状态机(最容易写错):

  写模式 → flip() → 读模式 → clear()/compact() → 写模式

  capacity=8,写入 3 字节:position=3, limit=8
  flip() 后:position=0, limit=3     ← limit 变成"有效数据长度"
  compact():把未读部分拷到开头 → position=1, limit=8

  忘 flip() → 读到未初始化数据;忘 clear() → 缓冲区被写满

4.1 完整 NIO Echo Server

package com.example.iot.nio;

import java.io.IOException;
import java.net.InetSocketAddress;
import java.net.StandardSocketOptions;
import java.nio.ByteBuffer;
import java.nio.channels.*;
import java.nio.charset.StandardCharsets;
import java.util.Iterator;
import java.util.Set;

/** 单 Reactor 单线程 NIO Echo Server:一个 Selector 管所有连接。 */
public final class NioEchoServer {
    public static void main(String[] args) throws IOException {
        Selector selector = Selector.open();
        ServerSocketChannel ssc = ServerSocketChannel.open();
        ssc.bind(new InetSocketAddress(9001));
        ssc.configureBlocking(false);
        ssc.register(selector, SelectionKey.OP_ACCEPT);
        ByteBuffer buf = ByteBuffer.allocate(4096);
        while (true) {
            if (selector.select(1000) == 0) continue;         // 阻塞等事件,最多 1s
            Set<SelectionKey> keys = selector.selectedKeys();
            Iterator<SelectionKey> it = keys.iterator();
            while (it.hasNext()) {
                SelectionKey key = it.next();
                it.remove();                                  // 必须移除,否则重复处理
                try {
                    SocketChannel ch;
                    if (key.isAcceptable()) {
                        ch = ((ServerSocketChannel) key.channel()).accept();
                        ch.configureBlocking(false);
                        ch.setOption(StandardSocketOptions.TCP_NODELAY, true);
                        ch.register(selector, SelectionKey.OP_READ);
                    } else if (key.isReadable()) {
                        ch = (SocketChannel) key.channel();
                        buf.clear();
                        int n = ch.read(buf);                 // 一次读到的字节数不确定
                        if (n < 0) { key.cancel(); ch.close(); continue; }
                        if (n == 0) continue;
                        buf.flip();
                        byte[] data = new byte[buf.remaining()];
                        buf.get(data);
                        String echo = "echo:" + new String(data, StandardCharsets.UTF_8);
                        key.attach(ByteBuffer.wrap(echo.getBytes(StandardCharsets.UTF_8)));
                        key.interestOps(SelectionKey.OP_WRITE);          // 有数据要写才注册
                    } else if (key.isWritable()) {
                        ch = (SocketChannel) key.channel();
                        ByteBuffer out = (ByteBuffer) key.attachment();
                        ch.write(out);                                   // 可能未写完
                        if (!out.hasRemaining()) {                       // 写完立刻取消关注
                            key.attach(null);
                            key.interestOps(SelectionKey.OP_READ);
                        }
                    }
                } catch (IOException e) {
                    key.cancel();
                    try { key.channel().close(); } catch (IOException ignored) { }
                }
            }
        }
    }
}

4.2 两个必知陷阱

陷阱一:OP_WRITE 不要常驻注册。 写缓冲区几乎总是"可写",常驻会让 select() 立刻返回、CPU 空转 100%。正确做法:有数据要写时注册,写完立刻取消。

陷阱二:Selector 空轮询 bug(JDK-6670302)。 某些情况下 select() 不阻塞地立即返回 0,while 循环空转导致 CPU 100%。对策:统计连续空 select 次数,超过阈值(Netty 用 512)就重建 Selector 并重新注册 Channel;或直接用 Netty(其 NioEventLoop 内置了 rebuild 逻辑)。官方称后续 JDK 已修复,但生产仍建议保留重建逻辑(需实测确认)。

4.3 为什么手写 NIO 很痛苦

半包/粘包(read() 字节数不确定,必须自己做累积缓冲与拆帧,详见 02-粘包与拆包.md)、写半包(write() 不保证写完,要维护待写队列并动态注册 OP_WRITE)、Buffer 状态(flip/clear/compact 用错一个就出诡异数据)、空轮询(需自己重建 Selector)、连接管理(超时/重连/心跳/优雅关闭全自己写)、线程模型(单 Selector 是瓶颈,多 Selector 要自己分组与负载均衡)、内存(每连接缓冲区与直接内存的分配回收)。

结论:学 NIO 是为了理解原理与读懂 Netty;生产代码不要手写 NIO。


5. Java AIO:为什么 Linux 上不如 NIO

AsynchronousSocketChannel ch = AsynchronousSocketChannel.open();
ByteBuffer buf = ByteBuffer.allocate(256);
ch.connect(new InetSocketAddress(host, 502), buf, new CompletionHandler<Void, ByteBuffer>() {
    @Override public void completed(Void r, ByteBuffer b) {          // 连接完成 → 发起读
        ch.read(b, b, new CompletionHandler<Integer, ByteBuffer>() {
            @Override public void completed(Integer len, ByteBuffer bb) { bb.flip(); /* 处理再读 */ }
            @Override public void failed(Throwable exc, ByteBuffer bb) { /* 失败处理 */ }
        });
    }
    @Override public void failed(Throwable exc, ByteBuffer b) { /* 失败处理 */ }
});
  1) Linux 原生 AIO(io_submit/io_getevents)历史上主要面向磁盘 IO,对 socket
     支持长期不完善(直到 io_uring 才真正改善)
  2) 因此 JDK 在 Linux 上的 AIO 是"线程池 + epoll 模拟"的:内部线程做 select,
     再把完成事件投递给 CompletionHandler → 比直接用 NIO 多一层线程与队列
  3) Windows 上 AIO 走 IOCP,是真异步 —— "AIO 更好"的说法多来自 Windows
     经验,搬到 Linux 就反了;回调式 API 处理"请求-响应配对 + 拆包 + 超时
     重传"时可读性也差(回调地狱)

6. Reactor 模式:三种形态

  ① 单 Reactor 单线程
    +----------------------------------------------+
    | 单线程:select → accept → read → 业务 → write  |
    +----------------------------------------------+
    优点:无锁、无上下文切换;缺点:业务一阻塞全卡死,用不了多核;典型:Redis

  ② 单 Reactor 多线程
     ┌───────────┐   业务    ┌──────────────┐
     │Reactor线程 │ ───────> │ Worker 线程池 │
     │select/IO  │          │   业务处理    │
     └───────────┘          └──────────────┘
    优点:业务不阻塞 IO 线程;缺点:Reactor 线程仍是单点

  ③ 主从 Reactor 多线程(Netty 采用)
     客户端 ─> ┌───────────┐  注册  ┌─────────────┬─────────────┐
              │MainReactor│ ─────> │SubReactor-1 │SubReactor-N │
              │  (Boss)   │        │  (Worker)   │  (Worker)   │
              └───────────┘        └──────┬──────┴──────┬──────┘
                                          v             v
                                   ┌────────────────────────────┐
                                   │ 业务线程池(可选,隔离耗时操作)│
                                   └────────────────────────────┘

线程数与适用性:单 Reactor 单线程(1 线程,接收设备上报可用但业务不能阻塞);单 Reactor 多线程(1+N,够用);主从 Reactor 多线程(1+N+M,Netty 默认形态,最通用)。

Doug Lea《Scalable IO in Java》的分层是理解 Netty 的最佳地图:

  自顶向下:业务处理层 → 事件分发层(Dispatcher) → 事件循环层(Reactor)
          → 多路复用层(select/epoll 封装) → 连接管理层(Acceptor)

  设计原则:IO 事件与业务处理解耦(线程池隔离);一个连接的所有事件尽量在
           同一线程内处理(无锁串行化);多 Reactor 分摊 accept 与 IO 压力

7. Netty 线程模型与 ByteBuf

7.1 EventLoopGroup / Boss / Worker

EventLoopGroup boss   = new NioEventLoopGroup(1);      // 只负责 accept
EventLoopGroup worker = new NioEventLoopGroup();       // 默认 2 × CPU 核数
try {
    ServerBootstrap b = new ServerBootstrap();
    b.group(boss, worker)
     .channel(NioServerSocketChannel.class)
     .option(ChannelOption.SO_BACKLOG, 1024)                // 全连接队列
     .childOption(ChannelOption.TCP_NODELAY, true)            // 关 Nagle!
     .childOption(ChannelOption.SO_KEEPALIVE, true)
     .childOption(ChannelOption.SO_RCVBUF, 64 * 1024)
     .childHandler(new ChannelInitializer<SocketChannel>() {
         @Override protected void initChannel(SocketChannel ch) {
             ch.pipeline().addLast(new ReadTimeoutHandler(5, TimeUnit.SECONDS));
             ch.pipeline().addLast(new ModbusFrameDecoder());      // 拆帧(见 02 篇)
             ch.pipeline().addLast(new ModbusRequestHandler());    // 业务
         }
     });
    b.bind(502).sync().channel().closeFuture().sync();
} finally { boss.shutdownGracefully(); worker.shutdownGracefully(); }
  Boss NioEventLoopGroup(1)
    └─ NioEventLoop-1 ── selector ── ServerSocketChannel(OP_ACCEPT)
  Worker NioEventLoopGroup(N)                (每个 loop 一条线程)
    ├─ NioEventLoop-1 ── selector ── Channel A, Channel D ...
    ├─ NioEventLoop-2 ── selector ── Channel B, Channel E ...
    └─ NioEventLoop-N ── selector ── Channel C, Channel F ...

  ⚠ 常见错误:在 ChannelHandler 里做阻塞操作(查库、同步 HTTP、Thread.sleep)
    → 会卡住该 EventLoop 上绑定的所有 Channel
    → 耗时业务必须丢到独立业务线程池:pipeline.addLast(业务线程池, handler)

串行无锁化:一个 Channel 一旦注册到某个 EventLoop 就终身绑定同一线程,其所有 inbound/outbound 事件、定时任务、用户提交的任务都串行执行 → handler 里无需加锁,也没有可见性问题。

7.2 ChannelPipeline

  入站(Inbound) ────────────────────────────────────────────>
  head ─> [ssl] ─> [frameDecoder] ─> [idleState] ─> [business] ─> tail
     <───────────────────────────────────────────────────────
  <────────────────── 出站(Outbound) ───────────────────

  · inbound(channelRead/channelActive/exceptionCaught)从 head 向 tail 传播
  · outbound(write/flush/connect/close)从 tail 向 head 传播
  · 双向链表,节点是 ChannelHandlerContext,可 addLast/addFirst/addBefore
  · ctx.write(msg) 从"当前节点"向前找最近 OutboundHandler(跳过不必要 handler);
    channel.write(msg) 从 tail 开始,走完全部 outbound handler

  典型采集客户端 Pipeline:
    入站 ByteBuf 字节流 → LengthFieldBasedFrameDecoder(完整帧)
       → ModbusResponseDecoder(帧 → Response 对象) → 业务 Handler
    出站 Request 对象 → ModbusRequestEncoder(→ ByteBuf) → 网卡
    ByteToMessageDecoder 帮你累积半包,正是 02 篇"长度前缀拆帧"的标准落地

7.3 ByteBuf

维度 说明
堆内存 JVM 堆上,分配快;做 IO 时 JVM 会临时拷贝到堆外(多一次拷贝)
直接内存(DirectByteBuf) 堆外,IO 无需额外拷贝;分配/回收成本高 → 必须池化
CompositeByteBuf 逻辑合并多个 ByteBuf,避免内存复制(Netty 语境的"零拷贝"之一)
池化 PooledByteBufAllocator(Netty 默认):jemalloc 风格 Arena→Chunk→Page→Subpage
引用计数 refCnt:retain() 加、release() 减,归零回收
  与 ByteBuffer 的核心差别:ByteBuffer 单个 position,读写靠 flip() 切换;
  ByteBuf 用 readerIndex/writerIndex 双指针、读写互不干扰,支持动态扩容
  (受 maxCapacity 限制)、引用计数与池化。

  引用计数规则(工业服务最容易泄漏内存的地方):① 谁最后使用谁 release,Pipeline
  中最后一个 InboundHandler 必须释放;② 继承 SimpleChannelInboundHandler<T>
  → Netty 自动 release;③ 交给业务线程池异步处理时必须在业务线程 release
  (注意跨线程配对);④ 泄漏检测 -Dio.netty.leakDetection.level=PARANOID
  (默认 SIMPLE),生产别长期开 PARANOID,性能损耗大

8. 零拷贝:sendfile / mmap / transferTo

8.1 传统 read + write

  ① read()  :DMA 把磁盘数据拷到内核页缓存          [DMA 拷贝]
  ②         :CPU 把内核页缓存拷到用户缓冲区        [CPU 拷贝]
  ③ write() :CPU 把用户缓冲区拷到 socket 发送缓冲  [CPU 拷贝]
  ④         :DMA 把 socket 缓冲拷到网卡            [DMA 拷贝]

  合计 4 次拷贝(2 CPU + 2 DMA)+ 4 次上下文切换(read/write 各进出内核态 2 次)

   用户空间 ┌──────────┐
            │用户缓冲区 │ ←② CPU→ ③ CPU
   内核空间 │页缓存     │ ←① DMA      │socket缓冲│ →④ DMA→ 网卡
            └──────────┘             └─────────┘

8.2 三种优化路径

方式 拷贝次数 上下文切换 说明
mmap + write 3(2 DMA + 1 CPU) 4 用户空间与内核共享页缓存,省掉"内核→用户"的 CPU 拷贝
sendfile 3(2 DMA + 1 CPU) 2 数据不进用户空间,系统调用减半
sendfile + SG-DMA(Linux 2.4+) 2(纯 DMA) 2 支持 scatter-gather 的网卡直接从页缓存 gather,零 CPU 拷贝
// 1) transferTo:底层用 sendfile,适合"文件 → 网络"的零拷贝
try (FileChannel file = FileChannel.open(Paths.get("history.dat"), StandardOpenOption.READ);
     SocketChannel sock = SocketChannel.open(new InetSocketAddress(host, port))) {
    long pos = 0, size = file.size();
    while (pos < size) {
        long n = file.transferTo(pos, size - pos, sock);   // 返回实际传输字节数
        if (n <= 0) break;                                 // 必须循环,可能未传完
        pos += n;
    }
}
// 2) MappedByteBuffer:mmap,适合大文件随机读
try (FileChannel file = FileChannel.open(p, StandardOpenOption.READ)) {
    MappedByteBuffer map = file.map(FileChannel.MapMode.READ_ONLY, 0, file.size());
    byte b = map.get(1024);                                // 像访问数组一样访问文件
}
// ⚠ MappedByteBuffer 释放依赖 GC(JDK 9+ 可用 Unsafe.invokeCleaner,JDK 14+ 有
//   MemorySession/Arena 等新 API,需实测确认);频繁映射小文件会造成虚拟内存与
//   句柄压力 → 不要每个请求 map 一次

Netty 语境下的"零拷贝"是另一层含义,别混淆:OS 层靠 FileRegion(底层 transferTo);用户层靠 CompositeByteBuf、ByteBuf.slice()、Unpooled.wrappedBuffer();以及池化 ByteBuf 减少分配与复制。


9. 线程池设计:队列、线程数、拒绝策略、虚拟线程

9.1 为什么不能用无界队列

ExecutorService bad = Executors.newFixedThreadPool(16);   // 内部是无界 LinkedBlockingQueue
  故障链(现场真实会发生):交换机故障 → 200 台 PLC 全部超时 → 失败任务进入
  重试队列 → 无界队列持续膨胀(每个任务还持有请求 ByteBuf 与上下文)
  → 内存上涨 → Full GC → OOM;更糟的是队列里的任务即使已无人关心也会执行,
  网络恢复瞬间几万任务一起涌出,把刚恢复的 PLC 再次打死

  正确做法:① 有界队列 ArrayBlockingQueue(容量 = 线程数 × 单线程允许积压量)
  ② 队列满时执行拒绝策略或让提交方阻塞(反压)③ 任务入队记 enqueueTime,
  出队检查是否过期 → 直接丢弃 ④ 按设备维度限流:单设备积压超 N 个就丢最旧的

9.2 线程数估算

  CPU 密集:N_threads ≈ N_cpu(或 N_cpu + 1)
  IO 密集(通用公式):N_threads ≈ N_cpu × (1 + 等待时间/计算时间)
  IO 密集(工程经验):N_threads ≈ N_cpu × 2 ~ N_cpu × 4,再压测调整

  但工业采集是"设备数驱动"而非"CPU 驱动":
    上限由"可并行的设备数"决定。例:200 台设备 → 并行度 32~64(跨设备并行),
    单设备内部串行 → 总线程数可控
    判据:队列常非空 + CPU 没满 → 加大并行度;
         CPU 满 + 队列空 → 瓶颈在计算(解析/序列化/落库),别再加线程

9.3 拒绝策略

策略 行为 工业采集评价
AbortPolicy(默认) 抛 RejectedExecutionException 可用,但要确保上层捕获并计入失败指标
CallerRunsPolicy 提交线程自己执行 推荐:天然反压,提交方被拖慢就不会猛塞任务
DiscardOldestPolicy 丢队首提交新的 可用(新数据比旧数据有价值),但必须计数
DiscardPolicy 静默丢弃 不推荐:无声丢数据,排障无从下手
自定义 打点 + 降级(如只保心跳) 推荐

9.4 虚拟线程(Loom,JDK 21 正式,JEP 444)

  机制:由 JVM 调度,挂载在少量 carrier 线程上;阻塞时自动卸载(yield),不占
  carrier → 创建百万个也不吃内存。价值:可以用"同步阻塞的 BIO 写法"获得接近
  NIO 的并发能力,Executors.newVirtualThreadPerTaskExecutor() 一行就够;
  "每设备一线程"从"勉强可接受"变成"几乎免费",且同步代码 + 超时/重试逻辑的
  可读性远好于 Netty 回调链。

  限制与坑:① synchronized 代码块/方法会 pin(钉住)carrier 线程 → 阻塞时不
  卸载,退化成平台线程 → 改用 ReentrantLock(JDK 24 JEP 491 改进了 pinning,
  需实测确认);② 不适合 CPU 密集任务(只提供并发不提供并行);③ ThreadLocal
  滥用会放大对象数量(可用 ScopedValue 替代,JDK 21 为预览);④ 监控指标含义
  变了(没有"活跃线程数"概念);⑤ 不要拿虚拟线程池去跑 Netty 的 EventLoop

10. 工业采集的真实并发模型

10.1 并发需求怎么算

  真实并发需求 ≠ 设备数,而是"单位时间内的请求数":
    请求数/秒 = Σ( 该设备测点数 / 单次请求能读的测点数 ) / 采集周期(秒)

  例:200 台设备 × 50 测点,周期 1s
    · 一个测点一次请求(最笨):200 × 50 = 10000 req/s ← 做不到也没必要
    · Modbus 单次读保持寄存器最多 125 个(0x7D)→ 50 测点 1 次读完 → 200 req/s
      (连续地址还能再合并);S7 一次 PDU 可读几百字节(取决于协商出的 PDU
      size,常见 240/480/960,需实测确认)→ 同理批量

  结论:第一步是"批量合并读",把请求数降一个数量级,而不是先优化 IO 模型。
       批量合并是数量级收益,IO 模型优化通常只有百分比收益。

10.2 连接池 vs 每设备一连接

PLC 的连接数是硬约束,这是与互联网服务最大的差别:

PLC / 协议 连接数上限(典型值,需实测确认)
S7-1200 8 个 S7 连接(含 PG/HMI/其它客户端)
S7-1500 依 CPU 型号差异大,常见十几个到几十个
S7-300/400 依 CPU 型号,常见 4~16
Modbus TCP 从站 常见 1~8(仪表/网关更严格,很多只支持 1~4)
MC 协议(三菱) 依模块与端口,常见 1~几路
  硬约束带来的设计结论:
  1) 连接是稀缺资源 → 必须全局记账:
     已用 = 采集器数 × 每采集器连接数 + HMI + 上位机 + 调试 PG
     超限 → PLC 直接拒绝新连接(connect 失败或握手后立刻 RST)
  2) "每设备一连接"优先于"连接池":PLC 不像数据库,建连代价不只是 TCP 握手,
     还有 S7 的 COTP 连接协商与参数交换(协商 PDU size 等),代价远高 →
     连接应长期持有、断线按退避重连,而不是用完归还。连接池只在"同设备多种
     访问模式"时才有意义(如读写分离、优先级分离)
  3) 多采集器访问同一 PLC:明确分配连接预算(采集器 3、HMI 1、预留 4);
     采集器要能感知"连接被拒"并降级(降低频率而不是疯狂重连)
  4) 断线重连必须带退避 + 抖动,否则整车间同时重连 = 连接风暴

10.3 一个连接上的 IO 必须串行化

  错误做法(多线程并发写同一 socket):
    线程A: write(读DB1) ─┐
    线程B: write(读DB2) ─┴→ 线路上字节交错 |A前半|B|A后半| → PLC 解析出乱码

  即使写入是原子的,还有响应错配问题:A 发"读温度"、B 发"读压力",回来两个
  响应无法区分归属 → 数据错位。⚠ 这是最危险的一类 bug:数据看起来正常但值是错的

  正确做法:一条连接 = 一个串行队列,同一时刻只有一个请求在飞(见 10.5)

10.4 用事务 ID 做流水线的可能与风险

协议 事务标识 位置 宽度
Modbus TCP MBAP 事务标识符 MBAP 前 2 字节 16 bit(0~65535)
S7comm(ISO-on-TCP) PDU reference S7 PDU 头 16 bit
MC 协议(3E 帧) 序列号等(依帧格式) 帧头 依帧格式(需实测确认)

响应带回相同标识 → 可以并发多个在飞请求(流水线),吞吐可提升数倍,尤其在高 RTT 链路:

  串行(RTT=2ms):|--req1-->|--resp1--|--req2-->|--resp2--|  吞吐 = 1/RTT = 500 req/s
  流水线(窗口3) :|--req1-->|--req2-->|--req3-->|          吞吐 ≈ 3/RTT = 1500 req/s
                   |<--resp1-|<--resp2-|<--resp3-|

但工业场景风险很大,必须逐条确认:

风险 说明
从站不支持 很多 PLC/网关只支持串行处理;收到第二个未完成请求可能丢弃、返回异常或关闭连接
窗口上限 即便支持也有 pending 上限(S7 侧通常很小,需实测确认),超限报错
响应错序 部分设备乱序返回 → 必须靠事务 ID 匹配,不能"先到先得"
事务 ID 回绕 16 bit 空间在高吞吐下会回绕;窗口小则无妨
调试困难 出问题后抓包里多个请求交织,排障成本陡增
写操作 绝对不要对写操作做流水线,写乱序或丢失会导致设备误动作

实践建议:默认串行;只有 ① 手册明确说明支持、② 实测验证过、③ 窗口限制 2~4、④ 只对读操作开放、⑤ 可用开关一键关闭,才启用流水线。

10.5 采集调度器骨架代码

设计要点:跨设备并行、单设备串行、优先级排队、任务超时、故障标记。线程数不随设备数增长(设备 500、并行度 32 也只需 32 个线程)。

package com.example.iot.sched;

import java.util.Comparator;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicLong;

/** 采集调度器:跨设备并行、单设备串行、优先级排队、任务超时。 */
public final class AcquisitionScheduler implements AutoCloseable {

    /** 在一条设备连接上执行的一次交互(读/写)。实现类内部必须保证线程安全。 */
    @FunctionalInterface
    public interface Command<T> {
        T execute(DeviceConnection conn) throws Exception;
    }

    /** 设备连接占位:真实实现持有 Socket/SocketChannel + 编解码器 + 重连逻辑。 */
    public static final class DeviceConnection {
        public final String deviceId;
        volatile boolean healthy = true;           // 置 false 后由重连线程处理
        DeviceConnection(String deviceId) { this.deviceId = deviceId; }
    }

    private static final class Task {
        final String deviceId;
        final int priority;                        // 数字越小越优先
        final long timeoutMs;
        final Command<?> command;
        final long enqueueTime = System.currentTimeMillis();
        final CompletableFuture<Object> future = new CompletableFuture<>();
        Task(String deviceId, int priority, long timeoutMs, Command<?> command) {
            this.deviceId = deviceId; this.priority = priority;
            this.timeoutMs = timeoutMs; this.command = command;
        }
    }

    private static final class DeviceQueue {
        final PriorityBlockingQueue<Task> queue = new PriorityBlockingQueue<>(256,
                Comparator.comparingInt((Task t) -> t.priority)
                          .thenComparingLong(t -> t.enqueueTime));
        final AtomicBoolean draining = new AtomicBoolean(false);   // 是否有任务在跑
    }

    private final ThreadPoolExecutor pool;
    private final ConcurrentMap<String, DeviceQueue> queues = new ConcurrentHashMap<>();
    private final ConcurrentMap<String, DeviceConnection> conns = new ConcurrentHashMap<>();
    private final AtomicLong rejected = new AtomicLong();

    public AcquisitionScheduler(int parallelism) {
        BlockingQueue<Runnable> workQueue = new ArrayBlockingQueue<>(parallelism * 64);
        AtomicLong seq = new AtomicLong();
        this.pool = new ThreadPoolExecutor(parallelism, parallelism, 0L, TimeUnit.MILLISECONDS,
                workQueue,
                r -> { Thread t = new Thread(r, "acq-" + seq.incrementAndGet());
                       t.setDaemon(true); return t; },
                new ThreadPoolExecutor.CallerRunsPolicy());   // 池满则由提交方跑 → 反压
    }

    /** 提交一个采集任务;返回的 Future 可按需阻塞或编排。 */
    public <T> CompletableFuture<T> submit(String deviceId, int priority,
                                           long timeoutMs, Command<T> command) {
        Task task = new Task(deviceId, priority, timeoutMs, command);
        DeviceQueue dq = queues.computeIfAbsent(deviceId, k -> new DeviceQueue());
        if (!dq.queue.offer(task)) {                              // 单设备积压上限
            rejected.incrementAndGet();
            task.future.completeExceptionally(
                    new RejectedExecutionException("设备队列已满: " + deviceId));
        } else { pump(dq); }
        @SuppressWarnings("unchecked")
        CompletableFuture<T> f = (CompletableFuture<T>) (CompletableFuture<?>) task.future;
        return f;
    }

    /** CAS 保证同一时刻只有一个线程在消费该设备的队列 → 单设备串行 */
    private void pump(DeviceQueue dq) {
        if (dq.draining.compareAndSet(false, true)) {
            try { pool.execute(() -> drain(dq)); }
            catch (RejectedExecutionException e) {
                dq.draining.set(false);                           // 池满:留给下次提交再泵
                rejected.incrementAndGet();
            }
        }
    }

    private void drain(DeviceQueue dq) {
        try {
            Task t;
            while ((t = dq.queue.poll()) != null) {
                if (System.currentTimeMillis() - t.enqueueTime > t.timeoutMs) {
                    t.future.completeExceptionally(new TimeoutException("排队超时: " + t.deviceId));
                    continue;                                     // 过期任务直接丢弃
                }
                DeviceConnection conn = conns.computeIfAbsent(t.deviceId, DeviceConnection::new);
                try {   // 单次读超时靠 Socket 的 SO_TIMEOUT / Netty ReadTimeoutHandler
                    t.future.complete(t.command.execute(conn));
                } catch (Exception e) {
                    conn.healthy = false;                         // 触发重连
                    t.future.completeExceptionally(e);
                }
            }
        } finally {
            dq.draining.set(false);
            if (!dq.queue.isEmpty()) pump(dq);                    // 期间又来任务 → 再消费一轮
        }
    }

    public int pendingTasks(String deviceId) {
        DeviceQueue dq = queues.get(deviceId);
        return dq == null ? 0 : dq.queue.size();
    }
    public DeviceConnection connection(String deviceId) { return conns.get(deviceId); }
    public long rejectedCount() { return rejected.get(); }

    @Override public void close() { pool.shutdown(); }

    // ---------------- 演示:8 台设备 × 8 个任务 ----------------
    public static void main(String[] args) throws Exception {
        try (AcquisitionScheduler s = new AcquisitionScheduler(8)) {
            java.util.List<CompletableFuture<String>> fs = new java.util.ArrayList<>();
            for (int i = 0; i < 64; i++) {
                String device = "plc-" + (i % 8);
                int idx = i;
                fs.add(s.submit(device, idx % 3, 5_000L,
                        conn -> "device=" + conn.deviceId + " value=" + idx));
            }
            int ok = 0, fail = 0;
            for (CompletableFuture<String> f : fs) {   // 真实场景:编排或异步回调
                try { f.get(10, TimeUnit.SECONDS); ok++; } catch (Exception e) { fail++; }
            }
            System.out.println("成功=" + ok + " 失败=" + fail + " 拒绝=" + s.rejectedCount());
        }
    }
}

代码之外同样重要的配套设计:

  1. 定时触发:用 ScheduledExecutorService 按周期 submit 任务,而不是在采集线程里 sleep——周期抖动需要被测量而不是被制造。
  2. 批量合并:一个设备一个周期只提交合并后的少量请求(见 10.1)。
  3. 优先级:心跳/控制指令 priority=0,周期采集 priority=5,历史补采 priority=9;写操作建议单独队列且不允许批量合并。
  4. 三层超时:排队超时(调度器 timeoutMs)、单次读超时(SO_TIMEOUT / ReadTimeoutHandler)、整帧截止时间(见 01 篇 15.1)。
  5. 失败处理:连续失败 N 次 → 标记离线 → 关闭连接(不复用)→ 退避重连;重连成功前该设备任务应快速失败而不是一直排队。
  6. 指标:每设备的 成功数/失败数/排队长度/最近 RTT/采集周期实际值,这是现场排障的唯一依据。

11. 性能测试方法

指标 定义 健康参考(车间内网,需实测确认)
单请求 RTT 发请求到收全响应,记录 P50/P95/P99 P99 < 3 × P50
单连接 TPS 一条串行连接每秒完成请求数 ≈ 1000 / RTT(ms)
采集周期抖动 实际触发间隔 − 目标周期 抖动 < 周期的 10%
成功率 / 重连次数 / 队列积压 成功÷总请求;单位时间重建次数;pendingTasks > 99.9% / 应为 0 / 应接近 0
  单连接串行吞吐的天花板(先算清,别定不切实际的目标):
    RTT=2ms → 500 req/s ;RTT=20ms → 50 req/s ;RTT=50ms → 20 req/s
  超天花板只有三条路:批量合并请求 / 多连接(受 PLC 连接上限)/ 流水线(有风险)
# 1) 先用 iperf3 确认网络本身没病
iperf3 -s                                   # 服务端
iperf3 -c 192.168.1.10 -t 60 -i 1           # TCP 带宽(应接近线速)
iperf3 -c 192.168.1.10 -u -b 100M -t 60     # UDP:看 jitter 与丢包率(应接近 0 / <1ms)
# 若 UDP 丢包 > 0 或 jitter 大 → 先修网络/换网线/查交换机端口,别优化代码

# 2) JVM 侧观测
jcmd <pid> VM.native_memory summary         # 直接内存(Netty 堆外)
jstat -gcutil <pid> 1000                    # GC 是否频繁
jstack <pid> | grep -c "acq-"               # 采集线程数

压测的三个注意事项:

  1. 闭环 vs 开环:"发一个等一个"(闭环)会掩盖问题——请求速率随系统变慢而下降;真实采集是开环(固定周期触发),压测也要按固定速率发,否则低估积压与长尾(coordinated omission)。
  2. 先用模拟器排除设备瓶颈:用 Modbus/S7 从站仿真工具测采集器自身极限,再接真实 PLC,否则分不清瓶颈在采集器还是 PLC 的通信处理器。
  3. 别在采集器本机压测(压测客户端会抢 CPU 与网络);延迟用 HdrHistogram / Prometheus histogram 记录,不要用平均值——平均值会被大量快请求掩盖长尾,而长尾正是采集周期抖动的来源。

小结

常见误区

误区 正解
"IO 多路复用是异步 IO" 它是同步 IO:等待阶段阻塞在 select,拷贝阶段仍阻塞
"epoll 一定比 select 快" 连接数少且几乎全活跃时(如 < 100),select/poll 的常数开销反而更小
"ET 性能一定更好" ET 减少系统调用,但要求一次读干净;用不好会丢事件,LT 更安全
"NIO 一定比 BIO 快" 单连接吞吐由 RTT 决定,与 IO 模型无关;NIO 的优势在海量连接下的资源占用
"用了 Netty 就不会阻塞了" 在 ChannelHandler 里做阻塞操作会卡住该 EventLoop 上的所有 Channel
"ByteBuf 不用管释放,GC 会回收" 池化的直接内存靠引用计数回收,不 release() 就泄漏
"虚拟线程能提升 CPU 密集任务性能" 虚拟线程只提供并发,不提供并行;CPU 密集仍受核数限制
"线程池队列用无界更省心" 批量掉线时会 OOM,并在恢复瞬间压垮 PLC
"设备多就得多开线程" 用"每设备串行队列 + 全局有界线程池",线程数可与设备数解耦
"协议支持事务 ID 就能放心并发发请求" 支持标识 ≠ 从站支持并发处理;实测前默认串行,写操作永远串行
"调大连接池能提高采集速度" PLC 连接数是硬上限(S7-1200 共 8 个),乱加会挤掉 HMI/PG 的连接
"零拷贝就是完全没有拷贝" OS 层零拷贝指"没有 CPU 拷贝",DMA 拷贝仍在;Netty 语境指"减少用户态内存复制"
"压测看平均值就够了" 平均值掩盖长尾,采集周期抖动正来自长尾,必须用 P95/P99 与直方图
"iperf 跑满带宽就说明网络没问题" 还要看 UDP 的 jitter 与丢包率;间歇性丢包对采集的杀伤远大于带宽不足
"MappedByteBuffer 用完不用管" 释放依赖 GC,频繁映射小文件会造成虚拟内存与句柄压力

本文是《工业通信与数据采集》栏目第 6/22 篇 · 上一篇:粘包与拆包 | 栏目总览 | 下一篇:Modbus 协议详解