dcddc

西米大人的博客

0%

系统学习Netty

基础

TCP vs UDP

TCP:传输控制协议
UDP:用户数据协议

  • TCP 是面向连接的(在客户端和服务器之间传输数据之前要先建立连接),UDP 是无连接的(发送数据之前不需要先建立连接)
  • TCP 提供可靠的服务(通过 TCP 传输的数据。无差错,不丢失,不重复,且按序到达);UDP 提供面向事务的简单的不可靠的传输。
  • UDP 具有较好的实时性,工作效率比 TCP 高,适用于对高速传输和实时性比较高的通讯或广播通信。随着网速的提高,UDP 使用越来越多。
  • 每一条 TCP 连接只能是点到点的,UDP 支持一对一,一对多和多对多的交互通信。
  • TCP 对系统资源要求比较多,UDP 对系统资源要求比较少
  • UDP 程序结构更加简单
  • TCP 是流模式,UDP 是数据报模式

UDP 应用场景

  • 数据包总量比较少的通信,比如 DNS、SNMP
  • 视频、音频等对实时性要求比较高的多媒体通信
  • 广播通信、多播通信

TCP 如果保证高可靠传输?

  • 序列号:TCP 给发送的每一个包进行编号,接收方对数据包进行排序,把有序数据传送给应用层。
  • 校验和:TCP 将保存它首部和数据的检验和。这是一个端到端的检验和,目的是检测数据在传输过程中的任何变化。如果接收端的检验和有差错,TCP 将丢弃这个报文段和不确认收到此报文段。
  • 流量控制:控制发送速率。TCP 连接的每一方都有固定大小的缓冲空间(窗口)。通过可变大小的滑动窗口协议,发送方根据确认消息里接收方窗口的大小,控制发送速率,防止丢数据。
  • 拥塞控制:发送方还有一个拥塞窗口,窗口大小基于一定的策略(慢开始、快重传、快恢复)动态调整。目的是当网络拥塞时,减少数据的发送。
    • 发送方的发送窗口取的是拥塞窗口和接收方接收窗口的最小值。
  • 自动重传 ARQ 协议:每发完一个分组就启动一个定时器,并停止发送,等待对方确认。在收到确认后再发下一个分组。如果超时未收到对方确认,将重发这个报文段。这种方式信道利用率低,一般采取连续 ARQ 协议,即位于发送窗口内的分组可以连续发送而不用等确认,接收方对按序到达的最后一个分组发送确认。缺点是如果出现消息丢失,发送方需要回退 N 重发。

TCP 建联的三次握手机制:

  • 第一次握手:主机 A 发送位码 syn = 1,随机产生 seq number=1234567 的数据包到服务器,主机 B 由 syn=1 知道,A 要求建立联机;
  • 第二次握手:主机 B 收到请求后要确认联机信息,向 A 发送 ack number=(主机 A 的 seq+1),syn=1,ack=1,随机产生 seq=7654321 的包
  • 第三次握手:主机 A 收到后检查 ack number 是否正确,即第一次发送的 seq number+1,以及位码 ack 是否为 1。若正确,主机 A 会再发送 ack number=(主机 B 的 seq+1),ack=1。主机 B 收到后确认 seq 值与 ack=1 则连接建立成功

TCP 断连的四次挥手机制(假设 C 端发起断连):

  • C 端发送 FIN 报文
  • S 端发送 ACK 报文,然后把剩余数据发完
  • S 端发送 FIN 报文
  • C 端发送 ACK 报文,S 端收到 ACK 后,关闭连接。C 端超过一定时长后没收到消息(担心 C 端发送的 ACK 丢失),证明 S 端已经断开,则关闭连接

为什么建联三次握手,断连四次握手?
因为建联时,接收端可以 ACK 和 SYN 一起发送。断连时,被断开方收到 FIN 消息后,还可能有需要发送的数据,所以只能先 ACK,等发完数据后再发送 FIN。

什么是 TCP 拆包、粘包?
拆包:将应用层下发的一个大数据包拆成多个数据包
粘包:将应用层下发的多个小数据包合成一个数据包

TCP 为什么存在拆包、粘包?

  • TCP 基于字节流,对应用层下发的数据看成一连串无边界的字节流
  • TCP 的数据帧结构没有标识数据长度的字段
  • TCP 基于发送窗口大小来发送数据包,且发送窗口存在动态调整,所以存在拆包、粘包

如何解决拆包、粘包?
只能通过在应用层定制协议来实现。一般有三种方式:

  • 消息定长
  • 每条报文尾部增加特殊字符来标识
  • 应用层定制协议字段,消息头增加字段标识数据长度

Socket 套接字

在传输层上做的一层抽象编程接口,不同编程语言都有对应的 Socket 服务端和客户端的库。
Socket 在设计之初就希望也能适应其他网络协议,不局限于 TCP/IP 协议,但一般用到的Socket都是基于TCP/IP协议的实现,且 Socket 在传输层既可以基于 TCP 协议,也可以基于 UDP 协议做实现。

长连接 & 短链接

所谓长连接,指在一个 TCP 连接上可以连续发送多个数据包,在 TCP 连接保持期间,如果没有数据包发送,需要双方发检测包以维持此连接(心跳包)。基于 TCP 的 Socket 连接就是长连接(三次握手建联后,除非一方主动发起断连,否则连接通道不会断开)。数据库连接就用的长连接。
短连接是指通信双方有数据交互时,就建立一个 TCP 连接,数据发送完成后,则断开此 TCP 连接。比如一次 HTTP 连接,只是 TCP 建连、发送请求、接收响应、断连。
HTTP/1.1 已经默认开启长连接:把 Connection 头写进标准,除非请求中写明 Connection: close,否则浏览器和服务器之间是会维持一段时间的 TCP 连接,不会一个请求结束就断掉。

延伸:Http/1.1 除了默认开启长连接来减少 TCP 建连的开销,还有什么提效的方法?

答案在 Http2,其提供了 multiplexing 多路传输特性,可以在一个 TCP 链接建立后,并行完成多个 Http 请求,以提高页面加载效率。
Http/1.1 时代是采用上面说的默认开启长连接和同时建立多个 TCP 连接来提高页面加载效率

