Netty源码分析(五)--网络包如何进入应用程序

13 阅读7分钟

前言

我们在《网络是如何连接的》这个专栏中讲过:网络包是如何从浏览器一路传输到服务器的。
然而,当这些数据包抵达服务器后,它们是如何从内核协议栈最终送达Netty 应用程序的?这部分才是真正的“网络到应用”的关键路径。

前几篇文章我们分析了 Netty 服务端的启动流程和连接接入机制。
今天,我们继续往下看:当网络包到达服务器协议栈之后,Netty 是如何被内核通知并把数据读取上来的?


一、网络包从协议栈到Netty

当客户端发送数据包后,服务器的网卡(NIC)首先通过 DMA(直接内存访问)将数据写入内核缓冲区。然后进入如下步骤:

  1. 协议栈接收与重组
    内核 TCP/IP 协议栈会对收到的网络包进行校验(例如校验和、序列号、窗口大小),确保其合法且顺序正确。
    如果数据被拆分成多个包(分片),协议栈会将这些数据片段重新拼装成连续的字节流,放入 TCP 的接收缓冲区(sk_buff 链表)。

  2. 内核通知应用层(中断与事件驱动)
    当接收缓冲区中有新数据到达时,内核会通过以下机制之一通知应用程序有数据可读

    • 对传统阻塞 socket:应用程序在 read() 阻塞等待时,内核直接唤醒该进程。

    • 对非阻塞 socket(Netty 使用的):内核通过 事件通知机制(如 epoll)将该 socket 标记为“可读”,并把事件放入 epoll 的就绪队列。
      具体过程:

      1. 当 TCP 缓冲区收到新数据后,协议栈调用 sock_def_readable()
      2. 该函数会触发等待在此 socket 上的 等待队列(wait queue)
      3. 如果是 epoll 机制,内核会在 ep_poll_callback() 中将该 fd 加入 epoll 实例的“就绪链表”。

    换句话说:协议栈不会主动推数据到用户态,而是告诉应用层“这个 socket 上有数据可以读”

  3. Netty 的 Reactor 线程检测事件
    Netty 的 I/O 线程(NioEventLoop)在底层通过 epoll_wait()(Linux)或 select()(Windows)持续轮询内核的就绪队列。
    当内核把某个连接标记为“可读”后,epoll_wait() 会返回该事件,Netty 得知“该 Channel 有数据可以读取”。

  4. 从内核缓冲区读取数据
    Netty 收到事件后,调用底层的 SocketChannel.read()(即 recv() 系统调用)从内核态复制数据到用户态的 ByteBuf

    • 内核会从 TCP 接收缓冲区中将数据复制到用户空间内存(Netty 分配的直接内存或堆内存)。
    • 这一步是一次真正的用户态-内核态数据拷贝
    • 读完后,Netty 将 ByteBuf 封装成事件,通过 Pipeline 向上触发 channelRead() 等回调。

整个过程

整个“网络包从协议栈到 Netty 应用”的路径可以概括为:

客户端发包
   ↓
网卡接收 → DMA写内核缓冲
   ↓
内核协议栈(TCP/IP层)重组校验
   ↓
将完整数据放入 TCP 接收缓冲区
   ↓
协议栈触发 epoll 回调 → 标记 fd 可读
   ↓
Netty 的 epoll_wait() 被唤醒
   ↓
调用 recv() 从内核缓冲复制数据到 ByteBuf
   ↓
触发 pipeline → 业务逻辑(channelRead)

二、拆包器与解码器

前面我们提到过,TCP 协议并不关心网络包是否具有业务意义。在实际传输过程中,由于网络拥塞、操作系统调度、Nagle 算法等因素,可能会出现以下几种情况:

  • 多个独立的业务包被合并成一个 TCP 包;
  • 一个业务包被拆成多个 TCP 包(半包);
  • 即使一个 TCP 包恰好对应一个业务包,Netty 在读取数据时也可能因 ByteBuf 大小限制将其截断。

因此,应用层必须自行根据业务协议,将这些“原始流式字节数据”正确拆分成完整的业务包。这正是 拆包器(Frame Decoder) 的作用。


拆包器的原理

在没有 Netty 的情况下,若用户自己实现拆包逻辑,通常遵循以下原则:

  1. 数据不足则继续读取
    当当前读取的数据不足以构成完整的业务包时,暂存这部分数据,等待下一次从 TCP 缓冲区读取更多字节后再拼接。
  2. 数据足够则组包并透传
    当累计的数据已能拼接出一个完整的业务包时,将其组装成一个完整的数据帧,交由业务逻辑处理;若存在多余数据,则保留用于下次拼接。

