Linux IO 复用之 epoll 总结

本贴最后更新于 1701 天前,其中的信息可能已经时移俗易

linux IO 复用之 epoll 总结

一、前言

《UNIX 网络编程》里并没有提到 epoll,不知道为啥,以下的内容是根据 linux manual 总结的。

二、API 介绍

epoll 是在 linux 上提供的实现 IO 复用的机制。epoll 与 poll 类似,可以同时监听多个描述符;epoll 新增了边缘触发和水平触发的概念,而且在处理大量描述符时更有优势。
epoll API 中核心概念就是 epoll 实例,它是一个内核内的数据结构,从用户角度来看它可以简单的看做包含了两个 list:

  • interest list(或者叫 epoll set):用户注册的感兴趣的描述符集合
  • ready list:就绪的描述符集合,当有 IO 就绪时内核会自动将就绪的描述符加到 ready list 中

epoll API包含三个系统调用:

epoll_create

int epoll_create(int size);
int epoll_create1(int flags);

epoll_create 创建一个 epoll 实例,函数会返回一个指向 epoll 实例的描述符,在使用完毕后应该调用 close 关闭 epoll 实例。size 参数类似 map 的 capacity,
标识 epoll 实例维护的描述符的数量。

epoll_create1epoll_create 相相似,但参数变成了 flags,size 则被忽略。这里的 flags 有一个可选项:EPOLL_CLOEXECEPOLL_CLOEXEC 表示在创建的描述符上设置 FD_CLOEXEC 标志。

epoll_ctl

int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);

/* Valid opcodes ( "op" parameter ) to issue to epoll_ctl().  */
#define EPOLL_CTL_ADD 1 /* Add a file decriptor to the interface.  */
#define EPOLL_CTL_DEL 2 /* Remove a file decriptor from the interface.  */
#define EPOLL_CTL_MOD 3 /* Change file decriptor epoll_event structure.  */

typedef union epoll_data {
    void *ptr;
    int fd;
    uint32_t u32;
    uint64_t u64;
};

struct epoll_event
{
    uint32_t events;   /* Epoll events */
    epoll_data_t data; /* User data variable */
}

epoll_ctl 将描述符和感兴趣的事件注册到 epoll 实例,这个函数相当于把描述符添加到 epoll 实例的 interest list 中。函数操作成功时返回 0,否则返回 -1 并设置 errno。

epoll_wait

int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);

epoll_wait 会阻塞等待 IO 事件,可以理解为从 ready list 里获取描述符。函数返回就绪描述的个数,并会将就绪的描述符存储到 events 参数中,
通过 timeout 可以设置以毫秒为单位的超时时间,-1 表示永不超时。

三、边缘触发和水平触发

关于边缘触发和水平触发的介绍有很多,这里就翻译一下 man 手册里的内容好了。

epoll 提供两种触发机制:edge-triggered (ET) 和 level-triggered (LT),它们的区别可以通过以下的例子来说明

  • 假设我们已有一个描述符 rfd,我们将从它读取一个 pipe 输出,我们将其注册到 epoll 实例中,感兴趣的事件为可读
  • pipe 的写入端写入了 2KB 的数据到 pipe
  • 进程调用了 epoll_wait,这时 rfd 会被放到 ready list 中然后成功返回
  • pipe 的读取端从 pipe 读取了 1KB 的数据
  • 进程又一次调用 epoll_wait

如果 rfd 在注册到 epoll 实例时使用了 EPOLLET 选项,那么上述第 5 步调用 epoll_wait 可能会发生阻塞,尽管这时读取缓冲区里仍有可读取的数据;而同时 pipe 的另一端可能在等待着相应的响应,于是陷入了无尽的互相等待。而出现这种现象的原因在于 ET 仅在描述符发生变化时才会返回事件。在上面的例子当中,第 2 步会产生一个事件,而第 3 步会消费这个事件。因为第 4 步没有读取完所有的数据,所以第 5 步可能会陷入无限期的阻塞。

而 linux manual 建议的边缘触发的使用方式如下:

  • 配合非阻塞描述符使用
  • 直到每次 read 或者 write 返回 EAGAIN 时才继续等待下一次事件

与边缘触发不同,当使用水平触发选项时,epoll 就相当于 poll 的升级版, 可以简单地替换 poll。

总的来说,ET 和 LT 的区别在于触发事件的条件不同,LT 比较符合编程思维(有满足条件的就触发),ET 触发的条件更苛刻一些(仅在发生变化时才触发),对使用者的要求也更高,理论效率更高。值得一提的是 java nio 的 selector 会根据操作系统不同采用不同的实现,在 linux 2.6 及以后的版本中使用的就是 epoll,采用的是水平触发;而 netty 中提供的额外的 EpollEventLoop 则采用了边缘触发。

在监听描述符事件时,同一个描述符上可能会连续发生多个事件,这是用户可以选择设置 EPOLLONESHOT 选项来通知 epoll 禁用后续的事件。如果设置了 EPOLLONESHOT 选项,在事件处理完毕后用户需要重新注册事件。这个选项在并发环境更加有用。

当多个进程或者线程同时监听一个 epoll 实例上的一个描述符时,使用 EPOLLET 选项可以保证每次事件只会通知一个进程或者线程,避免类似“惊群”的问题。