用户空间与内核空间

操作系统的核心是内核,独立于普通的应用程序,有访问底层硬件设备的所有权限。为了保证用户进程不能直接操作内核(kernel),保证内核的安全,操心系统将内存划分为两部分,一部分为内核空间,一部分为用户空间。对 32 位操作系统而言,虚拟存储空间为 2^32=4G。针对 linux 操作系统,将最高的 1G 字节(从虚拟地址 0xC0000000 到 0xFFFFFFFF),供内核使用,称为内核空间,而将较低的 3G 字节(从虚拟地址 0x00000000 到 0xBFFFFFFF),供各个进程使用,称为用户空间

文件描述符 fd

文件描述符(File descriptor)是计算机科学中的一个术语,是一个用于表述指向文件的引用的抽象化概念。当程序打开一个现有文件或者创建一个新文件时,内核向进程返回一个文件描述符(socket 也是一种文件描述符,因为需要 IO 操作)。在程序设计中,一些涉及底层的程序编写往往会围绕着文件描述符展开。但是文件描述符这一概念往往只适用于 UNIX、Linux 这样的操作系统。
网络IO里,fd可以简单理解为socket连接

缓存 I/O

缓存 I/O 又被称作标准 I/O,大多数文件系统的默认 I/O 操作都是缓存 I/O。在 Linux 的缓存 I/O 机制中,操作系统会将 I/O 的数据缓存在文件系统的页缓存( page cache )中,也就是说,数据会先被拷贝到操作系统内核的缓冲区中,然后才会从操作系统内核的缓冲区拷贝到应用程序的地址空间。

零拷贝

Java 调用 channel(文件 channel、网络连接 socketChannel 等)的数据读取 api 时,会经历以下步骤:

  • 用户态(java 内存空间)切换到内核态
    • 因为 java 无法直接操作系统资源,需要调用操作系统内核来完成
  • 等待数据到来
  • (磁盘、网卡上的)数据复制到内核缓冲区
  • 内核态切换回用户态
  • 内核缓冲区数据复制到用户空间(java 堆内存空间)

可以看到,一次数据读(写一样)经历了两次用户态内核态切换、两次数据复制,性能不高。针对这一问题,linux 内核做了优化,提供所谓直接内存给 java nio 的 ByteBuffer 使用,直接内存物理上指内核缓冲区,java 使用直接内存可以直接读写内核缓冲区上的数据,少了一次数据复制的性能损耗,且内核缓冲区的地址固定,不会受 java 垃圾回收导致堆数据地址偏移的影响,IO 读写性能更高。我们把这种使用直接内存解决读写数据时在内核缓冲区和 java 堆内存相互复制数据的技术,称为零拷贝。

在零拷贝技术的基础上,针对一种使用频率较高的复合操作:读取磁盘文件数据再写入网卡进行发送,Linux 内核进一步优化。完成该复合操作本来需要涉及到三次用户态到内核态的切换才能完成,但现在只需要由用户态切换一次到内核态,由内核将磁盘数据复制到内核缓冲区后,直接复制到网卡,性能进一步提升。目前 java nio 已支持对该类复合操作的性能优化。
https://www.bilibili.com/video/BV1py4y1E7oA?p=51

IO 模型

BIO

阻塞 IO(BIO)指用户线程在“等待数据和数据拷贝这两个阶段”都阻塞。
https://www.bilibili.com/video/BV1py4y1E7oA?p=48

BIO 模式下,线程执行所有网络 IO 的 api 都是阻塞的,例如接受 socket 连接、读取网络数据。因此服务端会为每个 Socket 连接分配一个独立的线程来负责该连接所有的 IO 读写操作,问题是高并发下内存占用率高、线程上下文切换成本高。即使使用线程池,因为线程大部分时间是阻塞在 IO 上的,利用率不高,只适合 http 短连接场景,通过请求执行后快速断开连接来提高线程利用率。早期的 tomcat 设计就是采取 BIO+线程池+http 短连接来做的。
https://www.bilibili.com/video/BV1py4y1E7oA?p=4

NIO

非阻塞 IO(NIO)指如果没有数据直接返回,否则阻塞在数据拷贝。
https://www.bilibili.com/video/BV1py4y1E7oA?p=48

BIO里用户最关心“我要读”,NIO里用户最关心"我可以读了",在AIO模型里用户更需要关注的是“读完了”

NIO 编程的三大组件:

  • Channel
  • Selector
  • Buffer

Channel

Channel 可以理解为 fd,是一个底层的数据传输双向通道模型,与传统 Stream 的区别,channel 是双向的,Stream 是单向的。在网络 IO 中使用 SocketChannel 表示 socket 连接。区别于 BIO,NIO 中的 channel 可以设置为非阻塞模式,没有 IO 事件到来时也不会阻塞线程,因此可以在单线程里使用 while 循环来轮训多个客户端 socket 连接,但这带来的问题就是大量的空转,浪费 CPU 资源。后面介绍的 Selector 可以解决这个问题

Buffer

Buffer 是数据的内存缓冲区。当数据到来时 Channel 将数据写到 Buffer,程序从 Buffer 读取数据。当程序要写(发送)数据时,将数据写到 Buffer,Channel 负责将 Buffer 里的数据发送出去。因此读写不会直接操作 channel,而是操作 buffer,由 buffer 再和 channel 进行数据互通
https://www.bilibili.com/video/BV1py4y1E7oA?p=2
https://www.bilibili.com/video/BV1py4y1E7oA?p=25

读取数据到 ByteBuffer
https://www.bilibili.com/video/BV1py4y1E7oA?p=6

Buffer 每次读写时需要先调 api 切换到对应模式,原因是读写操作都是操作的同一片内存空间,Buffer 使用三个属性来控制读写行为,分别是 position(当前读或写的起始位置)、limit(读取或写入的终止位置)、capacity(buffer 分配的内存空间大小)
https://www.bilibili.com/video/BV1py4y1E7oA?p=7

