深入理解网络 I/O 多路复用:Epoll

在这里插入图片描述

嗨,您好 我是 vnjohn,在互联网企业担任 Java 开发,CSDN 优质创作者
推荐专栏:Spring、MySQL、Nacos、Java,后续其他专栏会持续优化更新迭代
文章所在专栏:网络 I/O
我当前正在学习微服务领域、云原生领域、消息中间件等架构、原理知识
向我询问任何您想要的东西,ID:vnjohn
觉得博主文章写的还 OK,能够帮助到您的,感谢三连支持博客
代词: vnjohn
⚡ 有趣的事实:音乐、跑步、电影、游戏

目录

  • 前言
  • Epoll 函数
    • EPOLL_CREATE
    • EPOLL_CTL
    • EPOLL_WAIT
    • epoll_event 数据结构
    • 边沿触发、水平触发
    • 小结
  • Epoll 内核源码
  • 图解分析
    • epoll VS select/poll 工作原理
    • epoll VS select/poll 日志追踪
    • Epoll 优势之处
  • 总结

前言

Unix/Linux 下可用的 I/O 模型有以下五种:

  1. 阻塞式 I/O
  2. 非阻塞式 I/O
  3. I/O 复用(select、poll)
  4. 信号驱动式 I/O(SIGIO)
  5. 异步 I/O

在 Linux 中操作内核时,所有的无非三种操作,分别是输入、输出、报错输出

0-输入
1-输出
2-报错输出

一个输入操作通常包括两个不同的阶段:

  • 等待数据准备好
  • 从内核向进程复制数据

对于一个套接字(Socket)的输入操作,第一步通常涉及等待数据从网络中;当所等待分组到达时,它被复制到内核中的某个缓冲区,第二步就是把数据从内核缓冲区复制到应用进程缓冲区

Epoll 函数

在 Epoll 多路复用模型中,最主要的是涉及到了三个系统函数指令,分别是:epoll_create、epoll_ctl、epoll_wait

EPOLL_CREATE

借助:man 2 epoll_create 帮助文档来学习该函数

通过 epoll_create、epoll_create1 函数,打开 epoll 文件描述符

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

epoll_create 返回指向新的 Epoll 实例的文件描述符,该文件描述符用于对 epoll 接口的所有后续调用,当不再需要时,将调用 close 函数关闭 epoll_create 返回的文件描述符,当引用 epoll 实例的所有文件描述符都关闭时,内核将销毁该实例并释放相关资源以供资源重用

epoll_create1:若 flags 为 0,除了删除过时的 size 参数之外,epoll_create1 与 epoll_create 是相同的

如果指向新的 epoll 实例描述符成功的话,这些系统调用函数将会返回一个非负数的文件描述符,若出现错误,将返回 -1,并设置 errno 来指示错误.

EPOLL_CTL

借助:man 2 epoll_ctl 帮助文档来学习该函数

通过 epoll_ctl 来承担 epoll 描述符的控制接口

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

这个系统调用对文件描述符 epfd 引用的 epoll 实例执行控制操作,它请求对目前文件描述符 fd 执行 op 操作

op 参数可选值如下:

  1. EPOLL_CTL_ADD:在文件描述符 epfd 引用的 epoll 实例上注册目标文件描述符 fd,并关联事件,内部文件链接到 fd
  2. EPOLL_CTL_MOD:更改与目标文件描符 fd 关联的事件
  3. EPOLL_CTL_DEL:从 epfd 引用的 epoll 实例中删除或注销目标文件描述符 fd,该事件被忽略,并且可以为空

EPOLL_WAIT

借助:man 2 epoll_wait 帮助文档来学习该函数

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

epoll_wait 系统调用等待文件描述符 epfd 引用的 epoll 实例,事件指向的内存区域将包含调用者可用的事件,epoll_wait 最多返回 maxevents 个事件,其参数必须大于 0

timeout 函数指定 epoll_wait 将阻塞的最小毫秒数,若指定 timeout 为 -1 会导致 epoll_wait 无限期阻塞,而指定 timeout 为 0 会导致 epoll_wait 立即返回,即使没有事件可用

在 epoll_wait 调用时,在给定的 timeout 时间内,当在监控的所有 fd 中有事件发生时,就返回用户态的进程

epoll_event 数据结构

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

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

