linux网络IO
select、poll 和 epoll 本质上都是 Linux/Unix 提供的系统调用(system call)接口,而在 C 语言中表现为函数名称。
select
最简单的方式获取网络中传输过来的数据是通过select,不停的轮询是否有数据到来,但是这样子就会不停的询问内核,导致用户态和内核态不停切换造成开销。
一个简单的优化方式是,将自己需要监听的文件描述符通过bitmap直接告诉内核。
sockfd = socket(AF_INET, SOCK_STREAM, 0);
memset(&addr, 0, sizeof(addr));
addr.sin_family = AF_INET;
addr.sin_port = htons(2000);
addr.sin_addr.s_addr = INADDR_ANY;
bind(sockfd, (struct sockaddr *)&addr, sizeof(addr));
listen(sockfd, 5);
for (i = 0; i < 5; i++)
{
memset(&client, 0, sizeof(client));
addrlen = sizeof(client);
fds[i] = accept(sockfd,
(struct sockaddr *)&client,
&addrlen);
if (fds[i] > max)
max = fds[i];
}
while (1)
{
FD_ZERO(&rset);
for (i = 0; i < 5; i++)
{
FD_SET(fds[i], &rset);
}
puts("round again");
select(max + 1, &rset, NULL, NULL, NULL);
for (i = 0; i < 5; i++)
{
if (FD_ISSET(fds[i], &rset))
{
memset(buffer, 0, MAXBUF);
read(fds[i], buffer, MAXBUF);
puts(buffer);
}
}
}我们调用核提供的select方法,内核会接受一个1024个bit的bitmap(rset),不需要监听的被设置为0。
我们让内核来判断是否有数据到来,这个select函数是一个阻塞函数。直到有数据才会继续执行。
当有数据来的时候,内核会将有数据的FD置位,select函数会返回。这里的fd+1是最大需要的文件描述符编号。比如我最大需要的FD是9,那么内核会将0-9这10个文件描述符拿出来。
当有数据的时候,可能存在同时多个FD都有数据。因此下面还是要遍历每个fd是否有数据。用户程序需要完整遍历一次bitmap才知道哪些FD是有数据的。
这里提高效率主要的一点是把判断放到了内核态。但是这里的缺点是bitmap最大是1024,此外rset会被内核修改,每次都要重新开一个新的rset进行设置。
这里用户态和内核态还是要频繁的拷贝这个bitmap,还是有一定开销。而且每次要重新O(n)去遍历bitmap。
select是19世纪的API,因此有很多的缺陷。现在很多系统还在使用select。
POLL
struct pollfd {
int fd;
short events; //在意的事件
short revents; //对event的回馈
};
for (i = 0; i < 5; i++)
{
memset(&client, 0, sizeof(client));
addrlen = sizeof(client);
pollfds[i].fd = accept(sockfd, (struct sockaddr *)&client, &addrlen);
pollfds[i].events = POLLIN;
}
sleep(1);
while (1)
{
puts("round again");
poll(pollfds, 5, 50000);
for (i = 0; i < 5; i++)
{
if (pollfds[i].revents & POLLIN)
{
pollfds[i].revents = 0;
memset(buffer, 0, MAXBUF);
read(pollfds[i].fd, buffer, MAXBUF);
puts(buffer);
}
}
}poll函数的函数参数少了很多。这里pollfds就是pollfd结构体的数组。5是元素的个数,50000是超时时间。
这里工作方式和select类似,同样是将5个数据拷贝到内核态,然后让内核阻塞。
但是这里不是bitmap了,而是pollfd的数组。这里的poll依然是阻塞函数,一个或多个文件描述符有数据的时候,内核会将pollfd进行置位。这里置位的是pollfd的revent字段。然后将poll方法返回。判断的时候我们先判断哪个event是1,把event恢复为0.因此我们可以重用这个pllfdsd。
但是重新遍历,还有内核态的切换问题还是没解决。
EPOLL