对于应用层下发的数据,在传输层会出于网络传输效率考虑,对数据包做拆分或合并,因此服务端从 Channel 的 Buffer 读取数据后,需要解决产生的半包或黏包问题,根据一些协议字段重新对数据做拆分或合并
https://www.bilibili.com/video/BV1py4y1E7oA?p=14

Selector

多路复用是一种可以同时阻塞监听多个通道上的 IO 事件的技术。注意虽然是阻塞监听,但本质还是利用了 NIO,所以每个通道都必须工作在非阻塞 IO。
当 IO 事件到来时返回有事件的通道和相应的 IO 事件,业务线程拿到通道后再执行阻塞式的数据拷贝。虽然多路复用和阻塞 IO 很类似,但因为可以同时监听多个通道,所以效率有很大提升。
https://www.bilibili.com/video/BV1py4y1E7oA?p=49

Selector 是一个实现了非阻塞 IO 的多路复用器,可以通过一个持有 Selector 的线程来监听多个注册在它上的 Channel 指定的 IO 事件,当没有 IO 事件时线程阻塞在 selector 的 select 方法,当 IO 事件到来时唤醒线程,处理有 IO 事件的 channel 连接。因此使用 Selector 来处理多个客户端连接有两个好处:

Netty 使用多线程来最大化利用 Selector 的多路复用能力,boss 线程的 selector 只负责监听服务端 socketChannel 的 accept 事件,worker 线程的 selector 只负责监听已经建立连接的客户端 socketChannel 的读写事件
https://www.bilibili.com/video/BV1py4y1E7oA?p=40

需要注意,worker 线程的 selector 没有 IO 事件时阻塞在 select 方法,此时 boss 线程拿到客户端 socketChannel 调用 worker 线程的 selector 注册时也会阻塞,产生死锁。解法是将注册操作封装为一个 runnable 任务添加到 worker 线程的任务队列,然后唤醒 worker 线程的 selector,worker 线程接着遍历任务队列执行添加的注册操作。
https://www.bilibili.com/video/BV1py4y1E7oA?p=44

Selector 将注册给它的 channel 映射为一个 SelectionKey,添加到集合 a 里,每次 channel 有 IO 事件到来,将 channel 对应的 selectionKey 添加到另一个集合 b 里交给业务处理。这里需要注意:

  • 集合 b 只进不出,所以每次业务处理完事件后需要手动从集合 b 里删除 selectionKey
  • 如果客户端连接强制断开时,服务端会收到一个读 IO 事件,但从 ByteBuffer 读数据会抛出 IO 异常,这时需要捕获 IO 异常,然后将客户端 channel 对应的 selectionKey 从集合 a 里删除
  • 如果客户端正常断开连接,服务端会收到一个读 IO 事件,通过读取数据长度为-1 判断是正常断开连接,然后将客户端 channel 对应的 selectionKey 从集合 a 里删除
  • 如果数据没读完,下次调用 select 还会返回该 selectionKey 的读事件
    https://www.bilibili.com/video/BV1py4y1E7oA?p=30
    https://www.bilibili.com/video/BV1py4y1E7oA?p=31

服务端接收到客户端 socketChannel 时,如果需要给客户端发送数据,可以给 socketChannel 的 selectionKey 添加一个监听可写事件。当发送数据量很大时,一次写操作把网络缓冲区沾满了,还有剩余没写完的数据在 ByteBuffer 里,selector 会在下次网络缓冲区有空间时监听到这个 selectionKey 的写事件,交给服务端继续执行剩余数据的写操作
https://www.bilibili.com/video/BV1py4y1E7oA?p=38

使用 selectionKey 的附件 attachment 机制解决半包、黏包问题。附件是 Channel 注册给 Selector 时指定的一个附加对象。可以将 ByteBuffer 作为附件,存储未处理完的数据。下次处理 IO 读事件时,可以通过附件取出上次的 ByteBuffer,然后将后面的数据继续写入 ByteBuffer
https://www.bilibili.com/video/BV1py4y1E7oA?p=35

多路复用 IO

NIO 的核心是选择器 Selector 的 select 方法,获取发生了 IO 事件的所有 channel,底层使用多路复用实现。

多路复用 IO 的实现方式有三种:select、poll 和 epoll 模式,可以理解为 epoll 是改进版,JDK1.6 之后使用的是 epoll 模式,极大提升了 NIO 的性能。

select 模式(两次拷贝+轮询+遍历=性能低):
用户线程里将要监控的文件描述符添加到 fd_set 数组中,用户空间需要维护这个数组,然后拷贝到内核进行监控。因为数组存储文件描述符,所以能监控的文件描述符数量受限,一般最大为 1024。
内核需要轮询fd_set 数组检测这些文件描述符是否发生了事件,当事件发生后,将 fd_set 数组同步拷贝回用户空间的数组
用户线程需要遍历用户空间的数组才知道哪个文件描述符发生了事件。

缺点:

  • 存在大量的内核和用户空间的内存拷贝,系统开销大
  • 内核轮询 fd 数组获取事件 & 用户线程需要遍历 fd 数组判断哪些 fd 有事件到达,效率低

poll 模式:
存储文件描述符的结构从数组改为链表,因此监听文件描述符的数量不受限,其他地方和 select 模式相同

epoll 模式把 select 调用分为了三部分:

  • 调用 epoll_create 建立一个 epoll 对象,这个对象包含了一个红黑树和一个双向链表。并与底层建立回调机制。
  • 调用 epoll_ctl 向 epoll 对象中添加所有 fd(网络 IO 中就是 socket 句柄)
  • 调用 epoll_wait 收集发生事件的 socket 连接。

性能上有哪些提升呢?

  • 用户空间拷贝全量 fds 到内核?No!
    • 内核里使用红黑树存储所有文件描述符,高效添加和删除文件描述符(网络 IO 里是 socket 连接),省去从用户空间往内核拷贝全量文件描述符的性能消耗
  • 轮询 fds 获取 fd 事件?No!
    • 回调而非轮询的方式获取事件(socket 数据到达)
  • 内核拷贝全量 fds 到用户空间?No!
    • 使用双向链表存储发生事件的 socket,只需要从内核拷贝发生事件的fd到用户空间