作为 epoll 中重要返回的数据结构,每个返回结构的数据将包含用户使用:epoll_ctl「EPOLL_CTL_ADD、EPOLL_CTL_MOD」设置的相同数据,而 events 成员将包含返回的事件位字段

边沿触发、水平触发

Epoll 提供两种事件接口:边沿触发(ET:edge-triggered)、水平触发(LT:level-triggered)

边沿触发

  • socket 接收缓冲区状态变化时触发读事件,即空的接收缓冲区刚接收到数据时触发读事件
  • socket 发送缓冲区状态变化时触发写事件,即满的缓冲区刚空出空间时触发读事件

水平触发

  • socket 接收缓冲区不为空时,有数据可读,读事件一直触发
  • socket 发送缓冲区不满,可以继续写入数据,写事件一直触发

使用 EPOLLET 标志的应用程序应该使用非阻塞文件描述符,以避免在处理多个文件描述符的任务中出现阻塞读写。建议使用 epoll 作为边缘触发(EPOLLET)接口的方法如下:

  1. 使用非阻塞文件描述符
  2. 在 read 或 write 之后等待事件返回 EAGAIN

例如,两条线分别有数据 ABC、DEF,水平触发的处理顺序:ADBECF,边缘触发的处理顺序:ABCDEF

Nginx、Redis 都使用了 Epoll 多路复用模型

Nginx 使用的是边缘触发 ET、Redis 使用的是水平触发 LT

再举例而言说明两者的区别:你有急事打电话找人,如果对方一直不接,那你只有一直打,直到他接电话为止,这就是 LT 模式;如果不急,电话打过去对方不接,那就等有空再打,这就是 ET 模式

小结

从调用方式可以看出 epoll 对比 select/poll 优越之处,因为 每次调用时都要传递你所要监控的所有 socket 给 select/poll 进行系统调用,这也就意味着需要将用户态的 socket 列表 copy 到内核态,若以万计数的 socket 会导致每次都要 copy 几十或几百 KB 的内存到达内核态,非常的低效,而当我们调用 epoll_wait 时就相当于以往调用 select/poll,但这此时却不用传递 socketfd 给到内核,因为内核通过 epoll_ctl 函数已经拿到了所要监控的 socketfd 列表

实际在你调用 epoll_create 后,内核就已经在开始准备帮你存储要监控的 socket 了,每次调用 epoll_ctl 只是在往内核的数据结构 > 红黑树,塞入新的 socketfd 罢了

Epoll 内核源码

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

/* Set up listening socket, 'listen_sock' (socket(),
   bind(), listen()) */

epollfd = epoll_create(10);
if (epollfd == -1) {
    perror("epoll_create");
    exit(EXIT_FAILURE);
}

ev.events = EPOLLIN;
ev.data.fd = listen_sock;
if (epoll_ctl(epollfd, EPOLL_CTL_ADD, listen_sock, &ev) == -1) {
    perror("epoll_ctl: listen_sock");
    exit(EXIT_FAILURE);
}

for (;;) {
    nfds = epoll_wait(epollfd, events, MAX_EVENTS, -1);
    if (nfds == -1) {
        perror("epoll_pwait");
        exit(EXIT_FAILURE);
    }

    for (n = 0; n < nfds; ++n) {
        if (events[n].data.fd == listen_sock) {
            conn_sock = accept(listen_sock,
                            (struct sockaddr *) &local, &addrlen);
            if (conn_sock == -1) {
                perror("accept");
                exit(EXIT_FAILURE);
            }
            setnonblocking(conn_sock);
            ev.events = EPOLLIN | EPOLLET;
            ev.data.fd = conn_sock;
            if (epoll_ctl(epollfd, EPOLL_CTL_ADD, conn_sock,
                        &ev) == -1) {
                perror("epoll_ctl: conn_sock");
                exit(EXIT_FAILURE);
            }
        } else {
            do_use_fd(events[n].data.fd);
        }
	}
}

socket()、bind()、listen() 是所有的 I/O 模型都必须要经过的操作

  • epollfd=epoll_create(10):创建一个 epoll 文件描述符 > epfd,用于执行后续所有的 epoll 操作
  • 若 epoll_create 函数返回 -1,代表操作失败,失败的原因可能是内核中文件描述符的大小超出了限制
  • 通过 epoll_ctl 函数将新获取的 socket 套接字放入到 epoll 红黑树中,在内核会单独为 epoll 开辟一些数据结构,存放这些 socket 信息
  • EPOLL_CTL_ADD 代表新增 op 操作,成功返回 0,失败则返回 -1
  • 第一个死循环进行 wait 阻塞等待,主要是调用 epoll_wait 函数,类似 select/poll 的操作,从红黑树中复制出来的链表有状态的文件描述符,将拿到的结果存放在 events 数组中

