I/O多路复用:select/poll/epoll
很久很久以前(三天前),我被问到一个熟悉又陌生的问题,了解I/O多路复用吗?我回答,了解但是还没开始学?是的,我学习的领域就是这么广泛又浅显,名词我都了解,但是就是不知道具体是什么。
说到 I/O多路复用,我学习的博客都把他归到了操作系统一类里面,我也学过操作系统啊,为什么就不知道这是什么呢?我问了我周围的同学,好像都不知道,应该是当时老师没讲,也可能是讲了我们都没听。我知道这个算法是在学习 Redis 的时候,我们在 Redis 为什么这么快 那篇博客里提到了 多路复用 这个算法,今天我们就来看一看 I/O多路复用的前世今生。
最基础的 Socket
模型
要想客户端和服务器能在网络中通信,那必须得使用 Socket 编程,它是进程间通信里比较特别的方式,特别之处在于它是可以跨主机间通信。
Socket 的中文名叫做插口,乍一看还挺迷惑的。事实上,双方要进行网络通信前,各自得创建一个 Socket,这相当于客户端和服务器都开了一个“口子”,双方读取和发送数据的时候,都通过这个“口子”。这样一看,是不是觉得很像弄了一根网线,一头插在客户端,一头插在服务端,然后进行通信。
创建 Socket 的时候,可以指定网络层使用的是 IPv4 还是 IPv6,传输层使用的是 TCP 还是 UDP。
UDP 的 Socket 编程相对简单些,这里我们只介绍基于 TCP 的 Socket 编程。
服务器的程序要先跑起来,然后等待客户端的连接和数据,我们先来看看服务端的 Socket 编程过程是怎样的。
服务端首先调用 socket()
函数,创建网络协议为 IPv4,以及传输协议为 TCP 的 Socket ,接着调用 bind()
函数,给这个 Socket 绑定一个 IP 地址和端口,绑定这两个的目的是什么?
- 绑定端口的目的:当内核收到 TCP 报文,通过 TCP 头里面的端口号,来找到我们的应用程序,然后把数据传递给我们。
- 绑定 IP 地址的目的:一台机器是可以有多个网卡的,每个网卡都有对应的 IP 地址,当绑定一个网卡时,内核在收到该网卡上的包,才会发给我们;
绑定完 IP 地址和端口后,就可以调用 listen()
函数进行监听,此时对应 TCP 状态图中的 listen
,如果我们要判定服务器中一个网络程序有没有启动,可以通过 netstat
命令查看对应的端口号是否有被监听。
服务端进入了监听状态后,通过调用 accept()
函数,来从内核获取客户端的连接,如果没有客户端连接,则会阻塞等待客户端连接的到来。
那客户端是怎么发起连接的呢?客户端在创建好 Socket 后,调用 connect()
函数发起连接,该函数的参数要指明服务端的 IP 地址和端口号,然后万众期待的 TCP 三次握手就开始了。
在 TCP 连接的过程中,服务器的内核实际上为每个 Socket 维护了两个队列:
- 一个是「还没完全建立」连接的队列,称为 TCP 半连接队列,这个队列都是没有完成三次握手的连接,此时服务端处于
syn_rcvd
的状态; - 一个是「已经建立」连接的队列,称为 TCP 全连接队列,这个队列都是完成了三次握手的连接,此时服务端处于
established
状态;
当 TCP 全连接队列不为空后,服务端的 accept()
函数,就会从内核中的 TCP 全连接队列里拿出一个已经完成连接的 Socket 返回应用程序,后续数据传输都用这个 Socket。
注意,监听的 Socket 和真正用来传数据的 Socket 是两个:
- 一个叫作监听 Socket;
- 一个叫作已连接 Socket;
连接建立后,客户端和服务端就开始相互传输数据了,双方都可以通过 read()
和 write()
函数来读写数据。
至此, TCP 协议的 Socket 程序的调用过程就结束了,整个过程如下图:
看到这,不知道你有没有觉得读写 Socket 的方式,好像读写文件一样。
是的,基于 Linux 一切皆文件的理念,在内核中 Socket 也是以「文件」的形式存在的,也是有对应的文件描述符。
如何服务更多的用户?
前面提到的 TCP Socket 调用流程是最简单、最基本的,它基本只能一对一通信,因为使用的是同步阻塞的方式,当服务端在还没处理完一个客户端的网络 I/O 时,或者 读写操作发生阻塞时,其他客户端是无法与服务端连接的。
可如果我们服务器只能服务一个客户,那这样就太浪费资源了,于是我们要改进这个网络 I/O 模型,以支持更多的客户端。
在改进网络 I/O 模型前,我先来提一个问题,你知道服务器单机理论最大能连接多少个客户端?
相信你知道 TCP 连接是由四元组唯一确认的,这个四元组就是:本机IP, 本机端口, 对端IP, 对端端口。
服务器作为服务方,通常会在本地固定监听一个端口,等待客户端的连接。因此服务器的本地 IP 和端口是固定的,于是对于服务端 TCP 连接的四元组只有对端 IP 和端口是会变化的,所以最大 TCP 连接数 = 客户端 IP 数×客户端端口数。
对于 IPv4,客户端的 IP 数最多为 2 的 32 次方,客户端的端口数最多为 2 的 16 次方,也就是服务端单机最大 TCP 连接数约为 2 的 48 次方。
这个理论值相当“丰满”,但是服务器肯定承载不了那么大的连接数,主要会受两个方面的限制:
- 文件描述符,Socket 实际上是一个文件,也就会对应一个文件描述符。在 Linux 下,单个进程打开的文件描述符数是有限制的,没有经过修改的值一般都是 1024,不过我们可以通过 ulimit 增大文件描述符的数目;
- 系统内存,每个 TCP 连接在内核中都有对应的数据结构,意味着每个连接都是会占用一定内存的;
那如果服务器的内存只有 2 GB,网卡是千兆的,能支持并发 1 万请求吗?
并发 1 万请求,也就是经典的 C10K 问题 ,C 是 Client 单词首字母缩写,C10K 就是单机同时处理 1 万个请求的问题。
从硬件资源角度看,对于 2GB 内存千兆网卡的服务器,如果每个请求处理占用不到 200KB 的内存和 100Kbit 的网络带宽就可以满足并发 1 万个请求。
不过,要想真正实现 C10K 的服务器,要考虑的地方在于服务器的网络 I/O 模型,效率低的模型,会加重系统开销,从而会离 C10K 的目标越来越远。
多进程模型
基于最原始的阻塞网络 I/O, 如果服务器要支持多个客户端,其中比较传统的方式,就是使用多进程模型,也就是为每个客户端分配一个进程来处理请求。
服务器的主进程负责监听客户的连接,一旦与客户端连接完成,accept() 函数就会返回一个「已连接 Socket」,这时就通过 fork()
函数创建一个子进程,实际上就把父进程所有相关的东西都复制一份,包括文件描述符、内存地址空间、程序计数器、执行的代码等。
这两个进程刚复制完的时候,几乎一模一样。不过,会根据返回值来区分是父进程还是子进程,如果返回值是 0,则是子进程;如果返回值是其他的整数,就是父进程。
正因为子进程会复制父进程的文件描述符,于是就可以直接使用「已连接 Socket 」和客户端通信了,
可以发现,子进程不需要关心「监听 Socket」,只需要关心「已连接 Socket」;父进程则相反,将客户服务交给子进程来处理,因此父进程不需要关心「已连接 Socket」,只需要关心「监听 Socket」。
下面这张图描述了从连接请求到连接建立,父进程创建生子进程为客户服务。
另外,当「子进程」退出时,实际上内核里还会保留该进程的一些信息,也是会占用内存的,如果不做好“回收”工作,就会变成僵尸进程,随着僵尸进程越多,会慢慢耗尽我们的系统资源。
因此,父进程要“善后”好自己的孩子,怎么善后呢?那么有两种方式可以在子进程退出后回收资源,分别是调用 wait()
和 waitpid()
函数。
这种用多个进程来应付多个客户端的方式,在应对 100 个客户端还是可行的,但是当客户端数量高达一万时,肯定扛不住的,因为每产生一个进程,必会占据一定的系统资源,而且进程间上下文切换的“包袱”是很重的,性能会大打折扣。
进程的上下文切换不仅包含了虚拟内存、栈、全局变量等用户空间的资源,还包括了内核堆栈、寄存器等内核空间的资源。
多线程模型
既然进程间上下文切换的“包袱”很重,那我们就搞个比较轻量级的模型来应对多用户的请求 —— 多线程模型。
线程是运行在进程中的一个“逻辑流”,单进程中可以运行多个线程,同进程里的线程可以共享进程的部分资源,比如文件描述符列表、进程空间、代码、全局数据、堆、共享库等,这些共享些资源在上下文切换时不需要切换,而只需要切换线程的私有数据、寄存器等不共享的数据,因此同一个进程下的线程上下文切换的开销要比进程小得多。
当服务器与客户端 TCP 完成连接后,通过 pthread_create()
函数创建线程,然后将「已连接 Socket」的文件描述符传递给线程函数,接着在线程里和客户端进行通信,从而达到并发处理的目的。
如果每来一个连接就创建一个线程,线程运行完后,还得操作系统还得销毁线程,虽说线程切换的上写文开销不大,但是如果频繁创建和销毁线程,系统开销也是不小的。
那么,我们可以使用线程池的方式来避免线程的频繁创建和销毁,所谓的线程池,就是提前创建若干个线程,这样当由新连接建立时,将这个已连接的 Socket 放入到一个队列里,然后线程池里的线程负责从队列中取出「已连接 Socket 」进行处理。
需要注意的是,这个队列是全局的,每个线程都会操作,为了避免多线程竞争,线程在操作这个队列前要加锁。
上面基于进程或者线程模型的,其实还是有问题的。新到来一个 TCP 连接,就需要分配一个进程或者线程,那么如果要达到 C10K,意味着要一台机器维护 1 万个连接,相当于要维护 1 万个进程/线程,操作系统就算死扛也是扛不住的。
I/O多路复用
既然为每个请求分配一个进程/线程的方式不合适,那有没有可能只使用一个进程来维护多个 Socket 呢?答案是有的,那就是 I/O 多路复用技术。
一个进程虽然任一时刻只能处理一个请求,但是处理每个请求的事件时,耗时控制在 1 毫秒以内,这样 1 秒内就可以处理上千个请求,把时间拉长来看,多个请求复用了一个进程,这就是多路复用,这种思想很类似一个 CPU 并发多个进程,所以也叫做时分多路复用。
我们熟悉的 select/poll/epoll 内核提供给用户态的多路复用系统调用,进程可以通过一个系统调用函数从内核中获取多个事件。
select/poll/epoll 是如何获取网络事件的呢?在获取事件时,先把所有连接(文件描述符)传给内核,再由内核返回产生了事件的连接,然后在用户态中再处理这些连接对应的请求即可。
select/poll/epoll 这是三个多路复用接口,都能实现 C10K 吗?接下来,我们分别说说它们。
Select
Select 实现多路复用的方式是,将以连接的 Socket 都放到一个文件描述符集合,然后调用 Select 函数将文件描述符拷贝到内核里,让内核来检查是否有网络事件产生,检查的方式很粗暴,就是通过遍历文件描述符集合的方式,当检查有事件产生后,将此 Socket 标记为可读或者可写,接着再把整个文件描述符集合拷贝回用户态,然后用户态还需要再通过遍历的方法找到可读或可写的 Socket,然后再对其处理。
所以,对于 select 这种方式,需要进行 2 次「遍历」文件描述符集合,一次是在内核态里,一个次是在用户态里 ,而且还会发生 2 次「拷贝」文件描述符集合,先从用户空间传入内核空间,由内核修改后,再传出到用户空间中。
select 使用固定长度的 BitsMap,表示文件描述符集合,而且所支持的文件描述符的个数是有限制的,在 Linux 系统中,由内核中的 FD_SETSIZE 限制, 默认最大值为 1024
,只能监听 0~1023 的文件描述符。
函数签名与参数
1 | int select(int nfds, |
readfds
、writefds
、errorfds
是三个文件描述符集合。select
会遍历每个集合的前 nfds
个描述符,分别找到可以读取、可以写入、发生错误的描述符,统称为“就绪”的描述符。然后用找到的子集替换参数中的对应集合,返回所有就绪描述符的总数。
timeout
参数表示调用 select
时的阻塞时长。如果所有文件描述符都未就绪,就阻塞调用进程,直到某个描述符就绪,或者阻塞超过设置的 timeout 后,返回。如果 timeout
参数设为 NULL,会无限阻塞直到某个描述符就绪;如果 timeout
参数设为 0,会立即返回,不阻塞。
文件描述符 fd
文件描述符(file descriptor)是一个非负整数,从 0 开始。进程使用文件描述符来标识一个打开的文件。
系统为每一个进程维护了一个文件描述符表,表示该进程打开文件的记录表,而文件描述符实际上就是这张表的索引。当进程打开(open
)或者新建(create
)文件时,内核会在该进程的文件列表中新增一个表项,同时返回一个文件描述符 —— 也就是新增表项的下标。
一般来说,每个进程最多可以打开 64 个文件,fd ∈ 0~63
。在不同系统上,最多允许打开的文件个数不同,Linux 2.4.22 强制规定最多不能超过 1,048,576。
每个进程默认都有 3 个文件描述符:0 (stdin)、1 (stdout)、2 (stderr)。
socket 与 fd 的关系
socket 是 Unix 中的术语。socket 可以用于同一台主机的不同进程间的通信,也可以用于不同主机间的通信。一个 socket 包含地址、类型和通信协议等信息,通过 socket()
函数创建:
1 | int socket(int domain, int type, int protocol) |
返回的就是这个 socket 对应的文件描述符 fd
。操作系统将 socket 映射到进程的一个文件描述符上,进程就可以通过读写这个文件描述符来和远程主机通信。
可以这样理解:socket 是进程间通信规则的高层抽象,而 fd 提供的是底层的具体实现。socket 与 fd 是一一对应的。通过 socket 通信,实际上就是通过文件描述符 fd
读写文件。这也符合 Unix“一切皆文件”的哲学。
后面可以将 socket 和 fd 视为同义词。
fd_set 文件描述符集合
参数中的 fd_set
类型表示文件描述符的集合。
由于文件描述符 fd
是一个从 0 开始的无符号整数,所以可以使用 fd_set
的二进制每一位来表示一个文件描述符。某一位为 1,表示对应的文件描述符已就绪。比如比如设 fd_set
长度为 1 字节,则一个 fd_set
变量最大可以表示 8 个文件描述符。当 select
返回 fd_set = 00010011
时,表示文件描述符 1
、2
、5
已经就绪。
fd_set
的使用涉及以下几个 API:
1 |
|
select 使用示例
下图的代码说明:
- 先声明一个
fd_set
类型的变量readFDs
- 调用
FD_ZERO
,将readFDs
所有位置 0 - 调用
FD_SET
,将readFDs
感兴趣的位置 1,表示要监听这几个文件描述符 - 将
readFDs
传给select
,调用select
select
会将readFDs
中就绪的位置 1,未就绪的位置 0,返回就绪的文件描述符的数量- 当
select
返回后,调用FD_ISSET
检测给定位是否为 1,表示对应文件描述符是否就绪
比如进程想监听 1、2、5 这三个文件描述符,就将 readFDs
设置为 00010011
,然后调用 select
。
如果 fd=1
、fd=2
就绪,而 fd=5
未就绪,select
会将 readFDs
设置为 00000011
并返回 2。
如果每个文件描述符都未就绪,select
会阻塞 timeout
时长,再返回。这期间,如果 readFDs
监听的某个文件描述符上发生可读事件,则 select
会将对应位置 1,并立即返回。
select 的缺点
- 性能开销大
- 调用
select
时会陷入内核,这时需要将参数中的fd_set
从用户空间拷贝到内核空间 - 内核需要遍历传递进来的所有
fd_set
的每一位,不管它们是否就绪
- 调用
- 同时能够监听的文件描述符数量太少。受限于
sizeof(fd_set)
的大小,在编译内核时就确定了且无法更改。一般是 1024,不同的操作系统不相同
poll
poll 和 select 几乎没有区别。poll 在用户态通过数组方式传递文件描述符,在内核会转为链表方式存储,没有最大数量的限制。
poll 的函数签名如下:
1 | int poll(struct pollfd *fds, nfds_t nfds, int timeout); |
其中 fds
是一个 pollfd
结构体类型的数组,调用 poll()
时必须通过 nfds
指出数组 fds
的大小,即文件描述符的数量。
poll 和 select 并没有太大的本质区别,都是使用「线性结构」存储进程关注的 Socket 集合,因此都需要遍历文件描述符集合来找到可读或可写的 Socket,时间复杂度为 O(n),而且也需要在用户态与内核态之间拷贝文件描述符集合,这种方式随着并发数上来,性能的损耗会呈指数级增长。
epoll
epoll 是对 select 和 poll 的改进,避免了“性能开销大”和“文件描述符数量少”两个缺点。
简而言之,epoll 有以下几个特点:
- 使用红黑树存储文件描述符集合
- 使用队列存储就绪的文件描述符
- 每个文件描述符只需在添加时传入一次;通过事件更改文件描述符状态
select、poll 模型都只使用一个函数,而 epoll 模型使用三个函数:epoll_create
、epoll_ctl
和 epoll_wait
。
如下的代码中,先用e poll_create 创建一个 epol l对象 epfd,再通过 epoll_ctl 将需要监视的 socket 添加到epfd中,最后调用 epoll_wait 等待数据。
1 | int s = socket(AF_INET, SOCK_STREAM, 0); |
epoll 通过两个方面,很好解决了 select/poll 的问题。
第一点,epoll 在内核里使用红黑树来跟踪进程所有待检测的文件描述字,把需要监控的 socket 通过 epoll_ctl()
函数加入内核中的红黑树里,红黑树是个高效的数据结构,增删改一般时间复杂度是 O(logn)
。而 select/poll 内核里没有类似 epoll 红黑树这种保存所有待检测的 socket 的数据结构,所以 select/poll 每次操作时都传入整个 socket 集合给内核,而 epoll 因为在内核维护了红黑树,可以保存所有待检测的 socket ,所以只需要传入一个待检测的 socket,减少了内核和用户空间大量的数据拷贝和内存分配。
第二点, epoll 使用事件驱动的机制,内核里维护了一个链表来记录就绪事件,当某个 socket 有事件发生时,通过回调函数内核会将其加入到这个就绪事件列表中,当用户调用 epoll_wait()
函数时,只会返回有事件发生的文件描述符的个数,不需要像 select/poll 那样轮询扫描整个 socket 集合,大大提高了检测的效率。
从下图你可以看到 epoll 相关的接口作用:
epoll 的方式即使监听的 Socket 数量越多的时候,效率不会大幅度降低,能够同时监听的 Socket 的数目也非常的多了,上限就为系统定义的进程打开的最大文件描述符个数。因而,epoll 被称为解决 C10K 问题的利器。
插个题外话,网上文章不少说,
epoll_wait
返回时,对于就绪的事件,epoll 使用的是共享内存的方式,即用户态和内核态都指向了就绪链表,所以就避免了内存拷贝消耗。这是错的!看过 epoll 内核源码的都知道,压根就没有使用共享内存这个玩意。你可以从下面这份代码看到, epoll_wait 实现的内核代码中调用了
__put_user
函数,这个函数就是将数据从内核拷贝到用户空间。好了,这个题外话就说到这了,我们继续
epoll_create
1 | int epoll_create(int size); |
epoll_create
会创建一个 epoll
实例,同时返回一个引用该实例的文件描述符。
返回的文件描述符仅仅指向对应的 epoll
实例,并不表示真实的磁盘文件节点。其他 API 如 epoll_ctl
、epoll_wait
会使用这个文件描述符来操作相应的 epoll
实例。
当创建好 epoll 句柄后,它会占用一个 fd 值,在 linux 下查看 /proc/进程id/fd/
,就能够看到这个 fd。所以在使用完 epoll 后,必须调用 close(epfd)
关闭对应的文件描述符,否则可能导致 fd 被耗尽。当指向同一个 epoll
实例的所有文件描述符都被关闭后,操作系统会销毁这个 epoll
实例。
epoll
实例内部存储:
- 监听列表:所有要监听的文件描述符,使用红黑树
- 就绪列表:所有就绪的文件描述符,使用链表
epoll_ctl
1 | int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event); |
epoll_ctl
会监听文件描述符 fd
上发生的 event
事件。
参数说明:
epfd
即epoll_create
返回的文件描述符,指向一个epoll
实例fd
表示要监听的目标文件描述符event
表示要监听的事件(可读、可写、发送错误…)op
表示要对fd
执行的操作,有以下几种:EPOLL_CTL_ADD
:为fd
添加一个监听事件event
EPOLL_CTL_MOD
:Change the event event associated with the target file descriptor fd(event
是一个结构体变量,这相当于变量event
本身没变,但是更改了其内部字段的值)EPOLL_CTL_DEL
:删除fd
的所有监听事件,这种情况下event
参数没用
返回值 0 或 -1,表示上述操作成功与否。
epoll_ctl
会将文件描述符 fd
添加到 epoll
实例的监听列表里,同时为 fd
设置一个回调函数,并监听事件 event
。当 fd
上发生相应事件时,会调用回调函数,将 fd
添加到 epoll
实例的就绪队列上。
epoll_wait
1 | int epoll_wait(int epfd, struct epoll_event *events, |
这是 epoll 模型的主要函数,功能相当于 select
。
参数说明:
epfd
即epoll_create
返回的文件描述符,指向一个epoll
实例events
是一个数组,保存就绪状态的文件描述符,其空间由调用者负责申请maxevents
指定events
的大小timeout
类似于select
中的 timeout。如果没有文件描述符就绪,即就绪队列为空,则epoll_wait
会阻塞 timeout 毫秒。如果 timeout 设为 -1,则epoll_wait
会一直阻塞,直到有文件描述符就绪;如果 timeout 设为 0,则epoll_wait
会立即返回
返回值表示 events
中存储的就绪描述符个数,最大不超过 maxevents
。
边缘触发和水平触发
epoll 支持两种事件触发模式,分别是边缘触发(edge-triggered,ET)和水平触发(level-triggered,LT)。
这两个术语还挺抽象的,其实它们的区别还是很好理解的。
- 使用边缘触发模式时,当被监控的 Socket 描述符上有可读事件发生时,服务器端只会从 epoll_wait 中苏醒一次,即使进程没有调用 read 函数从内核读取数据,也依然只苏醒一次,因此我们程序要保证一次性将内核缓冲区的数据读取完;
- 使用水平触发模式时,当被监控的 Socket 上有可读事件发生时,服务器端不断地从 epoll_wait 中苏醒,直到内核缓冲区数据被 read 函数读完才结束,目的是告诉我们有数据需要读取;
举个例子,你的快递被放到了一个快递箱里,如果快递箱只会通过短信通知你一次,即使你一直没有去取,它也不会再发送第二条短信提醒你,这个方式就是边缘触发;如果快递箱发现你的快递没有被取出,它就会不停地发短信通知你,直到你取出了快递,它才消停,这个就是水平触发的方式。
这就是两者的区别,水平触发的意思是只要满足事件的条件,比如内核中有数据需要读,就一直不断地把这个事件传递给用户;而边缘触发的意思是只有第一次满足条件的时候才触发,之后就不会再传递同样的事件了。
如果使用水平触发模式,当内核通知文件描述符可读写时,接下来还可以继续去检测它的状态,看它是否依然可读或可写。所以在收到通知后,没必要一次执行尽可能多的读写操作。
如果使用边缘触发模式,I/O 事件发生时只会通知一次,而且我们不知道到底能读写多少数据,所以在收到通知后应尽可能地读写数据,以免错失读写的机会。因此,我们会循环从文件描述符读写数据,那么如果文件描述符是阻塞的,没有数据可读写时,进程会阻塞在读写函数那里,程序就没办法继续往下执行。所以,边缘触发模式一般和非阻塞 I/O 搭配使用,程序会一直执行 I/O 操作,直到系统调用(如 read
和 write
)返回错误,错误类型为 EAGAIN
或 EWOULDBLOCK
。
一般来说,边缘触发的效率比水平触发的效率要高,因为边缘触发可以减少 epoll_wait 的系统调用次数,系统调用也是有一定的开销的的,毕竟也存在上下文的切换。
select/poll 只有水平触发模式,epoll 默认的触发模式是水平触发,但是可以根据应用场景设置为边缘触发模式。
另外,使用 I/O 多路复用时,最好搭配非阻塞 I/O 一起使用,Linux 手册关于 select 的内容中有如下说明:
Under Linux, select() may report a socket file descriptor as “ready for reading”, while nevertheless a subsequent read blocks. This could for example happen when data has arrived but upon examination has wrong checksum and is discarded. There may be other circumstances in which a file descriptor is spuriously reported as ready. Thus it may be safer to use O_NONBLOCK on sockets that should not block.
我谷歌翻译的结果:
在Linux下,select() 可能会将一个 socket 文件描述符报告为 “准备读取”,而后续的读取块却没有。例如,当数据已经到达,但经检查后发现有错误的校验和而被丢弃时,就会发生这种情况。也有可能在其他情况下,文件描述符被错误地报告为就绪。因此,在不应该阻塞的 socket 上使用 O_NONBLOCK 可能更安全。
简单点理解,就是多路复用 API 返回的事件并不一定可读写的,如果使用阻塞 I/O, 那么在调用 read/write 时则会发生程序阻塞,因此最好搭配非阻塞 I/O,以便应对极少数的特殊情况。
为什么边缘触发必须使用非阻塞 I/O?
关于这个问题的解答,强烈建议阅读这篇文章。下面是一些关键摘要:
- 每次通过
read
系统调用读取数据时,最多只能读取缓冲区大小的字节数;如果某个文件描述符一次性收到的数据超过了缓冲区的大小,那么需要对其read
多次才能全部读取完毕 select
可以使用阻塞 I/O 通过select
获取到所有可读的文件描述符后,遍历每个文件描述符,read
一次数据- 这些文件描述符都是可读的,因此即使
read
是阻塞 I/O,也一定可以读到数据,不会一直阻塞下去 select
采用水平触发模式,因此如果第一次read
没有读取完全部数据,那么下次调用select
时依然会返回这个文件描述符,可以再次read
select
也可以使用非阻塞 I/O。当遍历某个可读文件描述符时,使用for
循环调用read
多次,直到读取完所有数据为止(返回EWOULDBLOCK
)。这样做会多一次read
调用,但可以减少调用select
的次数
- 这些文件描述符都是可读的,因此即使
- 在
epoll
的边缘触发模式下,只会在文件描述符的可读/可写状态发生切换时,才会收到操作系统的通知- 因此,如果使用
epoll
的边缘触发模式,在收到通知时,必须使用非阻塞 I/O,并且必须循环调用read
或write
多次,直到返回EWOULDBLOCK
为止,然后再调用epoll_wait
等待操作系统的下一次通知 - 如果没有一次性读/写完所有数据,那么在操作系统看来这个文件描述符的状态没有发生改变,将不会再发起通知,调用
epoll_wait
会使得该文件描述符一直等待下去,服务端也会一直等待客户端的响应,业务流程无法走完 - 这样做的好处是每次调用
epoll_wait
都是有效的——保证数据全部读写完毕了,等待下次通知。在水平触发模式下,如果调用epoll_wait
时数据没有读/写完毕,会直接返回,再次通知。因此边缘触发能显著减少事件被触发的次数 - 为什么
epoll
的边缘触发模式不能使用阻塞 I/O?很显然,边缘触发模式需要循环读/写一个文件描述符的所有数据。如果使用阻塞 I/O,那么一定会在最后一次调用(没有数据可读/写)时阻塞,导致无法正常结束
- 因此,如果使用
三者对比
select
:调用开销大(需要复制集合);集合大小有限制;需要遍历整个集合找到就绪的描述符poll
:poll 采用数组的方式存储文件描述符,没有最大存储数量的限制,其他方面和 select 没有区别epoll
:调用开销小(不需要复制);集合大小无限制;采用回调机制,不需要遍历整个集合
select
、poll
都是在用户态维护文件描述符集合,因此每次需要将完整集合传给内核;epoll
由操作系统在内核中维护文件描述符集合,因此只需要在创建的时候传入文件描述符。
此外 select
只支持水平触发,epoll
支持边缘触发。
总结
select 和 poll 并没有本质区别,它们内部都是使用「线性结构」来存储进程关注的 Socket 集合。
epoll 是解决 C10K 问题的利器,通过两个方面解决了 select/poll 的问题。
- epoll 在内核里使用「红黑树」来关注进程所有待检测的 Socket,红黑树是个高效的数据结构,增删改一般时间复杂度是 O(logn),通过对这棵黑红树的管理,不需要像 select/poll 在每次操作时都传入整个 Socket 集合,减少了内核和用户空间大量的数据拷贝和内存分配。
- epoll 使用事件驱动的机制,内核里维护了一个「链表」来记录就绪事件,只将有事件发生的 Socket 集合传递给应用程序,不需要像 select/poll 那样轮询扫描整个集合(包含有和无事件的 Socket ),大大提高了检测的效率。
而且,epoll 支持边缘触发和水平触发的方式,而 select/poll 只支持水平触发,一般而言,边缘触发的方式会比水平触发的效率高。
I/O 多路复用在一定程度上成就了 Redis,这是我的理解,没有 I/O多路复用,Redis 可能就没这么快了,也许吧。
说说面试,前两天有三次面试,这让我觉得机会又来了,这也过了两三天了,都还没挂,也是好消息了,这三场面试也是让我学到了不少的东西,之前一直闷着头学习总是觉得没有方向,东一榔头西一棒槌,面试找到了不足,最近这几天也都在去补,所以还没怎么开始学新的内容,不过明天应该就差不多了,不管是学习还是实习又或者是秋招,都祝我好运。
参考
https://imageslr.com/2020/02/27/select-poll-epoll.html
https://eklitzke.org/blocking-io-nonblocking-io-and-epoll
https://www.xiaolincoding.com/os/8_network_system/selete_poll_epoll.html