总结:
一颗红黑树,一张准备就绪句柄(fd)双向链表,少量的内核 cache(存储红黑树),就帮我们解决了大并发下的 socket 处理问题。执行 epoll_create 时,创建了红黑树和 fd 双向链表,执行 epoll_ctl 时,如果增加 socket 句柄,则检查在红黑树中是否存在,存在立即返回,不存在则添加到树干上,然后向内核注册回调函数,用于当中断事件来临时向 fd 双向链表中插入数据。执行 epoll_wait 时立刻返回链表里的数据即可。

AIO

异步 IO(AIO)指用户线程发起 IO 请求后可以继续执行,由另一个线程在数据复制完成后通过回调将数据传递给用户线程。因此异步 IO 一定是非阻塞的
https://www.bilibili.com/video/BV1py4y1E7oA?p=50

Netty

Netty 是一个基于 JavaNIO 的网络通信框架,屏蔽了底层 IO 通信的技术细节,封装了很多常用的网络通信协议,通过责任链模式保证了程序的扩展性。用户只需要专注于处理半包黏包问题、网络协议的编解码和业务逻辑的处理。

Netty 用到了这些技术:

  • Epoll 模式的多路复用技术
  • Reactor 设计模式
  • 主从多线程模型

通信流程

基于 Netty 编写 C/S 端,一次 C—>S 通信流程可以拆分为这么几个步骤:

  • 客户端向服务器端发起 tcp 连接建立请求
  • 服务器端 bossEventLoop 线程的 selector 监听到 accept 事件,调用 ChannelInitializer 的回调方法初始化客户端 SocketChannel,往 channel 的 pipeline 上添加自定义的 Handler
  • 将 socketChannel 注册给 workerEventLoop 线程的 selector,监听该连接上后续的读写事件
  • 客户端在连接建立后,调用 ChannelInitializer 的回调方法初始化该连接 socketChannel,往 channel 的 pipeline 上添加自定义的 Handler
  • 客户端使用该 socketChannel 向服务端发送数据
  • 服务端 workerEventLoop 线程的 selector 监听到 read 事件,调用 socketChannel 的 pipeline 上的 handler 处理请求,这会回调到业务之前添加的自定义 handler 执行业务逻辑

Netty 核心组件

Bootstrap

客户端 Netty 引导类,负责在客户端初始化和启动 netty 组件

ServerBootstrap

服务端 Netty 引导类,负责在服务端初始化和启动 netty 组件

NioXxxChannel

netty 里的 channel,对应 nio 里的 Channel,表示一个网络连接,包括 NioSocketChannel,NioServerSocketChannel

ChannelPipeline

每个 channel 都有自己的 pipeLine 责任链,它维护一个双向链表,channel 的 io 事件会根据事件类型(in\out)交给链表里匹配的 channelHandler 处理

ChannelHandler

channel 的 IO 事件处理器,作为双向链表的节点挂载在 channelPipeLine 上

NioEventLoop

NioEventLoop 内部维护一个工作线程和一个 Selector,可以监听和执行绑定在 Selector 上的 channel 的 IO 事件,也可以执行普通任务和定时任务

与 NioEventLoop 类似的是DefaultEventLoop,区别是它不能监听 IO 事件(没有 Selector),只能执行普通和定时任务。可以把一些 socketChannel 的 pipeline 上比较耗时的 handler 处理指定给 DefaultEventLoop 执行(通过给 handler 指定 DefaultEventLoopGroup,默认 handler 指定的是 NioEventLoopGroup),避免占用 NioEventLoop 的线程时间导致其他 socketChannel 的 IO 处理受影响。

  • pipeLine 的 handlers 在链式处理 IO 事件过程中,判断当前线程与下一个 handler 绑定的 eventLoop 所在线程,如果一致,则继续调下一个 handler 处理,如果不一致,则提交任务到下一个 handler 绑定的 eventLoop 所在线程,从而实现执行 handler 线程的切换。
  • 需要注意,其实每个请求的整体响应时间并没有缩短,反而因为线程切换的开销有所延长,但总比直接把线程打满导致后面的请求得不到响应要好很多!即,异步的作用是提升吞吐量而非提升性能!

NioEventLoop 逻辑上可以分为 BossNioEventLoop 和 WorkerNioEventLoop,前者只负责监听服务端 SocketChannel 的 accept 事件,后者只负责监听客户端 socketChannel 的读写事件。这么划分的目的是利用了异步多线程的优势,将建立连接和读写客户端连接分开在不同线程处理,避免性能相互影响,从而让 accept 的 nioEventLoop 线程尽快空闲下来执行新的建立连接事件,进而能提升系统的吞吐量(qps)

NioEventLoopGroup

管理一组 NioEventLoop 线程。提供一个自定义线程池 ThreadPerTaskExecutor,为每个 NioEventLoop 创建独立的线程。默认会创建 2 倍当前机器 CPU 核数的 EventLoop

它的作用主要是负载均衡,将 channel 均分给 Loop 线程来监听和执行 IO 事件

逻辑上 NioEventLoopGroup 又分为两类,BossNioEventLoopGroup 管理 BossNioEventLoop,WorkerNioEventLoopGroup 管理 WorkerNioEventLoop。服务端 BossNioEventLoopGroup 在接收到客户端 socket 连接后,会负载均衡地将客户端连接均分注册到 WorkerNioEventLoopGroup 内部的 WorkerNioEventLoop 的 Selector 上,后续该客户端连接上的 IO 事件都会在分配的 WorkerNioEventLoop 线程上处理

ChannelFuture