epoll_wait(epollfd, events, MAX_EVENTS, -1):第四个参数的 -1 代表不超时

  • 当拿到有结果的 events 数组以后,对这些有状态的文件描述符进行遍历,若当前文件描述符等于传入的文件描述符,那么则对当前描述符进行 accept 函数调用,将套接字对应的 IP、Port 进行绑定,成功则返回文件描述符,否则返回 -1

此时,代表有新的客户端连接了,需要进行 accept 监听,生成一个新的 socketfd,并且设置为非阻塞运行的方式,调用 epoll_ctl 将新的 socketfd 放入到红黑树中

  • 若当前文件描述符不等于传入的文件描述符,那么就使用已被监听的文件描述符中的数据

通过命令:cat /proc/sys/fs/epoll/max_user_watches,可以查看系统上所有 epoll 实例注册的文件描述符总数的最大限制

在这里插入图片描述

图解分析

其实 Epoll 的模型大致上和 select/poll 模型大致上是一样的,只不过它们额外做的处理工作不一样而已,下面具体来介绍

epoll VS select/poll 工作原理

深入理解网络 I/O 多路复用:Epoll_第1张图片

如上图,所有的 I/O 模型,都会经过三个函数的调用:socket -> bind -> listen,然后 accept 等待客户端建立连接再分配新的 fd 文件描述符!