epoll 监听的限制

/proc/sys/fs/epoll/max_user_watches 中的配置限制了同一个用户在所有 epoll 实例中能监听的描述符的总数。

四、使用边缘触发的例子

因为水平触发和 poll 的使用方式区别不大,这里仅展示边缘触发的示例:

    #define MAX_EVENTS 10
    struct epoll_event ev, events[MAX_EVENTS];
    int listen_sock, conn_sock, nfds, epollfd;

    /* 此处省略调用listen_sock调用socket、bind和listen的过程 */

    //创建epoll实例,程序最后应该调用close关闭epollfd
    epollfd = epoll_create1(0);
    if (epollfd == -1)
    {
        perror("epoll_create1");
        exit(EXIT_FAILURE);
    }

    ev.events = EPOLLIN; //感兴趣的事件为读事件
    ev.data.fd = listen_sock; //注册fd为监听套接字
    
    //注册event
    if (epoll_ctl(epollfd, EPOLL_CTL_ADD, listen_sock, &ev) == -1)
    {
        perror("epoll_ctl: listen_sock");
        exit(EXIT_FAILURE);
    }

    for (;;)
    {
        //等待描述符就绪,参数-1表示不超时
        nfds = epoll_wait(epollfd, events, MAX_EVENTS, -1);
        if (nfds == -1)
        {
            perror("epoll_wait");
            exit(EXIT_FAILURE);
        }

        for (n = 0; n < nfds; ++n)
        {
            if (events[n].data.fd == listen_sock)
            {
                //监听套接字就绪,调用accept建立连接
                conn_sock = accept(listen_sock,
                                  (struct sockaddr *)&addr, &addrlen);
                if (conn_sock == -1)
                {
                    perror("accept");
                    exit(EXIT_FAILURE);
                }
                //设置新连接为非阻塞模式(ET下必须设置非阻塞)
                setnonblocking(conn_sock);
                //感兴趣的事件为读事件,同时设置为边缘触发
                ev.events = EPOLLIN | EPOLLET;
                //注册fd为新建立的连接描述符
                ev.data.fd = conn_sock;
                //注册event
                if (epoll_ctl(epollfd, EPOLL_CTL_ADD, conn_sock,
                              &ev) == -1)
                {
                    perror("epoll_ctl: conn_sock");
                    exit(EXIT_FAILURE);
                }
            } else {//新建立的连接就绪
                //do_use_fd应该对fd进行read或者write直到EAGAIN,然后记录当前的read或write进度,等到下次就绪后再继续
                do_use_fd(events[n].data.fd);
            }
        }
    }

在边缘触发模式下,如果希望在事件到来时不立刻进行操作,而是等其他条件就绪后再进行 read 或 write,这时可以同时注册 EPOLLIN|EPOLLOUT 事件以提高性能,而不是反复调用 epoll_ctl 通过 EPOLL_CTL_MODEPOLLINEPOLLOUT 之间来回切换,如果在水平模式下就不能这样做了,因为感兴趣的事件一旦就绪的事件就会持续发生,带来不必要的消耗。

五、为什么 epoll 比 poll 更快

epoll 的介绍里提到 epoll 比 poll 更快,根据网上的其他博客总结了以下几点原因:

  • 等待描述符就绪时,不需要每次都将描述符集合传递到内核,而是将描述符注册到 epoll 实例,由 epoll 实例内部维护全部的描述符集合
  • epoll 实例内部使用了红黑树和内核 cache 区维护描述符集合,提高了描述符集合注册和删除操作的效率
  • epoll 内部通过回调机制维护 ready list。当有描述符就绪时就将其放到 ready list 中,调用 epoll_wait 时只需要判断 ready list 是否为空即可,如果不为空则将 ready list 复制到用户空间并清空 ready list;否则陷入睡眠
  • 有描述符就绪时不需要重新遍历所有描述符,epoll 会直接返回就绪的描述符集合

这里顺便提一下 LT 的实现,epoll_wait 在返回就绪描述符前会检查描述符的触发类型,如果是水平触发并且描述符上有未处理的数据,则会将其加入刚才清空的 ready list,这样下次调用 epoll_wait 时 ready list 仍会有该描述符。这也是 LT 和 ET 的表现的差别的实际原因。

六、鸣谢

  • 感谢原创作者

七、转载

  • Linux

    Linux 是一套免费使用和自由传播的类 Unix 操作系统,是一个基于 POSIX 和 Unix 的多用户、多任务、支持多线程和多 CPU 的操作系统。它能运行主要的 Unix 工具软件、应用程序和网络协议,并支持 32 位和 64 位硬件。Linux 继承了 Unix 以网络为核心的设计思想,是一个性能稳定的多用户网络操作系统。

    920 引用 • 931 回帖 • 2 关注
  • epoll
    6 引用 • 13 回帖

相关帖子

欢迎来到这里!

我们正在构建一个小众社区,大家在这里相互信任,以平等 • 自由 • 奔放的价值观进行分享交流。最终,希望大家能够找到与自己志同道合的伙伴,共同成长。

注册 关于
请输入回帖内容 ...