客户端 NioSocketChannel 调用 connect 请求建立连接或 close 断开连接后,返回一个异步凭证 ChannelFuture。可以调用它的 sync 方法让当前线程阻塞直到连接建立/断开,再从 ChannelFuture 拿到建连/断开后的 Channel。也可以给 ChannelFuture 添加一个 Listener 方法,在连接建立/断开后异步回调该方法,拿到建连/断开后的 channel。

  • 注意,执行建连/断开的线程实际为 NioEventLoop,所以执行 listener 方法也默认会在该线程执行,而非原发起调用 connect/close 的线程。
  • 异步:发起调用的线程和执行调用的线程是两个线程,发起调用的线程非阻塞。可以通过添加 listener 回调执行异步计算后的逻辑,也可以通过异步凭证 Future 同步阻塞获取结果,执行后续逻辑。

ByteBuf

Netty 的 ByteBuf 可以理解为 nio 里的 ByteBuffer,默认创建返回的是直接内存,读写性能更好(不过创建时不如使用 java 的堆内存快)

ByteBuf 相对于 ByteBuffer 做了这些优化:

  • 自动扩容
    • 当写入的字节数超过预设大小时会自动扩容
  • 池化,避免高并发下重复创建 ByteBuf
    • 默认创建直接内存的代价较高
  • 读写模式切换时不需要调 api
    • ByteBuf 将读写用两个指针表示,ByteBuffer 的读写是共用指针的,所以要调 api 来切换模式
  • 支持切片 slice,即对 ByteBuf 做切分,单独处理其中某一部分空间
    • 底层也是通过读写指针实现
    • 不需要将分片数据单独复制到另一个 ByteBuf 上,是“零拷贝”的思想

https://www.bilibili.com/video/BV1py4y1E7oA?p=81
https://www.bilibili.com/video/BV1py4y1E7oA?p=86

Netty 组件架构图

源码分析

服务端启动 netty

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
public class SimpleServer {
private int port;

public SimpleServer(int port) {
this.port = port;
}

public void run() throws Exception {
//EventLoopGroup是用来处理IO操作的多线程事件循环器
//bossGroup 用来接收进来的连接
EventLoopGroup bossGroup = new NioEventLoopGroup();
//workerGroup 用来处理已经被接收的连接
EventLoopGroup workerGroup = new NioEventLoopGroup();
try {
//启动 NIO 服务的辅助启动类
ServerBootstrap b = new ServerBootstrap();
b.group(bossGroup, workerGroup)
//配置 Channel
.channel(NioServerSocketChannel.class)
.childHandler(new ChannelInitializer<SocketChannel>() {
@Override
public void initChannel(SocketChannel ch) throws Exception {
// 注册handler
ch.pipeline().addLast(new SimpleServerHandler());
}
})
.option(ChannelOption.SO_BACKLOG, 128)
.childOption(ChannelOption.SO_KEEPALIVE, true);

// 绑定端口,开始接收进来的连接
ChannelFuture f = b.bind(port).sync();
// 等待服务器 socket 关闭 。
f.channel().closeFuture().sync();
} finally {
workerGroup.shutdownGracefully();
bossGroup.shutdownGracefully();
}
}

public static void main(String[] args) throws Exception {
new SimpleServer(9999).run();
}
}

服务端 Netty 程序启动入口:io.netty.bootstrap.AbstractBootstrap#bind(int)

总体分为 init->register->bind 三个阶段。

init 阶段构建 NioServerSocketChannel,在用户线程执行:

  • 创建 Netty 的 NioServerSocketChannel
    • 内部会创建 jdk 的 nio 包下的 ServerSocketChannel 作为成员变量
  • 将 ChannelInitializer(本质是一个 Handler,和其他 handler 的区别是它的 initChannel 方法只会执行一次)添加到 NioServerSocketChannel 的 pipeLine
  • 将注册任务提交到 NioEventLoop 执行,返回异步注册结果 Promise 对象给用户线程
  • 注册结果 Promise 对象添加回调方法(在用户线程执行注册后回调)

register 阶段将 NioServerSocketChannel 注册到 selector,在 NioEventLoop 的线程异步执行:

  • 把 NioServerSocketChannel 里 jdk 原生的 ServerSocketChannel 注册到 NioEventLoop 线程的 selector 上,且 NioServerSocketChannel 作为附件,目的是通过附件的 channel 拿到 pipeLine 处理 IO 事件
  • 执行上面的 ChannelInitializer 的 initChannel 方法,向 pipeLine 添加一个 accept 事件 Handler
  • 触发 register 事件让 pipeLine 上的 inHandler 执行
    • 因为我们无法给 NioServerSocketChannel 的 pipeLine 添加自己的 handler,所以也感知不到

bind 阶段完成端口绑定,在用户线程调用注册后回调时执行,它又会将任务提交到 NioServerSocketChannel 关联的 eventLoop 线程执行

  • 触发 bind 事件让 pipeLine 上的 outHandler 执行,最后由 headHandler 给 jdk 原生的 ServerSocketChannel 绑定端口号
  • 触发 active 事件让 pipeLine 上的 inHandler 执行,headHandler 在 readIfIsAutoRead 方法里会设置 NioServerSocketChannel 关注的 IO 事件为 Accept 事件
1
2
3
4
public void channelActive(ChannelHandlerContext ctx) throws Exception {
ctx.fireChannelActive();
this.readIfIsAutoRead();
}
1
2
3
4
5
6
7
8
9
10
11
protected void doBeginRead() throws Exception {
SelectionKey selectionKey = this.selectionKey;
if (selectionKey.isValid()) {
this.readPending = true;
int interestOps = selectionKey.interestOps();
if ((interestOps & this.readInterestOp) == 0) {
selectionKey.interestOps(interestOps | this.readInterestOp);
}

}
}

客户端启动 netty

客户端启动 demo:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
public class SimpleClient {

public void connect(String host, int port) throws Exception {
EventLoopGroup workerGroup = new NioEventLoopGroup();

try {
Bootstrap b = new Bootstrap();
b.group(workerGroup);
b.channel(NioSocketChannel.class);
b.option(ChannelOption.SO_KEEPALIVE, true);
b.handler(new ChannelInitializer<SocketChannel>() {
@Override
public void initChannel(SocketChannel ch) throws Exception {
ch.pipeline().addLast(new SimpleClientHandler());
}
});

// Start the client.
ChannelFuture f = b.connect(host, port).sync();

// Wait until the connection is closed.
f.channel().closeFuture().sync();
} finally {
workerGroup.shutdownGracefully();
}
}

public static void main(String[] args) throws Exception {
SimpleClient client=new SimpleClient();
client.connect("127.0.0.1", 9999);
}

}

