linux网络IO

selectpollepoll 本质上都是 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_MODEPOLL_CTL_DEL,也是通过这棵红黑树,快速定位到对应的 fd 节点,然后修改或删除。

内核并不是把所有监听的fd都拿出来,把有事件的fd排到前面去。实际上,整个过程是这样的:

  1. 事件发生:当一个fd上有数据到来时(比如网卡收到数据),硬件触发中断。
  2. 执行回调:内核的中断处理程序会调用该设备驱动注册的回调函数。
  3. 查找节点:这个回调函数会以这个fd为键,在你提到的那个红黑树中快速查找,找到对应的那个节点(这个节点里保存了你注册的事件信息和回调函数)。
  4. 排入就绪队列:内核会把这个节点添加到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利用率大幅提升。等于是我告诉内核应该进行什么操作,而不是内核将数据拷贝给我,我自己做这个操作。

Last modification:June 17, 2026
如果觉得我的文章对你有用,请随意赞赏