网络IO模型优化
最开始的阻塞式IO,它在每一个连接创建时,都需要一个用户线程来处理,并且在IO操作没有就绪或者结束时,线程被挂起,进入阻塞等待状态,阻塞式IO就成为导致性能瓶颈的根本原因。
阻塞式发生在那些环节呢?
- 首先,应用程序通过系统调用socket创建一个套接字,他是分配给应用程序的一个文件描述符
- 其次,应用程序会通过系统调用bind,绑定地址和端口号,给套接字命名一个名称
- 然后,系统会调用listen创建一个队列用于存放客户端进来的请求
- 最后,应用服务会通过系统嗲用accept来监听客户端的连接请求
当有一个客户端连接到服务端之后,服务端就会调用fork创建一个子进程,通过系统调用read监听客户端发来的消息,再通过write向客户端返回信息。
1. 阻塞式IO
在整个socket通信工作流程中,socket的默认状态是阻塞的。也就是说,当发出一个不能立即完成的套接字调用时,其进程将被阻塞,被系统挂起,进入睡眠状态,一直等待响应的操作响应。
connect阻塞:
当客户端发起TCP请求,通过系统调用connect函数,TCP连接的建立需要完成三次握手过程,客户端需要等待服务端发送过来的ACK以及SYN信号,同样服务端也需要阻塞等待客户端连接的ACK信号,这就意味着会阻塞等待,直到确认连接。
accept阻塞:
一个阻塞的socket通信的服务端接收外来连接,会调用accept函数,如果没有新的连接到达,调用进程将被挂起,进入阻塞状态。
read、write阻塞
当一个socket连接创建成功之后,服务端用fork函数创建一个子进程,调用read函数等待客户端的数据写入,如果没有数据写入,调用子进程将被挂起,进入阻塞状态。
2. 非阻塞式IO
- 使用fcntl可以把以上三个操作都设置为非阻塞操作。如果没有数据返回,就会直接返回一个EWOULDBLOCK或EAGAIN错误,此时进程将不会一直被阻塞。
-
当我们把以上操作设置为了非阻塞状态,我们需要设置一个线程对该操作进行轮询检查,这是最传统的非阻塞IO模型。
3. IO复用
- 如果使用用户线程轮询查看一个IO操作的状态,在大量请求的情况下,这对于CPU的使用率是灾难。
-
linux提供了IO复用函数select/poll/epoll,进程将一个或多个读操作通过系统调用函数,阻塞在函数操作上。这样,系统内核就可以帮助我们侦测多个读操作是否处于就绪状态。
select()函数
- 在超时时间内,监听用户感兴趣的文件描述符上的可读可写和异常事件的发生。
- linux操作系统的内核将所有外部设备都看做一个文件来操作,对一个文件的读写操作会调用内核提供的系统命令,返回一个文件描述符fd。
- select() 函数监视的文件描述符分 3 类,分别是 writefds(写文件描述符)、readfds(读文件描述符)以及 exceptfds(异常事件文件描述符)。
- 调用后select()函数会阻塞,直到有描述符就绪或者超时,函数返回。当select函数返回后,可以通过函数FD_ISSET比那里fdset,来找到就绪的描述符。
- fd_set可以理解而我一个集合,这个集合中存放的是文件描述符。
epoll()函数
- select是顺序描述fd是否就绪,而且支持fd数量不宜过大。
- epoll使用事件驱动的方式代替轮询扫描fd。
- epoll实现通过epoll_ctl来注册一个文件描述符,将文件描述符存放到内核的一个事件表中,这个事件是基于红黑树实现的,所以在大量IO请求的场景下,
插入和删除的性能比select/poll的数组fd_set要好,因此epoll的性能更胜一筹,而且不会受到fd数量的限制。 -
一旦某个文件描述符就绪时,内核会采用类似 callback 的回调机制,迅速激活这个文件描述符,当进程调用 epoll_wait() 时便得到通知,之后进程将完成相关 I/O 操作。
4. 信号驱动式IO
信号驱动式IO类似于观察者模式,内核就是一个观察者,信号回调则是通知。用户进程发起一个IO请求操作,会通过系统调用sigaction函数,给对应的套接字注册一个信号回调,
此时不阻塞用户进程,进程会继续工作。当内核数据就绪时,内核就为该进程生成一个SIGIO信号,通过信号回调通知进行相关IO操作。
5. 异步IO
在 NIO 服务端通信编程中,首先会创建一个 Channel,用于监听客户端连接;接着,创建多路复用器 Selector,并将 Channel 注册到 Selector,程序会通过 Selector 来轮询注册在其上的 Channel,当发现一个或多个 Channel 处于就绪状态时,返回就绪的监听事件,最后程序匹配到监听事件,进行相关的 I/O 操作。