客户端 Netty 程序启动入口:io.netty.bootstrap.Bootstrap#connect(java.lang.String, int)
总体分为 init->register->connect 三个阶段,和 Server 端存在一些区别:

  • 注册阶段向 Channel 的 pipeLine 加入自定义 handler 的区别

    • 服务端 Channel 不能加入用户定义的 handler,加入的是 Netty 自带的ServerBootstrapAcceptor,它的作用是在 channelRead 方法里专门处理 Accept 事件。用户定义的 handler 是在服务端接到客户端请求后,加到该客户端 channel 的 pipeLine 上
    • 客户端 Channel 能加入用户定义的 handler
  • 绑定阶段替换为连接阶段

    • bind 阶段变成 connect 阶段,也是提交任务在 nioEventLoop 线程执行,调用 channel 的 pipeLine 上的 outHandlers 处理 connect 事件,向服务度发起 tcp 连接
    • 连接完成(建立 tcp 连接成功)后,触发 active 事件交给 channel 的 pipeLine 上的 inHandlers 处理,headHandler 在 readIfIsAutoRead 方法里会设置 NioSocketChannel 关注的 IO 事件为 READ 事件

流程图

有些细节需要特别说明:

  • DefaultChannelPipeline 是一个双向链表,初始化后有 head 和 tail 节点(head 节点本身是 out 类型节点,是 in 类型 IO 事件的入口,tail 节点相反)
  • 如果想将自定义的 channelHandler 添加到 PipeLine,通过将 add 逻辑写到 ChannelInitializer 的 initChannel 方法来实现。该方法在 channel 注册到 selector 的流程中被回调执行,执行后 channelInitializer 会从 pipeLine 双向链表里 remove。因此 channelInitializer 的使命就是将自定义 channelHandler 添加到 pipeLine
  • NioEventLoopGroup 的默认 chooser 通过一个原子计数器,每次 choose 的时候计数器+1 后对 NioEventLoop 个数取模得到选取的下标,可以理解为顺序选取 NioEventLoop,每个 NioEventLoop 线程均匀承接 channel,达到负载均衡
  • nioEventLoopGroup 负责为 nioEventLoop 创建线程,而 loop 线程里主要做两件事
    • 轮询获取注册到 nioEventLoop 内部 selector 上的所有 channel 的 IO 事件,调用 channel 的责任链 pipeline 上 handlers 处理
    • 执行提交到 nioEventLoop 队列的所有任务(包括定时任务)
      • channel 注册流程就是作为任务提交到 nioEventLoop 的任务队列,在 loop 线程里执行

accept 流程

selector 监听到 NioServerSocketChannel 的客户端建连请求 accept 事件(从全连接队列取出客户端连接)后,在 bossNioEventLoop 线程上执行 accept 流程

  • 为客户端连接创建好 NioSocketChannel,封装 nio 原生的客户端 socketChannel
  • 调用 NioServerSocketChannel 的 pipeLine 上的 handlers 处理该 NioSocketChannel,核心在 acceptorHandler 上,它向 workerNioEventLoopGroup 上某个 NioEventLoop 提交一个异步注册任务
    • 把 NioSocketChannel 注册到该 NioEventLoop 的 selector 上
    • 回调 Server 端用户定义的 ChannelInitializer,把和客户端打交道的 handler 添加到 NioSocketChannel 的 pipeLine 上
    • 在 NioSocketChannel 的 pipeLine 上执行 Active 事件,设置 selector 关心该 NioSocketChannel 的 READ 事件

Netty 的通用设计

通过上述客户端、服务端启动流程和服务端 accept 流程的分析,不难发现一些 Netty 的通用设计。

  • 都是通过提交一个注册任务到 NioEventLoop,异步完成注册流程
    • 服务端 NioServerSocketChannel 注册、客户端 NioSocketChannel 注册、服务端 accept 客户端连接并注册 NioSocketChannel
  • 注册完成后会再提交一个异步任务到 NioEventLoop 执行,任务的核心是触发一个 IO 事件在 Channel 的 pipeLine 上传递处理
    • 服务端 NioServerSocketChannel 注册完成后触发 bind 事件来绑定端口,绑定成功后再触发 active 事件让 NioServerSocketChannel 关注 ACCEPT 事件
    • 客户端 NioSocketChannel 注册完成后触发 connect 事件来建立和服务端 tcp 连接,建立成功后再触发 active 事件让 NioSocketChannel 关注 READ 事件
    • accept 的客户端连接注册完成后触发 active 事件让 NioSocketChannel 关注 READ 事件

NioEventLoop

NioEventLoop 内部有三个核心成员变量:Selector、线程、任务队列。

Selector:
netty 针对 nioEventLoop 的 selector 做了性能优化。当 io 事件发生时,nio 原生的 selector 使用哈希表存储这些 selectionKeys,因此遍历这些代表 io 事件的 selectionKeys 的效率不高。netty 包装了 nio 原生的 selector,使用数组来存储 selectionKeys,因此遍历的性能有所提升。

线程:
nioEventLoop 内部维护了一个单线程,首次提交任务给 nioEventLoop 时启动线程。
该线程会死循环执行提交到 nioEventLoop 的任务、定时任务和注册到 selector 上的 channel 产生的 io 事件。逻辑为:

  • 判断任务队列是否有任务
    • 有任务,调用 selector.selectNow 获取 IO 事件,然后执行 IO 事件和任务队列里的任务
    • 没有任务,调用 selector.select 带超时参数的方法,阻塞一定时间等待 IO 事件
      • 当其他线程提交任务给 nioEventLoop 时,会唤醒 Selector 的阻塞方法 select,开始执行 io 事件和任务
      • 在 Linux 环境下,select 方法偶现不阻塞的情况,这会导致线程在死循环里一直运行,CPU 空转。这就是 netty 著名的空轮训bug。Netty 的解法是引入一个循环计数器,超过阈值(默认 512)就认为产生了空轮训 bug,然后重建一个 selector 替换之前的