Netty 的拆包机制本质上也遵循相同思路,只不过框架为我们封装了通用逻辑。
其核心基类是 ByteToMessageDecoder,该类内部维护一个累加器(Cumulator) ,用于合并多次从网络读取到的字节流,并尝试解析出完整的业务帧。


累加器实现(MERGE_CUMULATOR)

public static final Cumulator MERGE_CUMULATOR = new Cumulator() {
    @Override
    public ByteBuf cumulate(ByteBufAllocator alloc, ByteBuf cumulation, ByteBuf in) {
        ByteBuf buffer;
        if (cumulation.writerIndex() > cumulation.maxCapacity() - in.readableBytes()
                || cumulation.refCnt() > 1) {
            buffer = expandCumulation(alloc, cumulation, in.readableBytes());
        } else {
            buffer = cumulation;
        }
        buffer.writeBytes(in);
        in.release();
        return buffer;
    }
};

MERGE_CUMULATORByteToMessageDecoder 默认的累加器实现,用于将新读到的 ByteBuf in 合并到已有的累加区 ByteBuf buffer 中。
在合并之前,它会检查缓冲区容量是否足够,若不足则进行扩容。


三、核心方法:channelRead

public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
    if (msg instanceof ByteBuf) {
        CodecOutputList out = CodecOutputList.newInstance();
        try {
            ByteBuf data = (ByteBuf) msg;
            boolean first = cumulation == null;
            if (first) {
                cumulation = data;
            } else {
                cumulation = cumulator.cumulate(ctx.alloc(), cumulation, data);
            }
            callDecode(ctx, cumulation, out);
        } catch (DecoderException e) {
            throw e;
        } catch (Throwable t) {
            throw new DecoderException(t);
        } finally {
            if (cumulation != null && !cumulation.isReadable()) {
                numReads = 0;
                cumulation.release();
                cumulation = null;
            } else if (++numReads >= discardAfterReads) {
                numReads = 0;
                discardSomeReadBytes();
            }

            int size = out.size();
            decodeWasNull = !out.insertSinceRecycled();
            fireChannelRead(ctx, out, size);
            out.recycle();
        }
    } else {
        ctx.fireChannelRead(msg);
    }
}

channelRead()ByteToMessageDecoder 的核心逻辑。Netty 从协议栈取到字节数据后,会触发该方法。整体流程可以分为四个阶段:

1. 数据累加

cumulation = cumulator.cumulate(ctx.alloc(), cumulation, data);

每次从内核读取到新数据时,将其累加到 cumulation 中,形成一个完整的连续字节流。

2. 尝试拆包

callDecode(ctx, cumulation, out);

该方法内部会调用抽象方法 decode()

protected void callDecode(ChannelHandlerContext ctx, ByteBuf in, List<Object> out) {
    try {
        decode(ctx, in, out);
    }
}

decode() 由子类实现,用于根据协议规则将累加的字节流拆分成完整业务帧。
常见的实现有:

  • LengthFieldBasedFrameDecoder(基于长度字段拆包)
  • LineBasedFrameDecoder(基于换行符拆包)
  • DelimiterBasedFrameDecoder(基于分隔符拆包)

3. 缓冲区清理

若当前 ByteBuf 已无可读数据,则直接释放;
若连续多次(默认 16 次)读取后仍有未处理数据,则进行压缩(discardSomeReadBytes()),将有效数据段移至首部以节省空间。

4. 触发业务逻辑

当成功拆出业务包后:

fireChannelRead(ctx, out, size);

Netty 会将拆好的业务帧逐一触发到下游的 Handler 中继续处理。
期间 decodeWasNull 标记用于指示本次解码是否产生了新对象。


四、解码器(Decoder)

解码器的作用是将经过拆包器得到的完整字节帧,进一步反序列化为应用层能理解的对象
例如,在自定义通信协议中,包结构可能包含以下字段:

字段含义
Magic协议标识
Version协议版本
Command命令类型
Length数据长度
Body实际序列化数据

解码器会按约定读取这些字段,解析出业务对象并交由上层业务逻辑使用。
在 Netty 的 pipeline 中,通常是“拆包器 → 解码器 → 业务 Handler”这样的责任链顺序。


小结

本章我们完整梳理了网络包到达协议栈后,如何在应用层变成可识别业务对象的过程。
拆包器负责把连续的 TCP 字节流切分成完整的业务帧;
解码器则进一步将这些字节反序列化为应用层对象。

至此,Netty 的主流程已经基本闭环。
下一章,我们将深入分析 ChannelPipelinewriteAndFlush() 的内部机制,探索它们在事件传播与数据写出中的关键细节。