struct epoll_event events[5];
int epfd = epoll_create(10);
for (i = 0; i < 5; i++)
{
static struct epoll_event ev;
memset(&client, 0, sizeof(client));
addrlen = sizeof(client);
ev.data.fd = accept(sockfd, (struct sockaddr *)&client, &addrlen);
ev.events = EPOLLIN;
epoll_ctl(epfd, EPOLL_CTL_ADD, ev.data.fd, &ev);
}
while (1)
{
puts("round again");
nfds = epoll_wait(epfd, events, 5, 10000);
for (i = 0; i < nfds; i++)
{
memset(buffer, 0, MAXBUF);
read(events[i].data.fd, buffer, MAXBUF);
puts(buffer);
}
}epfd是用epoll_create创建的参数,用于epoll_wait的时候使用
epoll_ctrl是对epoll_fd进行配置,这里增加了文件描述符和事件,pollfd有fd字段和event字段,但是没有revent字段。这里循环了5次,epfd数组中存放了五个fd和对应的事件。
这里和之前的select与poll不同epfd是在用户态和内核态共享的。内核还是帮我们判断哪个fd有数据到来。因此不需要用户态和内核态的开销。
下面讲水平触发(LT)
没有数据的时候epoll_wait也是阻塞状态的,epoll中也有置位,假设监听到一个fd有数据来了。这时候就会进行重排,将有数据的fd放在最前面的位置。然后进行返回。
这里epoll_wait是有返回值的,返回有多少个fd触发了事件。比如返回3,这时候只需要遍历数组的前3个元素,对前3个元素进行处理就可以。
在边缘触发ET中,ET只通知一次,所以必须一次性把数据全部读完。直到读操作返回EAGIN。
redis,nginx,java中的NIO使用的都是EPOLL。
- 水平触发(LT,默认模式):
只要fd对应的缓冲区还有数据可读/可写,每次epoll_wait返回时,这个fd都会出现在就绪列表中。→ 如果不把数据读完,下次调用
epoll_wait还会继续通知你。
- 边缘触发(ET):
只有状态发生变化时(比如从“无数据”变为“有数据”,或从“不可写”变为“可写”),epoll_wait才会返回这个fd。
红黑树用在 epoll 的内核管理结构里,专门用来存储和管理所有通过 epoll_ctl 添加的 fd。
当你调用 epoll_ctl(epfd, EPOLL_CTL_ADD, fd, event) 时:
- 内核会在 epoll 实例对应的红黑树里插入一个节点,
key就是这个 fd。 红黑树保证了:
- 无重复:同一个 fd 只能添加一次。
- 快速查找:增、删、改的时间复杂度都是 O(log n)。
- 如果你用
EPOLL_CTL_MOD或EPOLL_CTL_DEL,也是通过这棵红黑树,快速定位到对应的 fd 节点,然后修改或删除。
内核并不是把所有监听的fd都拿出来,把有事件的fd排到前面去。实际上,整个过程是这样的:
- 事件发生:当一个fd上有数据到来时(比如网卡收到数据),硬件触发中断。
- 执行回调:内核的中断处理程序会调用该设备驱动注册的回调函数。
- 查找节点:这个回调函数会以这个fd为键,在你提到的那个红黑树中快速查找,找到对应的那个节点(这个节点里保存了你注册的事件信息和回调函数)。
- 排入就绪队列:内核会把这个节点添加到epoll实例的一个专门的双向链表——就绪队列(rdllist)的尾部(tail)。
整个过程就是一个简单的链表尾部追加操作。哪个fd的事件先处理好,它的节点就先被加到链表里,自然就排在前面,没有额外的排序算法。
当 epoll_wait 返回时,它会把就绪队列里的节点从内核态复制到你传入的 events 数组中。这个复制过程,也是从就绪队列的头部(head)开始,按链表顺序依次取出的。
所以,你在 events[0]、events[1]、events[2] 里看到的fd,其实就是内核就绪队列里的自然顺序——谁先准备好,谁就在前面。
AIO
早期的Linux AIO底层还是依赖epoll来完成的,本质上是用多路复用模拟出来的异步。
第二层:为什么“早期的网络AIO”做不到这一点?
Linux内核在很早期(2.6版本)就提供了libaio(POSIX AIO的Linux实现),但它只对磁盘文件(O_DIRECT)支持较好,对网络Socket的支持极差。原因有两点:
Socket的复杂性:网络数据是流式的、分包的,不像磁盘块那样固定,内核很难在用户不参与的情况下完美管理TCP流的边界和缓存。
内核实现偷懒:早期Linux内核的AIO实现,对于Socket,并没有实现真正的内核态异步回调机制。它只是在用户态封装了一个线程池,或者在内核里用epoll去监听Socket,等epoll告诉它有数据了,内核再帮你调用recv把数据读出来。
Io_uring
在epoll模式下,每次项检查事件或者发起读写都要通过epoll_wait,read,write等系统调用陷入内核,这是开销的主要来源。
io_uring在工作开始之前,就在内核和用户程序之间创建了俩个共享的环形缓冲区(ring buffer)
- 提交队列(SQ,Submission Queue):你把要做的I/O操作请求(比如读文件、发网络包)放进这个队列。
- 完成队列(CQ,Completion Queue):内核完成你的请求后,把结果放进这个队列。
这意味着,在理想情况下,你提交任务和获取结果,都可以通过读写共享内存完成,完全不需要调用任何系统调用。只有在需要等待新完成事件时,才可能需要调用一次io_uring_enter,或者干脆开启SQPOLL模式,让内核线程主动轮询,从而将系统调用次数降到极低。
epoll只是告诉你“数据准备好了”,你还是得自己去调用read把数据搬走,这个“搬”的过程是同步阻塞的。
而io_uring从你发起请求到数据就绪,整个过程都是异步的。你提交一个读请求(IORING_OP_RECV或IORING_OP_READ)后,内核会全权负责等待数据和拷贝数据,完成后将结果放到CQ里通知你,你的线程可以去干别的事。
更重要的是,它还支持批量处理。你可以一次提交几十上百个请求,内核也会批量处理并将完成事件放回,这能极大地减少上下文切换次数,让CPU利用率大幅提升。等于是我告诉内核应该进行什么操作,而不是内核将数据拷贝给我,我自己做这个操作。