可以通过 ioRatio 参数来控制执行 io 事件和任务的时间占比。例如设为 80,则执行任务的时间为 IO 事件时间的 1/4。不过如果设置为 100,并非只执行 IO 事件,而是 IO 事件执行完接着把任务队列里的任务也都执行完

黏包半包与帧解码器

黏包半包产生的本质原因是 tcp 协议为流式协议,消息无边界。具体原因有下面几种:

  • tcp 提供可靠传输,需要对端的 ack 才能继续发送下一条数据,为了提高通信效率以及控制流量,tcp 协议的发端使用“滑动窗口”的思想,提供一个发送缓冲区,缓冲区内的每条应用层数据可以不等对端的 ack 就直接发送,当接收到某条消息的 ack 后,窗口才可以继续向下滑动,发送后面的数据。因此,当网络拥塞时,就可能产生半包现象,后面的数据还没发送过来。当网络顺畅时,就可能产生黏包现象,多条应用层数据同时发过来。
  • tcp 和 ip 层的协议头各占 20 字节,如果应用层一次发送数据的字节过少,会因为协议字段的数据白白浪费带宽,因此对于这种情况也会把多条数据合并在一起发送,产生黏包现象
  • netty 的 ByteBuf 默认 1024 字节

Netty 通过提供一些通用的帧(一条完整的消息数据)解码器来解决黏包半包问题:

  • 提供一个定长帧解码器 handler,无论该 socketChannel 的 ByteBuf 上缓存了多少字节,他都按照设置的固定长度拆分后交给后面的 handler 处理,所以只要客户端和服务器端约定好每次发送数据的长度就不会出现黏包半包问题。为了满足所有场景,定长的字节数会比较大,对于一些字节数少的消息比较浪费空间
  • 提供一个指定字符串作为分隔符的帧解码器 handler,当匹配到该字符串即认为一条消息结束,将该消息截取后发送给下游 handler 处理。注意,需要给该解码器指定消息的最大长度,如果读取了最大长度都没匹配到分隔符,会抛出异常。该解法因为要匹配分隔符,所以效率不高
  • 提供一个基于解析长度字段的帧解码器 handler,创建该 handler 时通过构造参数指定长度字段从协议消息数据的几个字节偏移量开始占据几个字节,以及后面的消息内容从几个字节偏移量后开始读取

https://www.bilibili.com/video/BV1py4y1E7oA?p=97
https://www.bilibili.com/video/BV1py4y1E7oA?p=98

通信协议

所谓通信协议,即一套数据传输字节流的约定格式,哪些字节表达哪些特定含义。发送端按照协议将应用层数据转成 Byte 数组写入 ByteBuf,这一过程称为协议编码,接收端按照协议格式解析 ByteBuf 里收到的字节数组,转成业务对象,这一过程称为协议解码。
对于 netty 而言,实现基于协议的双端通信,需要提供协议编码器 handler 和解码器 handler。不过,netty 已经封装好了一些常用协议的编解码器(codec)handler,如 http、https、redis、websocket 等。如果需要自定义一套通信协议,需要提供该协议的编解码器(codec)handler
https://www.bilibili.com/video/BV1py4y1E7oA?p=99
https://www.bilibili.com/video/BV1py4y1E7oA?p=100

注意,消息接收端在协议解析前仍然需要添加帧解码器 handler(通常是基于解析长度字段的帧解码器 handler),否则仍然会出现半包黏包问题造成协议被错误解析
https://www.bilibili.com/video/BV1py4y1E7oA?p=104
https://www.bilibili.com/video/BV1py4y1E7oA?p=105

连接状态监测

Netty 提供了一个 Channel 的读写事件空闲检测器 handler。服务端可以使用该 handler 监测已建立连接的 socketChannel 是否读空闲,即超过一定时间没有读事件到来。客户端可以使用该 handler 监测自身有多久没有给服务器写(发送)数据了。当发生读空闲、写空闲时,Netty 会发送相应的事件交给 pipeLine 处理。这时会用到 Netty 提供的另一个 ChannelDuplexHandler,处理空闲读、空闲写事件。服务端处理读空闲事件可以简单地断开连接,客户端处理写空闲事件可以发送心跳包。采用这种策略需要注意,读空闲判定的时长阈值一定要大于写空闲判定的时长阈值,否则客户端还没发心跳包,就会被服务端强制断开连接
https://www.bilibili.com/video/BV1py4y1E7oA?p=117

可配置参数

Netty 提供了一些服务端和客户端的配置参数,可由用户在构建 Netty 程序时动态指定参数值

  • 客户端最大连接超时时间
    • 只作用于客户端,Netty 底层会创建一个延时任务,延时时长就是这里指定的超时时间。如果客户端和服务器在超时时间内连接建立完成就会取消该延时任务,否则执行延时任务,给连接建立 Future(Netty 的连接建立是异步的)设置异常,原线程获取建连的 Future 结果会抛出异常
    • https://www.bilibili.com/video/BV1py4y1E7oA?p=123
  • 全连接队列大小
    • 客户端和服务器建连要经过 TCP 三次握手,第一次服务端收到客户端发的 SYN 消息后,会将客户端连接放入半连接队列。第二次服务端收到客户端的 ACK 消息后,会将客户端连接放入全连接队列。服务端执行 ACCEPT 事件会从全连接队列取走客户端连接
    • Netty 提供配置参数来设置全连接队列大小。注意,Linux 系统在配置文件里也指定了全连接队列的大小,这时会取二者的最小值
    • https://www.bilibili.com/video/BV1py4y1E7oA?p=125

Netty 解决 NIO 空轮询 bug

所谓 NIO 空轮询 bug,指调用 Selector.select 轮询方法等待 channel 事件到来,但这个阻塞方法有时在没有 channel 事件到来时也可能直接返回,即出现了空轮询,然后再次回到 while 循环不断空轮询,最终导致 CPU 利用率飙到 100%,程序崩溃。

