一文搞懂网络IO模型(阻塞式IO、非阻塞式IO、IO复用、信号驱动IO、异步IO)
网络通信中,最底层的就是内核中的网络I/O模型了。随着技术的发展,操作系统内核的网络模型衍生出了五种I/O模型,《UNIX网络编程》一书将这五种I/O模型分为阻塞式I/O、非阻塞式I/O、I/O复用、信号驱动式I/O和异步I/O。
1. 阻塞式IO
阻塞式I/O,它在每一个连接创建时,都需要一个用户线程来处理,并且在I/O操作没有就绪或结束时,线程会被挂起,进入阻塞等待状态。
那阻塞到底发生在套接字(socket)通信的哪些环节呢?
套接字通信可以分为流式套接字(TCP)和数据报套接字(UDP), TCP通信的阻塞阶段有三次:
- accept阻塞:服务端接收外来连接,会调用accept函数,如果没有新的连接到达,调用进程将被挂起,进入阻塞状态。
- connect阻塞:客户端发起TCP连接请求,通过系统调用connect函数,TCP连接的建立需要完成三次握手过程,客户端需要等待服务端发送回来的ACK以及SYN信号,同样服务端也需要阻塞等待客户端确认连接的ACK信号,这就意味着TCP的connect都会阻塞等待。
- read/write阻塞:接创建成功之后,服务端用fork函数创建一个子进程, 调用read函数等待客户端的数据写入,如果没有数据写入,调用子进程将被挂起,进入阻塞状态。
2. 非阻塞式IO
以上三种阻塞都可以设置为非阻塞操作,如果没有数据返回,就会直接返回一个EWOULDBLOCK或EAGAIN错误,此时进程就不会一直被阻塞。
非阻塞式IO需要使用用户线程轮询(需要设置一个线程对这些操作进行轮询检查)查看一个I/O操作的状态,在大量请求的情况下,这对于CPU的使用率无疑是种灾难。
3. IO复用
多个阻塞线程阻塞在IO复用函数上,当阻塞线程就绪后,内核会采用类似callback的回调机制,迅速激活这个阻塞线程,当进程便得到通知,之后进程将完成相关I/O操作。
IO复用函数的作用
IO复用函数监视的阻塞线程的三个文件描述符,分别是writefds(写文件描述符)、readfds(读文件描述符)以及exceptfds(异常事件文件描述符)。直到有描述符就绪或者超时,函数返回。当函数返回后,顺序轮训或红黑树查找阻塞线程是否就绪,当阻塞线程就绪后,内核会采用类似callback的回调机制,激活这个阻塞线程,当进程便得到通知,之后进程将完成相关I/O操作。
IO复用函数的分类?
- select:轮训监视多个阻塞线程的文件描述符
- poll:poll() 的机制与 select() 类似,二者在本质上差别不大。poll() 管理多个描述符也是通过轮询,根据描述符的状态进行处理,但 poll() 没有最大文件描述符数量的限制。
- epoll:epoll使用事件驱动的方式代替轮询扫描文件描述符。epoll事先通过注册阻塞线程的文件描述符,将文件描述符存放到内核的一个事件表中,这个事件表是基于红黑树实现的。一旦某个文件描述符就绪时,内核会采用类似callback的回调机制,迅速激活这个文件描述符,当进程调便得到通知,之后进程将完成相关I/O操作。
4. 信号驱动式IO
前三种I/O模式,线程等待就绪时会阻塞,而信号驱动式IO真正实现了在等待数据就绪时,进程不被阻塞,主循环可以继续工作,所以性能更佳。
用户进程发起一个I/O请求操作,会通过系统调用sigaction函数,给对应的套接字建立一个基于SIGIO的信号处理程序(回调函数),此时不阻塞用户进程,进程会继续工作。当内核数据就绪时,内核就为该进程生成一个SIGIO信号,通过信号回调通知进程进行相关I/O操作。
使用范围
而由于TCP来说,信号驱动式I/O几乎没有被使用,这是因为SIGIO信号是一种Unix信号,信号没有附加信息,如果一个信号源有多种产生信号的原因,信号接收者就无法确定究竟发生了什么。而 TCP socket生产的信号事件有七种之多,这样应用程序收到 SIGIO,根本无从区分处理。
但信号驱动式I/O现在被用在了UDP通信上,我们从10讲中的UDP通信流程图中可以发现,UDP只有一个数据请求事件,这也就意味着在正常情况下UDP进程只要捕获SIGIO信号,就调用recvfrom读取到达的数据报。如果出现异常,就返回一个异常错误。比如,NTP服务器就应用了这种模型。
5. 异步IO
信号驱动式I/O虽然在等待数据就绪时,没有阻塞进程,但在被通知后进行的I/O操作还是阻塞的,进程会等待数据从内核空间复制到用户空间中。而异步I/O则是实现了真正的非阻塞I/O。
当用户进程发起一个I/O请求操作,系统会告知内核,并让内核在整个操作完成后通知进程。(目前Linux暂不支持)
6. 零拷贝
Java的NIO实现原理?
Java的NIO使用I/O复用器Selector实现非阻塞I/O,Selector就是使用了这五种类型中的I/O复用模型。Java中的Selector其实就是select/poll/epoll的外包类。在NIO服务端通信编程中,首先会创建一个Channel,用于监听客户端连接;接着创建多路复用器Selector,并将Channel注册到Selector,程序会通过Selector来轮询注册在其上的Channel,当发现一个或多个Channel处于就绪状态时,返回就绪的监听事件,最后程序匹配到监听事件,进行相关的I/O操作。
Java的NIO零拷贝优化?
在Java的NIO编程中,使用Direct Buffer来实现内存的零拷贝。Java直接在JVM内存空间之外开辟了一个物理内存空间,这样内核和用户进程都能共享一份缓存数据。
7. NIO用户层优化
NIO在用户层也做了优化升级——Reactor模型。Reactor模型是同步I/O事件处理的一种常见模型,其核心思想是将I/O事件注册到多路复用器上,一旦有I/O事件触发,多路复用器就会将事件分发到事件处理器中,执行就绪的I/O事件操作。
该模型有以下三种
- 单线程模型:所有的I/O操作(IO监听、处理)都是在一个NIO线程上完成,是单线程非阻塞式的。
缺点:因为读写I/O操作时用户进程还是处于阻塞状态,这种方式在高负载、高并发的场景下会存在性能瓶颈
- 多线程模型
在Tomcat和Netty中都使用了一个Acceptor线程来监听连接请求事件。当连接成功之后,会将建立的连接注册一个线程实现的多路复用器中,一旦监听到事件,将交给Worker线程池来负责处理。
- 主从线程模型
现在主流通信框架中的NIO通信框架都是基于主从线程模型来实现的。在这个模型中,Acceptor不再是一个单独的NIO线程,而是一个线程池。Acceptor使用线程池监听接收客户端的TCP连接请求,建立连接之后,后续的I/O操作将交给Worker I/O线程池操作。
8. tomcat配置项
-
acceptorThreadCount:该参数代表Acceptor的线程数量,监听接收客户端的TCP连接请求;负责从accept队列中取出该connection;然后交给工作线程去执行相关操作,默认值为1。
-
maxThreads:专门处理I/O操作的Worker线程数量,默认是200
-
acceptCount:accept队列的大小。
当Http关闭keep alive,在并发量比较大时,可以适当地调大这个值。而在Http开启keep alive时,因为Worker线程数量有限,Worker线程就可能因长时间被占用,而连接在accept队列中等待超时。如果accept队列过大,就容易浪费连接。
-
maxConnections:表示有多少个socket连接到Tomcat上,maxConnections应该设置得比maxThreads要大的多,默认是10000。