Linux 网络收包与 IO 多路复用
今天看完了《深入理解 linux 网络》关于网络收包的部分,但是书里对源码读的太过具体,反而搞不懂很多基本步骤是在做什么,所以结合网络资料做一些整理。
同步阻塞IO收包
传统的同步阻塞IO网络通信方式比较容易理解。
客户端实现就是先用系统调用在内核态创建 socket 对象,然后建立tcp连接、阻塞地发送数据、关闭连接:
int main()
{
int fd = socket(); // 创建一个网络通信的socket结构体
connect(fd, ...); // 通过三次握手跟服务器建立TCP连接
send(fd, ...); // 写入数据到TCP连接
close(fd); // 关闭TCP连接
}
服务端同样先创建 socket,绑定和监听端口。此时服务端需要主动调用 accept() 阻塞地建立连接,然后使用 recv() 主动、阻塞地读取发来的数据,然后再关闭这个连接:
int main()
{
fd = socket(...); // 创建一个网络通信的socket结构体
bind(fd, ...); // 绑定通信端口
listen(fd, 128); // 监听通信端口,判断TCP连接是否可以建立
while(1) {
connfd = accept(fd, ...); // 阻塞建立连接
int n = recv(connfd, buf, ...); // 阻塞读数据
doSomeThing(buf); // 利用读到的数据做些什么
close(connfd); // 关闭连接,循环等待下一个连接
}
}
我们说的阻塞
其实就是在进程进行的过程中,因为需要等待某些事件而暂时放弃CPU导致进程运行被阻塞的情况。这里不管是客户端还是服务端,都是同步地依靠阻塞彼此等待这个同步过程的完成。
考虑细节,可以展开成下面这张图:
服务端接收客户端信息主要经历的步骤:
- 服务端调用 socket 系统调用,进入内核态并创建内核对象socket。这个结构体主要包含等待队列和接收队列。等待队列存放服务端的用户进程的描述符和回调函数;接收队列用于存放从网卡收到的该socket要处理的数据。
- 服务端调用 recvfrom() 函数接收数据,进入内核态,如果此时socket的接收队列没有其需要的数据到达,则让出 cpu 进入阻塞状态,同时将自己的描述符和唤醒回调函数放入 socket 的等待队列。
- 客户端的数据到达网卡,网卡将数据通过 DMA 复制到内存的环形缓冲区 RingBuffer 中;
- 网卡向 cpu 发出硬中断;
- cpu 收到硬中断,调用网络驱动注册的中断处理函数,快速处理后向内核中断进程 ksoftirqd 发出软中断,以避免过度占用 cpu 处理来自网络的消息。
- ksoftirqd 软中断进程开始处理剩下的耗时逻辑,根据报文ip和端口号找到对应的 socket,将网卡送到内存的数据复制到这个 socket 的接收队列。
- ksoftirqd 调用 socket 的等待队列中的回调函数,唤醒要处理该数据的用户进程,使其进入 cpu 的运行队列。
- 用户进程获得 cpu,回到之前阻塞的位置继续执行 recvfrom() 函数,进入内核态将内核空间的 socket 等待队列的数据复制到用户空间,然后回到用户空间继续执行,阻塞解除。
研究上面的步骤,可以发现一些比较大的问题:
- 进程在recvfrom()的时候大概率要被阻塞,导致一次上下文切换;
- 数据到达后用户进程由然中断唤醒,又是一次上下文切换;
- 一个进程同时只能监听一个连接,如果要同时处理多个连接,就需要开多个进程。
非阻塞IO收包
操作系统也提供了一种方式避免在 recv() 时阻塞进程,也就是非阻塞IO。我们可以用 socket 系统调用创建一个非阻塞的 socket,这样在 recv() 时如果数据没有就绪,系统调用回直接返回而不是阻塞等待。
用这种方法进行 recv() 后,socket 中仍然会在等待队列中记录用户进程,等数据到达软中断触发后仍然会执行一次上下文切换并使用户进程等待数据从内核空间复制到用户空间,然后才会从系统调用中返回。
因此,之前同步阻塞的两处阻塞变成了一处阻塞,但是仍然存在两次进程切换,单进程也只能处理单个连接。
IO多路复用
IO 多路复用的设计目的是要处理多个 I/O 操作,核心是用一个单独的线程或进程来监视多个 I/O 事件。 linux 主要有三个支持多路复用的系统调用:select
poll
epoll
。
select
定义:
int select (int n, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
- readfds:fd_set 类型是文件描述符的集合,其中一位对应一个文件描述符是否已就绪。如果需要让内核帮忙检测某个IO是否可读,就(用系统函数)把文件描述符加入该集合;
- writefds:内核检测该集合中的IO是否可写,同上;
- exceptfds:内核检测该集合中的IO是否异常,同上;
- n:监控的文件描述符集合里最大的文件描述符+1;
- timeout:用户线程调用 select 的超时时长,设为 NULL 表示一直等待到有 IO 事件发生;其他值表示等待IO事件的固定超时时间。
- 返回值:大于0表示成功,返回集合中已就绪的IO的总个数;-1表示调用失败;0表示没有就绪的IO。
在服务器调用 select 后,如果列表中的连接均没有数据到达,进程会阻塞让出CPU,交由内核去轮询处理列表中的连接,同时会将其进程描述符和被唤醒时用到的函数组成等待队列项加入 socket 的等待队列。此外 select 调用时会将文件描述符集复制到内核空间;如图:
此时当网络收到数据,执行中断程序时,中断程序会将网络数据写到 socket 的数据接收队列,然后唤醒等待队列中的进程。select 调用结束时,要返回的结果文件描述符集会从内核空间复制到用户空间,如图:
select 相比之前提到的方法最大的优点就是可以同时监听多个 IO 事件,也不仅限于网络数据。这样就不需要为每个 IO 操作创建一个独立的进程了。select 也可以设置 timeout 使其成为非阻塞的模式。
select 也有两个明显的缺点:
- 性能开销大,每次调用 select 都需要将 fds 集合从用户态复制到内核态;进程被唤醒后并不知道哪些连接是就绪的,还需要遍历一遍所有 fd_set 的每一位;
- 由于 fd_set 大小有限制,所以能够监听的文件描述符数量很少。(64位是2048)
poll
为了解决第二个缺点,操作系统提供了 poll。不过 poll 的实现原理和 select 基本一致,只是描述 fds 集合的方式不同:
struct pollfd {
int fd; // 要监听的文件描述符
short events; // 要监听的事件类型
short revents; // 返回文件描述符上实际发生的事件
};
这是 poll 的节点,由一个数组来管理它,这样就没有 select 的 bitMap 的大小限制了。此外,对于返回结果,内核只需要修改结构体里的 revents,而不用像 select 那样反复初始化一个新的 rset。
然而,poll 依然没有解决需要在调用和返回时在内核空间和用户空间拷贝 pollfd 的问题。另外,进程被唤醒后仍然需要遍历整个 pollfd 才知道哪些文件描述符有数据可处理。
epoll
epoll 是对 poll 和 select 的改进,主要目的就是要解决 select 那两个缺点,也就是性能开销大和文件描述符少。
IO多路复用本身就是为了高效的监视多个IO流的活动状态,很重要的一个瓶颈就是 poll 和 select 在查找活动流时是用轮询的方法。为此,epoll 引入了事件驱动(软中断直接确定)来找到就绪的文件描述符,不再轮询。此外,epoll 使用红黑树来存储文件描述符,而不用像 select 的 fdset 那样每次重新传入。最后,epoll 只返回就绪的文件描述符,不用再遍历一遍。
先看看基本用法:
int main(void)
{
struct epoll_event events[5];
int epfd = epoll_create(10); // 创建一个 epoll 对象
......
for(i = 0; i < 5; i++)
{
static struct epoll_event ev;
.....
ev.data.fd = accept(sock_fd, (struct sockaddr *)&client_addr, &sin_size);
ev.events = EPOLLIN;
epoll_ctl(epfd, EPOLL_CTL_ADD, ev.data.fd, &ev); // 向 epoll 对象中添加要管理的连接
}
while(1)
{
nfds = epoll_wait(epfd, events, 5, 10000); // 等待其管理的连接上的 IO 事件
for(i=0; i<nfds; i++)
{
......
read(events[i].data.fd, buff, MAXBUF)
}
}
再看看 eventpoll 主要结构:
struct eventpoll {
wait_queue_head_t wq; // 等待队列链表,存放阻塞的进程
struct list_head rdllist; // 数据就绪的文件描述符都会放到这里
struct rb_root rbr; // 红黑树,管理用户进程下添加进来的所有 socket 连接
......
}
其中 wq 是进程的等待队列,存放当前进程描述符和唤醒回调函数;
rdllist 是就绪的描述符的列表,返回的时候用,只储存就绪的连接;
rbr 是一棵红黑树,用来管理用户添加进来的所有 socket 连接。
使用时,首先使用 epoll_create 创建一个 epoll 对象(参数可忽略,返回epoll的fd):
int epoll_create(int size);
创建好 epoll 以后,需要调用 epoll_ctl 函数将监听的连接注册进 epoll:
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
epfd 是刚刚拿到的返回fd;op 是动作,从注册、修改和删除中选择;fd 是要监听的描述符;event 告诉内核要监听哪种类型的事件,如可读可写错误边缘触发等;返回值若为0表示成功,-1表示失败。
epoll_ctl 会创建一个 epitem 对象,包含监听的 fd 和指向 epoll 的指针;将数据到达时用的回调函数添加到 socket 的 等待队列中;将这个 epitem 插入红黑树。
需要注意的是这里加入 socket 等待队列的只有回调函数,因为在 epoll 中,进程是放在 epoll 的等待队列中,由 epoll_wait 函数唤醒,而不是被 socket 在等待队列中直接唤醒的。
最后,应用程序调用 epoll_wait 函数,检查就绪的连接上是否有数据到达:
int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);
这里 events 是用户程序自己建好的 epoll_event 结构体数组,wait 函数会将发生的事件赋值到里面;maxevents 告知内核 events 的数量;timeout 控制超时时间,单位为ms,同样可以设为-1表示无超时。这个函数的返回值是需要处理的返回的 events 的数目,返回0表示已超时,返回-1表示失败。
这个函数调用时,如果就绪队列 rdllist 有数据,有就直接取出返回,没有就把当前进程描述符加入 epoll 的等待队列中,然后阻塞当前进程,等待数据到达时被回调唤醒。
最后就是有数据到达时的流程了:
- 当个 epoll 监控的连接(可以是 socket)有数据到达时,会通过(socket的)进程等待队列的回调函数 ep_poll_callback 唤醒红黑树节点的 epitem;
- ep_poll_callback 函数将有数据到达的 epitem 添加到 epoll 的rdllist 中;
- ep_poll_callback 函数检查 epoll 的等待队列上是否有等待项,通过等待项里的回调函数 default_wake_func 唤醒这个进程进行数据处理;
- 进程醒来后继续从 epoll_wait 处的代码继续执行,把 rdllist 中就绪的事件返回给用户进程,用户进程调用 recv 把已经到达内核 socket 等待队列的数据拷贝到用户空间。
可以看出,epoll 的一大特点就是会在 socket 注册特别的回调函数 ep_poll_callback,可以直接找到对应的 epitem,将其加入到就绪队列中,再唤醒用户进程,而不是直接唤醒用户进程让用户自己去遍历是哪些连接就绪。
另外一些博客也会提到,epoll 支持前两种方式不支持的 边缘触发(ET)模式。默认的水平触发和边缘触发的不同主要在于,当 socket 中的接收缓冲区还有数据可读的时候,epoll_wait 是否会清空 rdllist。
在水平触发模式下,用户进程调用 epoll_wait 后只用系统IO读了一部分数据没读完,再次调用 epoll_wait 时 epoll 会检查这个 socket 缓冲区是否有剩余内容,如果有就把它重新放回 rdllist。这样如果上次 IO 没有处理完,再次调用 epoll_wait 依然可以获得这些 socket。
在边缘触发模式,epoll_wait 会直接清空 rdllist,不管 socket 上是否有数据可读。这样,如果你想要处理剩下的数据,只有等下次这个 socket 有网络数据到达才行了。
总结
为什么 epoll 更高效?
- epoll 只在内核空间用红黑树管理连接,每次调用时不用传入全量的文件描述符集合,避免了海量的文件描述符在用户空间和内核空间中来回复制;
- epoll 只会返回 IO 就绪的 socket,避免了全量返回和在用户空间遍历的开销;
- epoll 通过在 socket 上注册自己的回调函数来通知用户程序 IO 就绪的 socket,避免了一直在内核中轮询的开销;
综上,epoll 是目前各大主流网络框架和反向代理中间件都使用的网络 IO 模型。
epoll 设计要解决的主要问题?
epoll 最初设计出来是为了解决早期的 c10k问题,解决的主要方法是实现多路复用 IO,使一个进程能处理海量的连接,而不需要一个连接对应一个进程。
因此,epoll 要解决的主要问题其实是如何一个进程处理海量连接的问题,而非一上来就解决高性能高并发。高并发一般指 c100k 问题,需要配合线程池和硬件性能提升等进行优化。说 epoll 性能更优,是相对于 select、poll 这种其他的解决一对多问题的方案来说。
常见误区?
- 以上介绍的 IO 模型全都是同步 IO,都会阻塞在数据拷贝阶段,并非不阻塞,和异步IO 或信号驱动 IO 有所区别。这几种同步IO区别只是在等待给你打饭的时候排不排队、怎么排队。
- epoll 里的红黑树优化没有那么重要。红黑树主要是维护文件描述符集在用户进程通过系统调用进行的增删查改,网络包到达的流程用不到红黑树,对性能的影响是次要的。
- epoll 并非直接解决了阻塞-唤醒和其带来的上下文切换问题。IO多路复用主要通过避免一个进程管理一个连接导致的进程资源开销以及由此产生的每个进程都要阻塞导致的上下文切换开销(多路复用后虽然连接数量多,但一次 wait 最多也只阻塞一次,而因为同时监控多个连接,所以这个阻塞发生的概率也小很多,可以理解为几乎不阻塞);epoll 相对于其他多路复用的优势是引入回调机制避免非阻塞时的轮询损耗,以及避免了文件描述符集的来回复制。
对比表格
select | poll | epoll | |
---|---|---|---|
性能 | 随着连接数的增加,性能急剧下降,处理成千上万的并发连接数时,性能很差 | 随着连接数的增加,性能急剧下降,处理成千上万的并发连接数时,性能很差 | 随着连接数的增加,性能基本没有变化 |
连接数 | 一般1024 | 无限制 | 无限制 |
内存拷贝 | 每次调用select拷贝 | 每次调用poll拷贝 | fd首次调用epoll_ctl拷贝,每次调用epoll_wait不拷贝 |
数据结构 | bitmap | 数组 | 红黑树 |
内在处理机制 | 线性轮询 | 线性轮询 | FD挂在红黑树,通过事件回调callback |
时间复杂度(广义) | O(n) | O(n) | O(1) |
一段话总结 epoll 原理
epoll 是 linux 提供的一种高效的 IO 多路复用模型。在实现上,通过红黑树管理要监听的连接,避免了对连接数的限制,同时也避免了文件描述符集在用户空间和内核空间的来回拷贝。最主要的,epoll 通过事件驱动方式避免了对连接的轮询开销。epoll 在 socket 注册了自己的回调函数,数据到达时由回调函数找到对应的红黑树节点,只将对应 socket 加入就绪队列并唤醒用户进程,从而避免用户进程遍历所有文件描述符的开销。
参考
深入学习IO多路复用 select/poll/epoll 实现原理-腾讯云开发者社区-腾讯云 (tencent.com)
IO多路复用——深入浅出理解select、poll、epoll的实现 - 知乎 (zhihu.com)