Netty 在 io.netty.channel.nio.NioEventLoop#select 方法里解决了空轮询 bug,具体做法是:

  • 检测到 while 死循环里空轮询次数达到阈值(默认 512)时,重建 nioEventLoop 的 selector,然后将注册到老 selector 的 channel 重新注册到新 selector

Netty 线程模型

reactor 设计模式

Reactor 模式是处理并发 I/O 比较常见的一种模式,中心思想是将所有要处理的 I/O 事件注册到一个中心 I/O 多路复用器上,主线程阻塞监听注册在多路复用器上的 I/O 事件;一旦有 I/O 事件到来或是准备就绪,多路复用器返回并将相应 I/O 事件分发到对应的处理器中处理。

优点:

  • 多路复用技术节省了服务端线程数,避免了多线程切换带来的性能问题
  • 可水平扩展。灵活增加多路复用器来处理高并发请求

基于 Reactor 模式,Netty 线程模型分为三种:

  • 单线程模型:所有 IO 事件(ACCEPT、读写事件)都在一个线程里监听和处理,适合并发极低的场景
  • 多线程模型:ACCEPT 事件单线程,和客户端的读写事件多线程。对于登录、认证耗时的场景,也存在性能问题。
  • 主从多线程模型:ACCEPT 事件在主线程池里分配线程处理,和客户端的读写事件在从线程池里分配线程处理。Netty推荐使用这种线程模型

主从多线程模型的优势:

  • 多线程方式解决了单点性能问题
  • 主从线程池解决了 ACCEPT 事件中可能出现的一些耗时操作(登录、认证)带来的性能问题
  • 每个 SocketChannel 都在唯一一个 NioEventLoop 线程里执行 IO,确保了这些操作都是线程安全的

在 Netty 组件中,NioEventLoopGroup 扮演了线程池角色,主从线程模型中分别有一个主 NioEventLoopGroup,用线程池并发处理多个 NioServerSocketChannel(多个服务端的 ip:port)上的 ACCEPT 事件。从 NioEventLoopGroup 负责多线程并发处理所有 Server 接收到的客户端 NioSocketChannel 的读写事件。NioEventLoop 扮演执行线程角色,监听注册在它内部的多路复用器 Selector 上的所有 channel 的 IO 事件并调用 channel 的 handlers 链式处理。

基于 Netty 实现聊天工具/RPC 框架

基于 TCP/IP 的网络编程应用必须考虑上面提到的黏包半包问题和通信协议,因此如果基于 Netty 实现,必备的就是帧解码器 handler 和协议编解码器 codecHandler,以及一些业务 handler。

服务端 Handler(顺序从 head 到 tail,即读事件 handler 执行顺序):

  • 基于解析长度字段的帧解码器 handler
    • 处理黏包半包
  • 协议编解码器 codecHandler
    • 返回解码后的业务对象或者将业务对象编码后写入 ByteBuf
  • Inbound 类型的业务 handler
    • 【聊天工具】用户登录验证
    • 【聊天工具】处理发送聊天消息请求
    • 【rpc 框架】处理 rpcRequest,反射调用接口,将结果写到 rpcResponse 并 write 回 socketChannel

客户端 Handler(顺序从 head 到 tail,即读事件 handler 执行顺序):

  • 基于解析长度字段的帧解码器 handler
    • 处理黏包半包
  • 协议编解码器 codecHandler
    • 返回解码后的业务对象或者将业务对象编码后写入 ByteBuf
  • Inbound 类型的业务 handler
    • 【聊天工具】读取连接建立事件,发送登录请求
    • 【聊天工具】读取服务端登录响应,发送消息
    • 【rpc 框架】读取 rpcResponse

对于聊天工具,客户端只需要维护当前聊天者自己的 socketChannel,持有它来发送消息,而从服务端接收到的消息一定是发给自己的,这可以由服务端保证。
服务端维护每个登陆用户和 socketChannel 的映射关系,在处理发送聊天消息请求时读取到接收人,调用他的 socketChannel 发送消息数据到客户端。

对于 RPC 框架,客户端(调用方)需要将rpc服务使用动态代理进行包装,用户线程在发起 rpc 调用时走到动态代理的增强逻辑,先封装 rpcRequest,然后拿到与 rpc 服务端建立好的 socketChannel(如果请求的是同一个 rpc 服务端,socketChannel 可以复用)发送 rpcRequest。

注意,发送 rpcRequest 是在 nioEventLoop 线程执行的,而接收 rpcResponse 也是在 nioEventLoop 线程执行的,所以用户线程在发起 rpc 调用到接收到响应是异步的(不同线程间执行操作)。那么用户线程如何拿到 rpcResponse,即异步线程间如何通信呢?

Netty 使用 Promise 来实现异步线程间结果通信(可以理解为 jdk 里的 Future)

  • 用户线程使用 socketChannel 发起 rpc 网络请求
  • 需要创建用于接收 rpcResponse 的 Promise 对象,为 Promise 指定执行线程
  • 将 rpcRequest 里的 sequenceId 和 Promise 对象通过全局 Map 做映射
  • 调用 Promise 对象的 await 阻塞等待异步返回的 rpcResponse

这样,客户端收到 rpcResponse 后:

  • 通过 rpcResponse 里的 sequenceId 拿到 Promise
  • 将结果 set 给 Promise
    • 在 Promise 指定的线程里执行
  • 用户线程结束阻塞,拿到结果作为动态代理的结果返回给调用方,完成一次 rpc 请求。
    https://www.bilibili.com/video/BV1py4y1E7oA?p=136

参考资料

https://blog.csdn.net/cj2580/java/article/details/78087101
https://blog.csdn.net/zgege/java/article/details/81632990
https://zhuanlan.zhihu.com/p/23488863
https://wenjie.store/archives/netty-nioeventloop-boot-2
https://blog.csdn.net/eric_sunah/article/details/80437025?utm_medium=distribute.pc_relevant.none-task-blog-baidujs-1