linux网络IO

无论是select poll epoll都属于NIO。他们本身不负责读写数据,它们只是负责同志哪些fd已经准备就绪。

BIO的核心是一个线程管理一个socket,NIO的核心是一个线程管理多个socket。

虽然是non-blocking IO。但其实底层都是要进行同步阻塞的。BIO的核心是线程等待IO,NIO的核心是线程等待事件。

selectpollepoll 本质上都是 Linux/Unix 提供的系统调用(system call)接口,而在 C 语言中表现为函数名称

Go语言中BIO的底层实现通常是Epoll,java的BIO底层实现就不是。G语言更像把netty藏进了语言运行时,对上层暴露的接口仍然是BIO风格的。

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是linux正统切新一代的异步I/O标准,但它并非第一个Linux AIO实现。

Linux 内核很早就有一套名为 libaio(或传统 Linux AIO)的异步 I/O 接口,但它因为设计上的诸多限制,一直没能成为通用、高效的解决方案,甚至连 Linus Torvalds 本人都曾严厉批评过它“是一个糟糕的临时的设计”

特性对比传统 Linux AIO (libaio)io_uring
设计哲学设计受限,并非真正的通用异步 I/O设计之初就为真正的异步,提交即返回,永不阻塞
使用场景几乎只支持 O_DIRECT 模式的存储文件,无法使用页缓存,不支持网络 I/O统一支持:支持缓存文件、直接 I/O、网络 sockets(如 send, recv),甚至 openat, accept 等系统调用
性能与开销每次提交和获取完成事件都需要系统调用,有上下文切换开销通过共享内存环形队列(SQ/CQ)通信,可实现零系统调用进行提交和收割,支持批量处理
扩展性扩展性差,功能基本固化革命性的灵活和可扩展,甚至被视为可以“重新实现所有系统调用”的基础框架

Io_uring

io_uring设计时就是linux特有的 异步I/O接口。用它编写的程序,其核心部分是无法直接移植到windows平台上运行的。

在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利用率大幅提升。等于是我告诉内核应该进行什么操作,而不是内核将数据拷贝给我,我自己做这个操作。

java相关的实现

java中的selector在linux上底层就对应了epoll操作。

Selector selector = Selector.open();底层可能变成epoll_create(...)

这里把所有的channel注册到一个selector。当这个selector查询到有事件发生的时候,

Java的netty中,4.1.x之前,需要netty-incubator-transport-io_uring,4.2版本已经内置了io_uring。

但是如果想使用io_uring的特性,仍然需要进行特定编码,比如判断io_uring是否可用,然后进行对应的操作。

Selector 只负责事件通知(event notification),Channel 才负责实际的数据传输(read/write)。

远端机器
    ↓
TCP协议栈
    ↓
操作系统内核Socket缓冲区
    ↓
SocketChannel.read()
    ↓
ByteBuffer

在netty中bossgroup负责监听ServerSocketChannel。把新连接交给woker。一个链接在被建立之后永远只属于一个worker。

Boss线程的任务在TCP连接建立后就结束了。不会负责后续IO。当TCP accept成功后,就会把channel注册给workerGroup。

每个worker都有自己的

Selector
TaskQueue
线程

当注册完成后,后续的读写都贵worker。

阻塞非阻塞:线程状态

同步异步:结果获取的方式

阻塞非阻塞是当发起一个io请求时,线程是否需要停下来等待。没有数据的时候线程被挂起,就是阻塞。非阻塞就是每次都看一下有没有数据,有就读取,没有就返回不会进行等待。

同步异步关注的是IO完成后,谁来通知结果。

同步阻塞:自己发起,自己等待,自己获取结果。

同步非阻塞:

while(true){
    recv()
}

一直问。

异步阻塞:异步阻塞并没有意义,异步要达成的效果就是可以继续干其他的事情,但是阻塞有导致当前线程停住,有的优势也没了。

异步非阻塞:真正的异步IO,执行后立刻返回,等待内核来回调函数。

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