0%

【IO】epoll 入门

[TOC]

概述

网页逐渐替代了纸质媒体,成为了人们获取信息的主要渠道,每时每刻都有许多人在通过网页获取每日的最新资讯,从网页的角度出发,虽然连接的数量可能非常多,但并非每路连接都时时在与服务器交互信息,换言之,对某个网页的服务器来说,多路连接中活跃用户的数量可能远远小于连接的总数。

假如使用select或poll模型搭建此种类型的服务器,对服务器而言,大部分的时间都浪费在了毫无意义的轮询中,真正处理请求的时间反而少之又少。(连接数太多了,每次轮序,有数据的连接可能就1-2个

Linux系统中通常使用epoll模型搭建这种活跃连接较少的服务器,相比select/poll的主动查询,epoll模型采用基于事件的通知方式,事先为建立连接的文件描述符注册事件,一旦该文件描述符就绪,内核会采用类似callback的回调机制,将文件描述符加入到epoll的指定的文件描述符集中,之后进程再根据该集合中文件描述符的数量,对客户端请求逐一进行处理。

虽然epoll机制中返回的同样是就绪文件描述符的数量,但epoll中的文件描述符集只存储了就绪的文件描述符,服务器进程无需再扫描所有已连接的文件描述符;且epoll机制使用内存映射机制(类似共享内存),不必再将内核中的文件描述符集复制到内存空间;此外,epoll机制不受进程可打开最大文件描述符数量的限制(只与系统内存有关),可连接远超过默认FD_SETSIZE的进程。

linux系统中提供了几个与实现epoll机制相关的系统调用——epoll_create()、epoll_ctl()和epoll_wait(),下面将对这些系统调用逐一进行讲解。

系统调用

1
2
3
4
5
6
// epoll 的 API 非常简洁,涉及到的只有 3 个系统调用:

#include <sys/epoll.h>
int epoll_create(int size); // int epoll_create1(int flags);
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);

其中,epoll_create 创建一个 epoll 实例并返回 epollfd;

epoll_ctl 注册 file descriptor 等待的 I/O 事件(比如 EPOLLIN、EPOLLOUT 等) 到 epoll 实例上;

epoll_wait 则是阻塞监听 epoll 实例上所有的 file descriptor 的 I/O 事件,它接收一个用户空间上的一块内存地址 (events 数组),kernel 会在有 I/O 事件发生的时候把文件描述符列表复制到这块内存地址上,然后 epoll_wait 解除阻塞并返回,最后用户空间上的程序就可以对相应的 fd 进行读写了

epoll_create

epoll_create()函数用于创建一个epoll句柄,并请求内核为该实例后期需存储的文件描述符及对应事件预先分配存储空间,该函数存在于函数库sys/poll.h中,其声明如下:

1
int epoll_create(int size);

函数中的参数size为在该epoll中可监听的文件描述符的最大个数,若该函数调用成功,将返回一个用于引用epoll的句柄;若调用失败,则返回-1,并设置errno。

当所有与该epoll相关的文件描述符都关闭后,内核会销毁epoll实例并释放相关资源,但若该函数返回的epoll句柄不再被使用,用户应主动调用close()函数将其关闭。

epoll_ctl

epoll_ctl()是epoll的事件注册函数,用于将文件描述符添加到epoll的文件描述符集中,或从集合中删除指定文件描述符。该函数存在于函数库sys/poll.h中,其声明如下:

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

epoll_ctl()函数中的参数epfd为函数epoll_create()返回的epoll句柄;参数op表示epoll_ctl()的动作,该动作的取值由三个宏指定,这些宏及其含义分别如下:

● EPOLL_CTL_ADD表示epoll_ctl()将会在epfd中为新fd注册事件;

● EPOLL_CTL_MOD表示epoll_ctl()将会修改已注册的fd监听事件;

● EPOLL_CTL_DEL表示epoll_ctl()将会删除epfd中监听的fd。

epoll_ctl()函数的参数fd用于指定待操作的文件描述符;参数event用于设定要监听的事件,该参数是一个struct epoll_event类型的指针,用于传入一个struct epoll_event结构体类型的数组,该结构体的类型定义如下:

1
2
3
4
struct epoll_event {
__uint32_t events; //epoll事件
epoll_data_t data; //用户数据变量
};

struct epoll_event结构体中的成员events表示要监控的事件,该成员可以是由一些单一事件组成的位集,这些单一事件由一组宏表示,宏及其含义分别如下:

● EPOLLIN表示监控文件描述符fd的读事件(包括socket正常关闭);