经历过三个函数调用以后,epoll、select/poll 做以下对比:

  1. 经过通用的函数调用以后,在 Epoll 中会调用 epoll_create 函数生成 fd7,再调用 epoll_ctl 将应用程序与客户端之间新创建的文件描述符放入到内核中维护的红黑树数据结构中,当有客户端有数据 Send-Queue 发送到网卡,然后会到达对应文件描述符 fd4「socket 函数生成的文件描述符」Buffer 缓冲区中,在此时,epoll 对比 select/poll 多做了一下延伸处理工作:将红黑树里有状态的文件描述符 fds 拷贝到链表中,随即在调用 epoll_wait 函数就可以从链表中取出所有有状态(R/W)的 fds
  2. 经过通用的函数调用以后,在 select/poll 中会通过 socket 生成的文件描述符 fd 到内核循环遍历全量的文件描述符以后,返回哪些有状态(R/W)的 fds,当我们在应用程序什么时候调用 select 方法就会触发一次全量的 fds 遍历
  3. 无论是 epoll 还是 select/poll,即使不调用内核,内核也会随着中断的处理机制完成所有 fd 文件描述符的状态设置,而 Epoll 就是在中断处理时多做了一件事情:将红黑树里有状态的 fd 拷贝到了链表中,当应用程序要取来进行处理时,直接取链表里面的 fds 即可(O(1)),而不需要像 select/poll 那样去循环遍历(O(n)

epoll VS select/poll 日志追踪

可以通过 strace 命令来追踪 epoll、select/poll 内核底层的源码是如何处理的,Java 代码与上一篇讲解的 select/poll 多路复用是一致的。

select/poll

运行命令:

strace -ff -o poll java -Djava.nio.channels.spi.SelectorProvider=sun.nio.ch.PollSelectorProvider SelectMultiplexingSocketThread

JVM native 分配了数组来保存 fd 信息,strace 源码追踪:

socket(AF_INET6, SOCK_STREAM, IPPROTO_IP) = 4
# 如 Java 代码:server.configureBlocking(false)
fcntl(4, F_SETFL, O_RDWR|O_NONBLOCK)    = 0

bind(4, {sa_family=AF_INET6, sin6_port=htons(8090), inet_pton(AF_INET6, "::", &sin6_addr), ...) = 0

listen(4, 50)
# 返回 = 1,代表一个 fd 有事件到来
# 返回 = -1,代表非阻塞情况下,没有事件
# 返回 = 0,代表调用超时且没有事件返回
ppoll([{fd=5, events=POLLIN}, {fd=4, events=POLLIN}], 2, NULL, NULL, 0) = 1 ([{fd=4, revents=POLLIN}])
# 新的客户端连接进来,分配的是 fd7
accept(4, {sa_family=AF_INET6, sin6_port=htons(60292), inet_pton(AF_INET6, "::1", ...) = 7
fcntl(7, F_SETFL, O_RDWR|O_NONBLOCK)    = 0

epoll

strace -ff -o epoll java -Djava.nio.channels.spi.SelectorProvider=sun.nio.ch.EPollSelectorProvider SelectMultiplexingSocketThread

运行命令:

socket(AF_UNIX, SOCK_STREAM, 0)         = 4
fcntl(4, F_SETFL, O_RDWR|O_NONBLOCK)    = 0
bind(4, {sa_family=AF_INET6, sin6_port=htons(8090), inet_pton(AF_INET6, "::",...) = 0
listen(4, 50) 
# 创建了 epfd 7
epoll_create1(0)                        = 7
# 将 socket 返回的文件描述符放入红黑树中
epoll_ctl(7, EPOLL_CTL_ADD, 4, {EPOLLIN, {u32=4, u64=545460846596}}) = 0
# 未设置超时时间会一直阻塞,设置了超时时间,在时间内无事件会返回 0
epoll_pwait(7,
# 新的客户端连接进来,分配 fd8
accept(4, {sa_family=AF_INET6, sin6_port=htons(60294), inet_pton(AF_INET6, "::1", &sin6_addr), ...) = 8
fcntl(8, F_SETFL, O_RDWR|O_NONBLOCK)    = 0
# 将新客户端 fd8 天假到红黑树中
epoll_ctl(7, EPOLL_CTL_ADD, 8, {EPOLLIN, {u32=8, u64=545460846600}}) = 0
# 继续循环 epoll_wait
epoll_pwait(7,

在 Java NIO 包下 Selector 通过一套代码在底层实现了 select/poll、epoll 两种 I/O 模型,对应的实现类分别是:sun.nio.ch.PollSelectorProvider、sun.nio.ch.EPollSelectorProvider

Epoll 优势之处

Epoll 高效在于:当我们调用 epoll_ctl 往内核塞入百万个 socket 时,epoll_wait 仍然可以飞快的返回,并会有效的将有发生事件的 socket 给到应用程序;这主要是在调用 epoll_create 时,内核除了在文件系统里建了 epfd,还在内核中建立了一个红黑树结构用于存储以后 epoll_ctl 传来的 socket 以外,还会再建立一个链表,用于存储哪些准备就绪的事件,当 epoll_wait 调用时,只需要仅仅观察这个链表有没有数据即可,有数据就返回,无数据就 sleep 等待 timeout 时间到,所以,epoll_wait 非常快.

每次都是 O(1) 的操作,不会在内核中发生循环遍历寻找的动作,以及也会减少用户态、内核态之间的大额数据交互,减少了资源的浪费及无效时间的行为.

总结

该篇博文主要介绍的就是比较重要比较核心的多路复用模型 Epoll,先简略说明 Epoll 重要的三大函数:epoll_create、epoll_ctl、epoll_wait,在其中说到了 Epoll 事件接口:边沿触发(ET:edge-triggered)、水平触发(LT:level-triggered),提及到了 Epoll 内核中关键的源码部分,使用三大函数巧妙结合起来实现 epoll 高效的多路复用,在底层采用红黑树结构存储所有的 socket fd 信息,采用链表结构存储所有有事件状态的 socket fd 信息,最后图解+日志追踪分析了 Epoll 与 select/poll 之间的区别以及介绍 Epoll 优势之处,希望您能够喜欢,感谢三连支持!

参考文献:

  1. 《UNIX网络编程 卷1:套接字联网API(第3版)》— [美] W. Richard Stevens Bill Fenner Andrew M. Rudoff
  2. Epoll原理 — 千寻
  3. Epoll — dreamgoing

学习帮助文档:

  • man pages:yum install man
  • pthread man pages:yum -y install man-pages

愿你我都能够在寒冬中相互取暖,互相成长,只有不断积累、沉淀自己,后面有机会自然能破冰而行!

博文放在 网络 I/O 专栏里,欢迎订阅,会持续更新!

如果觉得博文不错,关注我 vnjohn,后续会有更多实战、源码、架构干货分享!

推荐专栏:Spring、MySQL,订阅一波不再迷路

大家的「关注❤️ + 点赞 + 收藏⭐」就是我创作的最大动力!谢谢大家的支持,我们下文见!

你可能感兴趣的:(网络,I/O,网络,Epoll)