网络I/O模型定义的是应用线程与操作系统内核之间的交互行为模式。我们通常用阻塞(Blocking)/非阻塞(Non-Blocking)来描述网络 I/O 模型。
所谓阻塞,就是内核一直等到全部数据就绪才返回。如果内核查看数据就绪状态后,若没就绪便立刻返回错误,那么这种行为模式称为非阻塞。
阻塞I/O
阻塞 I/O 是最常用的模型,这个模型下应用线程与内核之间的交互行为模式是这样的:
我们看到,在阻塞 I/O 模型下,当用户空间应用线程,向操作系统内核发起 I/O 请求后,内核会尝试执行这个 I/O 操作,并等所有数据就绪后,将数据从内核空间拷贝到用户空间,最后系统调用从内核空间返回。而在这个期间内,用户空间应用线程将阻塞在这个 I/O 系统调用上,无法进行后续处理,只能等待。
服务器端的read函数会阻塞,去调用内核空间的数据
非阻塞I/O
非阻塞 I/O 模型下,应用线程与内核之间的交互行为模式是这样的:
和阻塞 I/O 模型正相反,在非阻塞模型下,当用户空间线程向操作系统内核发起 I/O 请求后,内核会执行这个 I/O 操作,如果这个时候数据尚未就绪,就会立即将“未就绪”的状态以错误码形式(比如:EAGAIN/EWOULDBLOCK),返回给这次 I/O 系统调用的发起者。而后者就会根据系统调用的返回状态来决定下一步该怎么做。
在非阻塞模型下,位于用户空间的 I/O 请求发起者通常会通过轮询的方式,去一次次发起 I/O 请求,直到读到所需的数据为止。不过,这样的轮询是对 CPU 计算资源的极大浪费,因此,非阻塞 I/O 模型单独应用于实际生产的比例并不高。
I/O多路复用
为了避免非阻塞 I/O 模型轮询对计算资源的浪费,同时也考虑到阻塞 I/O 模型的低效,开发人员首选的网络 I/O 模型,逐渐变成了建立在内核提供的多路复用函数 select/poll 等(以及性能更好的 epoll 等函数)基础上的 I/O 多路复用模型。
从图中我们看到,在这种模型下,应用线程首先将需要进行 I/O 操作的 Socket,都添加到多路复用函数中(这里以 select 为例),然后阻塞,等待 select 系统调用返回。当内核发现有数据到达时,对应的 Socket 具备了通信条件,这时 select 函数返回。然后用户线程会针对这个 Socket 再次发起网络 I/O 请求,比如一个 read 操作。由于数据已就绪,这次网络 I/O 操作将得到预期的操作结果。也就是通过多路复用函数的线程管理其他socket通信。
我们看到,相比于阻塞模型一个线程只能处理一个 Socket 的低效,I/O 多路复用模型中,一个应用线程可以同时处理多个 Socket。同时,I/O 多路复用模型由内核实现可读 / 可写事件的通知,避免了非阻塞模型中轮询,带来的 CPU 计算资源浪费的问题。
select
多路复用的方式:将已连接的 Socket 都放到一个文件描述符集合,然后调用 select 函数将文件描述符集合拷贝到内核里,让内核来检查是否有网络事件产生。
内核如何检查是否有网络事件发生呢?
通过遍历文件描述符集合,当检测当有网络事件(socket的write/read事件)发生时,标记为可读/可写,之后将整个文件描述符集合拷贝回用户态,用户态再通过遍历找到可读或可写的Socket,并对其进处理。
所以,对于 select 这种方式,需要进行 2 次「遍历」文件描述符集合,一次是在内核态里,一个次是在用户态里 ,而且还会发生 2 次「拷贝」文件描述符集合,先从用户空间传入内核空间,由内核修改后,再传出到用户空间中。
select 使用固定长度的 BitsMap,表示文件描述符集合,而且所支持的文件描述符的个数是有限制的,在 Linux 系统中,由内核中的 FD_SETSIZE 限制, 默认最大值为 1024,只能监听 0~1023 的文件描述符。
poll
poll 不再用 BitsMap 来存储所关注的文件描述符,取而代之用动态数组,以链表形式来组织,突破了 select 的文件描述符个数限制,当然还会受到系统文件描述符限制。
用户态使用数组传递文件描述符集合,在内核态使用链表存储。
poll 和 select 并没有太大的本质区别,都是使用「线性结构」存储进程关注的 Socket 集合,因此都需要遍历文件描述符集合来找到可读或可写的 Socket,时间复杂度为 O(n),而且也需要在用户态与内核态之间拷贝文件描述符集合,这种方式随着并发数上来,性能的损耗会呈指数级增长。
epoll
int s = socket(AF_INET, SOCK_STREAM, 0);
bind(s, ...);
listen(s, ...)
int epfd = epoll_create(...);
epoll_ctl(epfd, ...); //将所有需要监听的socket添加到epfd中
while(1) {
int n = epoll_wait(...);
for(接收到数据的socket){
//处理
}
}
epoll 在内核里使用红黑树来跟踪进程所有待检测的文件描述字,把需要监控的 socket 通过 epoll_ctl() 函数加入内核中的红黑树里,红黑树是个高效的数据结构,增删改一般时间复杂度是 O(logn)
epoll 因为在内核维护了红黑树,可以保存所有待检测的 socket ,所以只需要传入一个待检测的 socket,减少了内核和用户空间大量的数据拷贝和内存分配。
epoll 使用事件驱动的机制,内核里维护了一个链表来记录就绪事件,当某个 socket 有事件发生时,通过回调函数内核会将其加入到这个就绪事件列表中,当用户调用 epoll_wait() 函数时,只会返回有事件发生的文件描述符的个数,不需要像 select/poll 那样轮询扫描整个 socket 集合,大大提高了检测的效率。
边缘触发/水平触发
- 水平触发:当被监控的 Socket 上有可读事件发生时,服务器端不断地从 epoll_wait 中苏醒/调用select或poll,直到内核缓冲区数据被 read 函数读完才结束
- 边缘触发:当被监控的 Socket 描述符上有可读事件发生时,服务器端只会从 epoll_wait 中苏醒一次,即使进程没有调用 read 函数从内核读取数据,也依然只苏醒一次
select/poll 只有水平触发模式,epoll 默认的触发模式是水平触发,但是可以根据应用场景设置为边缘触发模式
为什么多路复用是同步阻塞的?而多路复用内部是同步非阻塞的呢?
用户线程阻塞知道获取fd_set才返回,无需轮询,减少CPU的消耗
内部每个连接采用非阻塞,不会因为某个连接为就绪而阻塞很长时间。
同时read最好也要设置为非阻塞?
思考:多线程还是单线程
多线程技术可以充分利用CPU资源,适合有IO密集型操作
但是对于计算密集型应用,引入多线程反而需要维护大量的上下文切换,但线程足矣。