● EPOLLOUT表示监控fd的写事件;

● EPOLLPRI表示监控fd的紧急可读事件(有优先数据到达时触发);

● EPOLLERR表示监控fd的错误事件;

● EPOLLHUP表示监控fd的挂断事件;

● EPOLLET表示将epoll设置为边缘触发(Edge Triggered)模式;

● EPOLLONESHOT表示只监听一次事件,当此次事件监听完成后,若要再次监听该fd,需将其再次添加到epoll队列中。

struct epoll_event结构体成员data的数据类型是共用体epoll_data_t,其类型定义如下:

1
2
3
4
5
6
typedef union epoll_data {
void *ptr;
int fd;
__uint32_t u32;
__uint64_t u64;
} epoll_data_t;

可根据程序需要选择不同的成员,后续的案例中将以fd为例进行示范。

若epoll_ctl()函数调用成功时会返回0;若调用失败,则返回-1,并设置errno。不同于select/poll机制在监听事件时才确定事件的类型,epoll机制在连接建立后便会指定要监控的事件。

epoll_wait

epoll_wait()函数用于等待epoll句柄epfd中所监控事件的发生,当有一个或多个事件发生或等待超时后epoll_wait()返回,该函数存在于函数库sys/epoll.h中,其声明如下:

1
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
  • 参数epfd为epoll_create()函数返回的句柄;
  • 参数events指向发生epoll_create()调用时系统事先预备的空间,当有监听的事件发生时,内核会将该事件复制到此段空间中;
  • 参数maxevents表示events的大小,该值不能超过调用epoll_create()时所传参数size的大小;
  • 参数timeout的单位为毫秒,用于设置epoll_wait()的工作方式:若设置为0则立即返回,设置为-1则使epoll无限期等待,设置为大于0的值表示epoll等待一定的时长。

若epoll_wait()函数调用成功时返回就绪文件描述符的数量;若等待超时后并无就绪文件描述符则返回0;若调用失败则返回-1,并设置errno。

epoll 的原理

在实现上 epoll 采用红黑树来存储所有监听的 fd,而红黑树本身插入和删除性能比较稳定,时间复杂度 O(logN)。通过 epoll_ctl 函数添加进来的 fd 都会被放在红黑树的某个节点内,所以,重复添加是没有用的。当把 fd 添加进来的时候时候会完成关键的一步:该 fd 会与相应的设备(网卡)驱动程序建立回调关系,也就是在内核中断处理程序为它注册一个回调函数,在 fd 相应的事件触发(中断)之后(设备就绪了),内核就会调用这个回调函数,该回调函数在内核中被称为: ep_poll_callback ,这个回调函数其实就是把这个 fd 添加到 rdllist 这个双向链表(就绪链表)中。epoll_wait 实际上就是去检查 rdllist 双向链表中是否有就绪的 fd,当 rdllist 为空(无就绪 fd)时挂起当前进程,直到 rdllist 非空时进程才被唤醒并返回。

相比于 select&poll 调用时会将全部监听的 fd 从用户态空间拷贝至内核态空间并线性扫描一遍找出就绪的 fd 再返回到用户态,epoll_wait 则是直接返回已就绪 fd,因此 epoll 的 I/O 性能不会像 select&poll 那样随着监听的 fd 数量增加而出现线性衰减,是一个非常高效的 I/O 事件驱动技术。

由于使用 epoll 的 I/O 多路复用需要用户进程自己负责 I/O 读写,从用户进程的角度看,读写过程是阻塞的,所以 select&poll&epoll 本质上都是同步 I/O 模型,而像 Windows 的 IOCP 这一类的异步 I/O,只需要在调用 WSARecv 或 WSASend 方法读写数据的时候把用户空间的内存 buffer 提交给 kernel,kernel 负责数据在用户空间和内核空间拷贝,完成之后就会通知用户进程,整个过程不需要用户进程参与,所以是真正的异步 I/O。

epoll 在 Linux kernel 里并没有使用 mmap 来做用户空间和内核空间的内存共享,所以那些说 epoll 使用了 mmap 的文章都是误解。

epoll 的工作模式

epoll有两种工作模式,分别为边缘触发(Edge Triggered)模式和水平触发(LevelTriggered)模式。

所谓边缘触发,指只有当文件描述符就绪时会触发通知,即便此次通知后系统执行I/O操作只读取了部分数据,文件描述符中仍有数据剩余,也不会再有通知递达,直到该文件描述符从当前的就绪态变为非就绪态,再由非就绪态再次变为就绪态,才会触发第二次通知;此外,接收缓冲区大小为5字节,也就是说ET模式下若只进行一次I/O操作,每次只能接收到5字节的数据。因此系统在收到就绪通知后,应尽量多次地执行I/O操作,直到无法再读出数据为止。

