前言
本文主要从 select 和 epoll 系统调用入手,来打开 Netty 的大门,从认识 Netty 的基础原理 —— I/O 多路复用模型开始。如下是微信公号系列文章,持续更新
。。。。
Netty 的通信原理
Netty 底层的通信机制是基于I/O多路复用模型构建的,简单一句话概括就是多路网络连接可以复用一个I/O线程,在 Java 层面也就是封装了其 NIO API,但是 JDK 底层基于 Linux 的 epoll 机制实现(其实是三个函数)。注意在老旧的 Linux 上,可能还是 select,没考证过,但是时下主流版本,肯定早就是 epoll 机制了,不妨就认为 JDK NIO 底层是基于 epoll 模型。
想象这样一个场景:老师站在讲台上提问,下面100个学生把答案写在纸上,谁写完谁举手示意,让老师来检查,完成的好就可以放学回家。如果学生张三举手,李四也举手,就表示他们已经完成了,老师就立即依次去检查张三和李四的答案,检查完毕,老师就可以返回讲台休息或者溜达等等,接着王五,赵四儿又举手,然后老师马上去检查他们的答案。。。以此往复。
如上这种生活现象就是 I/O 多路复用模型,Linux下的 select、poll,和epoll 就是实现的这种机制,这样就避免了大量的无用操作,比如,老师不需要依次的等待一个学生写完了,然后检查一个学生,检查完毕,再去等待下一个学生。。。(对应多客户端单线程模型),也不需要请100个老师,每个老师对应1个学生(一客户端一线程的 BIO 模型),而是让所有学生先自己闷头写答案,写完才主动举手示意,老师在去检查答案,处理完毕,老师就可以走了,继续等待其它学生举手,全程一个老师就能处理(epoll 函数),这就是所谓的非阻塞模式。另外,老师也不需要顺序的询问每个学生的问题完成情况(select 函数)只需要看谁举手。。。这样老师不烦躁,学生也能专心答题。
类比到通信,整个I/O过程只在调用 select、poll、epoll 这些调用的时候才会阻塞,收发客户消息是不会阻塞的,整个进程或者线程就被充分利用起来,从而使得系统在单线程(进程)的情况下,可以同时处理多个客户端请求,这就是I/O 多路复用模型。与传统的多线程(单线程)模型相比,I/O多路复用的最大优势就是系统开销小,系统不需要创建新的额外线程,也不需要维护这些线程的运行、切换、同步问题,降低了系统的开发和维护的工作量,节省了时间和系统资源。
主要的应用场景,服务器需要同时处理多个处于监听状态或多个连接状态的套接字,服务器需要同时处理多种网络协议的套接字。
支持I/O多路复用的系统调用主要有select、pselect、poll、epoll。而当前推荐使用的是epoll,优势如下:
- 支持一个进程打开的socket fd(file description)不受限制
- I/O效率不会随着fd数目的增加而线性下将
- 使用mmap加速内核与用户空间的消息传递。
- epoll拥有更加简单的API。
而常见的一种 I/O 多路复用模型有所谓的 reactor 模式,Netty 就实现了多线程的 reactor 模型(reactor 模型有三种,单线程,多线程和主从),即当有感兴趣的事件(event)发生,就通知对应的事件处理器(ChannelHandler)去处理这个事件,如果没有就不处理。故用一个线程(NioEventLoop)做轮询就可以了。如果要获得更高性能,可以使用少量的线程,一个负责接收请求(boss NioEventLoopGroup),其他的负责处理请求(worker NioEventLoopGroup),对于多 CPU 时效率会更高(Netty 的线程池会默认启动 2 倍的 CPU 核数个线程)。
后续笔记会详细分析。
Socket 的抽象层次
Socket是一种"打开—读/写—关闭"模式的实现,服务器和客户端各自维护一个"文件",在建立连接打开后,可以向自己文件写入内容供对方读取或者读取对方内容,通讯结束时关闭文件。
不同层次的抽象,对 Socket 的解释是不一样的,在计算机网络中,解释 Socket 是 ip 地址+端口号,都对,主要看是哪一层次的抽象。
在网络编程层次,这些Socket函数是操作系统内核实现的,用户代码无法触及,只能使用,这些内核代码把TCP/IP协议栈和网卡封装,暴露出来对用户友好的API,就成了所谓的 Socket 函数,用户代码可以用这些 Socket 函数操纵本地的TCP/IP协议栈和网卡,和服务器通信。
回到网络层次,OSI 的上三层等价于 TCP/IP 协议族的应用层(典型的 Telnet、FTP 等应用), OSI 下两层等价于 TCP/IP 协议族中随系统提供的设备驱动程序和硬件。在一个网络程序中, 对应OSI 模型,上三层处理应用本身的细节,却对应用底层的通信细节了解很少;下四层可以处理所有的底层网络的通信细节。OSI 的上三层可以对应所谓的用户进程,下四层通常对应操作系统内核的一部分,因此,把第4层和第5层之间的接口抽象为 Socket API 是自然而然的一个过程,即所谓的 Socket 所处的位置就是 TCP/IP 协议族应用层和传输层的交界处。
从编程角度看TCP协议状态转移过程
在 Linux 的网络编程这个层次中,客户机和服务器各有一个Socket文件,当两台主机通信时,客户机里的客户端应用进程 A 发送消息,通过 TCP协议数据包头的 SYN 标志位置1,进行主动打开,经 A 主机的 TCP/IP 协议栈发送到 LAN,然后经 WAN 中的路由器传给服务端应用进程 B 的目的主机所在的 LAN,之后经目的主机的 LAN 将报文传给目的主机,最后经目的主机的 TCP/IP 协议栈处理,服务器被动打开,将消息递交给目的应用程序 B。
具体分析如下,在连接建立阶段,客户端调用 connect() 函数发起主动连接——触发客户端的 TCP 协议栈发送 SYN 报文,此时客户端处于 SYN-SENT 态,如下。而在此之前,服务端的 Socket 需要已经处于监听态(LISTEN),在 Linux 上就是调用 listen() 函数即可实现监听 Socket。
服务端的 TCP 协议栈收到该 SYN 报文后,发送给处于 LISTEN 状态的服务端 Socket,服务端应用进程通过调用 accept() 函数触发其 TCP 协议栈发送 SYN+ACK 报文返回给客户端,此时服务端从 LISTEN 态转移到 SYN-RCVD 态。
客户端收到服务端的 SYN+ACK 报文后,发送确认的 ACK 报文,此时客户端从 SYN-SENT 态进入 ESTABLISHED 态,当服务端收到客户端的 ACK 报文后,同样会从 SYN-RCVD 态也进入 ESTABLISHED 态,此时服务端的 accept() 函数返回。
经过如上三个报文交互,TCP 连接建立,然后就可以进行数据传输。
在数据传输阶段,客户端的 Socket 可以调用 send() 函数发送数据,然后服务端的 Socket 接到客户端 Socket 传来的请求,调用 read() 函数读取,调用 write() 函数写入响应。
在连接断开阶段,以客户端主动关闭为例子。
客户端的 TCP 协议栈主动发送一个 FIN 报文,主动关闭到服务端方向的连接,此时客户端状态从 ESTABLISHED 态转移到 FIN-WAIT-1 态。通过调用 close() 函数即可实现。
服务端 TCP 协议栈收到 FIN 报文,就发回客户端一个 ACK 报文确认关闭,此时,服务端状态从 ESTABLISHED 态转移到 CLOSE-WAIT 态(因为是被动关闭),和 SYN 一样,一个 FIN 也占用一个序号,同时服务端还向客户端传送一个文件结束符。当客户端接受到服务端确认关闭的报文后,客户端状态从 FIN-WAIT-1 态转移到 FIN-WAIT-2 态。
接着这个服务端程序就关闭它的连接,这会导致服务端的 TCP 协议栈也会发送一个 FIN 报文给客户端,这里也能清楚看到,ACK 不消耗序号。此时,服务端状态从 CLOSE-WAIT 转移到 LAST-ACK 态。
客户端收到服务端的 FIN 报文,也必须发回一个ACK 确认报文。此时,客户端状态从 FIN-WAIT-2 态转移到 TIME-WAIT 态。
至此,TCP 连接关闭。
Linux 网络编程中的系统调用函数
对于运行在 Java 虚拟机上的 Java 语言来说,其自身的 Socket 函数,就是对操作系统的这些系统调用函数的封装而已。看看这些系统调用函数,有助于理解非阻塞通信原理,先认识一些辅助的 Socket 系统调用函数。
socket 函数:对应于普通文件的打开操作,要知道 Linux 中,一切都是文件,包括 Socket 本身也是一个文件,分别存在于客户端和服务端机器上。前面也提到了 fd,即普通文件的打开操作会返回一个文件描述符——file description,即 fd,socket() 函数就是用来创建 Socket 描述符(socket descriptor,即 sd) 的,它唯一标识一个 Socket。这个 sd 跟 fd 一样,后续的操作都会用到它,把它作为参数,通过它来进行一些 Socket 的读写操作。
bind 函数:给一个 sd 绑定一个协议和地址+端口号。
listen 函数:socket() 函数创建的 Socket 默认是一个主动类型的,listen 函数将 Socket 变为被动类型的,用于等待客户的连接请求。
connect 函数:客户端通过调用 connect 函数来建立与 TCP 服务器的连接。
accept 函数:TCP 服务端依次调用 socket()、bind()、listen() 之后,就会监听指定的 Socket 地址了,TCP 客户端依次调用 socket()、connect() 之后就向 TCP 服务端发送一个连接请求。服务端监听到这个请求后,调用 accept() 函数接收请求,如果 accept() 函数成功返回,则标识服务端与客户端已经正确建立连接,此时服务端可以通过 accept 函数返回的 Socket 来完成与客户端的通信,之后的操作就和普通的 I/O 操作(read 函数和 write 函数)没什么区别。
Linux select 函数
Linux 提供了 select/poll 函数,这些系统调用的进程通过将一个或多个 fd(文件描述符,Linux 的一切都是文件) 传递给 select 或 poll 系统调用,阻塞在这两个系统调用中的某一个之上,而不是阻塞在真正的 I/O 系统调用上,这样 select/poll 可以帮我们侦测多个 fd 是否处于就绪状态。
具体的说,联系老师和学生考试的例子,select/poll 顺序扫描 fd 是否就绪,但是 select 支持的 fd 数量有限,因此它的使用受到了一些制约。Linux 还提供一个 epoll 系统调用,两个东西本质是一样的,只不过 epoll 高级一些,能力更强一些,是基于事件驱动方式代替顺序扫描,因此性能更高——当有 fd 就绪时,立即回调函数rollback。该函数允许进程指示内核等待多个事件中的任何一个发生,并只在有一个或多个事件发生或经历一段指定的时间后才唤醒它。
也就是说,我们调用 select/epoll 告知内核对哪些描述符(读、写或异常条件〉感兴趣以及等待多长时间。我们感兴趣的描述符不局限于套接字,任何描述符都可以使用select来测试。
乍一看上面的解释,可能会懵逼,当然,懂得就略过。下面就详细分析下,毕竟人家都黑我们 Javaer 不懂。。。
众所周知,read、write、recv, 和 recvfrom 等函数都是阻塞的函数,所谓阻塞,简单说,就是当函数不能成功执行完毕的时候,程序就会一直停在这里,无法继续执行以后的代码。
严格的说,Linux 对一个 fd 指定的文件或设备, 有两种工作方式: 阻塞与非阻塞方式。阻塞方式是指当试图对该 fd 进行读写时,如果当时没有数据可读,或者暂时不可写,程序就进入等待状态,直到可读或者可写为止。非阻塞方式是指如果 fd 没有数据可读,或者不可写,读/写的函数马上返回,不会等待结果。使用 selcet/epoll 函数就可以实现非阻塞编程。
先看 selcet 函数,它本质是一个轮循函数,即当循环询问fd时,可设置超时时间,超时时间到了就跳过代码继续往下执行。
1 fd_set readfd; 2 struct timeval timeout; 3 4 FD_ZERO(&readfd); // 初始化 readfd 5 FD_SET(gps_fd, &readfd); // 把 gps_fd 加入 readfd 6 timeout.tv_sec = 3; // 设置 3 秒超时 7 timeout.tv_usec = 0; 8 9 j = select(gps_fd+1, &readfd, NULL, NULL, &timeout); // 用 select 对 gps_fd 进行轮循 10 if(j>0){ 11 if( FD_ISSET(gps_fd, &readfd) ){ // 如果 gps_fd 可读 12 i = read(gps_fd, buf, SIZE); 13 buf[i] = '