而水平触发与边缘触发有所不同,即便就绪通知已发送,内核仍会多次检测文件描述符状态,只要文件描述符为就绪态,内核就会继续发送通知。

epoll的工作模式在调用注册函数epoll_ctl()时确定,由该函数中参数event的成员events指定,默认情况下epoll的工作模式为水平触发,若要将其设置为边缘触发模式,需使用宏EPOLLET对event进行设置,具体示例如下。

1
event.events=EPOLLIN|EPOLLET;

之后需在循环中不断调用,保证将文件描述符中的数据全部读出。

epoll_s.c 中的epoll便工作在水平模式下,为帮助读者理解,下面给出具体案例,来展示epoll在边缘触发模式下如何实现双端通信。ET模式只能工作在非阻塞模式下,否则单纯使用epoll(单进程)将无法同时处理多个文件描述符,因此在实现案例之前,需先掌握设置文件描述符状态的方法,Linux系统中可使用fcntl()函数来设置文件描述符的属性。

工作模式

这两种模式的实现原理:当我们调用epoll_wait的时候,会把就绪链表拷贝到用户态,然后就会清空链表。最后epoll_wait会检查这些返回的socket,如果是LT模式,如果socket还有未被处理的数据,那么会把这个socket继续添加到就绪列表,而ET则没有这个过程。

fcntl

fcntl()函数是Linux中的一个系统调用,其功能为获取或修改已打开文件的性质,该函数存在于函数库fcntl.h中,其声明如下:

1
int fcntl(int fd, int cmd, ... /* arg */ );

其中参数fd为被操作的文件描述符,cmd为操作fd的命令(具体取值可参见Linux的manpage),之后的arg用来接收命令cmd所需使用的参数,该值可为空。

若要通过fcntl()设置文件描述符状态,通常先使用该函数获取fd的当前状态,再对获取的值进行位操作,最后调用fcntl()将操作的结果重新写回文件描述符。如下为修改文件描述符阻塞状态的方法:

1
2
3
flag = fcntl(fd, F_GETFL);   //宏F_GETEL表示获取文件描述符相关属性
flag |= O_NONBLOCK;
fcntl(fd, F_SETFL, flag); //使用新属性设置文件描述符

函数执行失败,将返回-1并设置errno全局变量来指明错误。

fcntl 常见指令

1
2
3
4
5
F_DUPFD     //复制文件描述符,跟dup()函数功能一样
F_GETFD //获取文件描述符标志
F_SETFD //设置文件描述符标志
F_GETFL //获取文件状态
F_SETFL //设置文件状态

使用fcntl对文件加锁

当fcntl中的cmd为F_GETLK,F_SETLK,F_SELFKW时为对文件进行锁操作,此时arg参数为flock。注意:使用fcntl对文件加锁,加锁效果类似于自旋锁,只有写写互斥和读写互斥,读读并不互斥。

epoll 配合多进程或线程池

多进程/多线程可单独使用,也可与I/O多路转接服务器结合,通过转接机制监控客户端程序状态,通过多进程/多线程处理用户请求,以期减少资源消耗,提升服务器效率。

然而大多网络端服务器都有一个特点,即单位时间内需处理的连接请求数目虽然巨大,但处理时间却是极短的,如此,若使用多进程/多线程机制结合I/O多路转接机制搭建的服务器,便需在每时每刻不停地创建、销毁进程或线程,虽说相对进程,线程消耗的资源已相当少,但诸多线程同时创建和销毁,其开销仍是不可忽视的。而Linux系统中的线程池机制便能客服这些问题。

所谓线程池(Thread Pool),简单来说,就是一个用来放置线程的“池子”。线程池的实现原理如下:当服务器程序启动后,预先在其中创建一定数量的线程,并将这些线程依次加入队列中。在没有客户端请求抵达时,线程队列中的线程都处于阻塞状态,此时这些线程只占用一些内存,但不占用cpu。若随后有用户请求到达,由线程池从线程队列中选出一个空闲线程,并将用户请求传给选出的线程,由该线程完成用户请求。用户请求处理完毕,该线程并不退出,而是再次被加入线程队列,等待下一次任务。此外,若线程队列中处于阻塞状态的线程较多,为节约资源,线程池会自动销毁一部分线程;若线程队列中所有线程都有任务执行,线程池会自动创建一定数量的新线程,以提高服务器效率。

参考

epoll