I/O多路复用(select/poll/epoll)


简单介绍

这三种模式是为了在一个线程下面尽可能多的管理很多个socket。这三种模式在不同的需求下,有着各自的优势,select和poll差不多,但并不一定是说epoll是最好的。比如我们就写个简单的几个client连接下的情况,这个时候用select就很香,代码更少一点,如果是在真实的生产环境下,肯定是用epoll好。

select

select管理多个socket的fd(文件描述符,通过这个可以找到socket),在这期间select会监听所有的socket。如果没有一个socket有事件发生,这个线程会让出cpu的阻塞等待,即让该线程可以去做别的事情。如果使用普通的conncet()、accept()、recv()或recvfrom()这种函数,如果没有事件发生,就必须要阻塞,直到事件发生。

如果有事件发生,select会在他的睡眠队列里设置一个entry,当 socket 接收到网卡的数据后,就会去它的睡眠队列里遍历 entry,调用 entry 设置的 callback 方法,这个 callback 方法里就能唤醒 select !

select函数原型:

#include <sys/select.h>
struct timeval {
    time_t      tv_sec;         /* seconds */
    suseconds_t tv_usec;        /* microseconds */
};

int select(int nfds, fd_set *readfds, fd_set *writefds,
           fd_set *exceptfds, struct timeval * timeout);

 参数介绍:

  • nfds:委托给内核处理的后面三个fd_set的最大值+1,也是遍历的时候的最大值。+ readfds:传入内核处理文件描述符的读集合,内核只需要检测这些文件描述符的读缓冲区。

  • 传入传出参数

  • writefds:传入内核处理文件描述符的读集合,内核只需要检测这些文件描述符的写缓冲区。

  • 传入传出参数

  • exceptfds:文件描述符的集合,内核检测集合中文件描述符是否有异常状态

  • 传入传出参数

  • timeout:设置超时的时间,可以强制操作select()函数的阻塞状态。

  • NULL:没有事件就会一直阻塞。+ 秒数:等待时间。+ 0:不等待。

返回值:

  • -1:函数调用失败了+ 0:监听的所有的socket_fd里面没有发生事件的socket_fd。+ >0:返回已经就绪的socket_fd。

与fd_set配合的操作函数:

// 将文件描述符fd从set中删除,即把set中fd对应的标志位置0
void FD_CLR(int fd, fd_set* set);
// 将文件描述符fd添加到set中,即把set中fd对应的标志位置1
void FD_SET(int fd, fd_set* set);
// 判断文件描述符fd是否在set中,即判断文件描述符fd在set中的标志位是否为1
void FD_ISSET(int fd, fd_set* set);
// 清空set,即全部置0,用于初始化set
void FD_ZERO(fd_set* set);

注:fd_set 是一个128个字节的数组(位图Bitmap),128*8=1024 bit,在操作fd_set的时候,实际上操作的是每一位bit,置0还是置1,与内核中的文件描述符表的每一位都对应。

  • 0代表不检测这个文件描述符的状态(读缓冲区,写缓冲区等)+ 1代表检测这个文件描述符的状态(读缓冲区,写缓冲区等)

从这里可以看出select的缺点:

  • 具有O(n)的无差别轮询复杂度,需要一直遍历1024位fd_set的各个位的状态+ 每次select都需要将fd_set拷贝到内核空间,开销比较大+ 需要轮询fd_set,消耗时间多+ 能够检测的最大文件描述符是1024个,这个在内核写死了

poll

相对于【select】,它不使用【Bitmap】来保存已经连接的文件描述符,使用的是链表来管理,没有了1024的限制,当然还会受到系统文件描述符限制。

poll的函数原型:

#include <poll.h>
// 每个委托poll检测的fd都对应这样一个结构体
struct pollfd {
    int   fd;         /* 委托内核检测的文件描述符 */
    short events;     /* 委托内核检测文件描述符的什么事件 */
    short revents;    /* 文件描述符实际发生的事件 -> 传出 */
};

struct pollfd myfd[100];
int poll(struct pollfd *fds, nfds_t nfds, int timeout);

函数参数介绍:

  • fds: 这是一个 struct pollfd 类型的数组,里边存储了待检测的文件描述符的信息,这个数组中有三个成员:

  • fd:委托内核检测的文件描述符+ events:委托内核检测的 fd 事件(输入、输出、错误),每一个事件有多个取值+ revents:这是一个传出参数,数据由内核写入,存储内核检测之后的结果

  • 读事件:POLLIN+ 写事件:POLLOUT+ 错误事件:POLLERR

  • nfds: 这是第一个参数数组中最后一个有效元素的下标 + 1(也可以指定参数 1 数组的元素总个数)+ timeout: 指定 poll 函数的阻塞时长

  • -1:一直阻塞,直到检测的集合中有就绪的文件描述符(有事件产生)解除阻塞+ 0:不阻塞,不管检测集合中有没有已就绪的文件描述符,函数马上返回+ 大于 0:阻塞指定的毫秒(ms)数之后,解除阻塞

函数返回值:

  • 失败: 返回 - 1+ 成功:返回一个大于 0 的整数,表示检测的集合中已就绪的文件描述符的总个数

操作函数:

  • 只需要poll函数:revent中包含了这个文件描述符的事件

从上面可以总结出poll的特点:

  • 对比select,使用起来更加方便(将相关的文件描述符封装到了pollfd中),但是不能跨平台,只能在Linux平台

  • events参数对应select中的readfds、writefds、exceptfds的传入状态+ revents参数对应select中的readfds、writefds、exceptfds的传出状态

  • 与select相差不大,需要的时间复杂度一样

epoll

epoll的操作函数:

#include <sys/epoll.h>
// 创建epoll实例,通过一棵红黑树管理待检测集合
int epoll_create(int size);
int epoll_create1(int flags);//flag可以是::EPOLL_CLOEXEC
// 管理红黑树上的文件描述符(添加、修改、删除)
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
// 检测epoll树中是否有就绪的文件描述符
int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);

在select/poll中低效的原因之一是它们将“添加/修改任务列表”与“等待任务列表”放在一起处理。在大部分情况下,需要监听的socket比较确定,这个时候检测去任务列表是多余的。epoll将两个步骤分开,epoll_ctl用来管理任务列表,并且使用的是红黑树结构,这种结构有利于增加、删除或修改,然后用epoll_wait检测任务列表(文件描述符)是否有事件发生,这样实现了解耦,效率大大提高。

epoll_create(int size):

  • 参数:一般指定为大于0的数就可以+ 返回值:

  • 失败:-1,创建错误+ 成功:一个文件描述符,通过这个就可以访问epoll实例

  • 用法:int epfd = epoll_create(100);

epoll_create1(int flags):

  • 参数:flags 可以设置为0 或者EPOLL_CLOEXEC,为0时函数表现与epoll_create一致,设置为EPOLL_CLOEXEC标志与open 时的O_CLOEXEC 标志类似,即进程被替换时会关闭文件描述符。+ 返回值:与epoll_create类似

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

  • 参数:

  • epfd是epoll_crate的返回值,操作epoll实例+ op是一个枚举值,控制函数执行什么类型的操作

  • EPOLL_CTL_ADD:添加一个fd+ EPOLL_CTL_DEL:删除一个fd+ EPOLL_CTL_MOD:修改一个fd

  • fd是文件描述符,即要添加 / 修改 / 删除的文件描述符+ event是epoll 事件,用来修饰第三个参数对应的文件描述符的,指定检测这个文件描述符的什么事件

  • events:委托 epoll 检测的事件

  • EPOLLIN:读事件,接收数据,检测读缓冲区,如果有数据该文件描述符就绪+ EPOLLOUT:写事件,发送数据,检测写缓冲区,如果可写该文件描述符就绪+ EPOLLERR:异常事件

  • data:用户数据变量,这是一个联合体类型,通常情况下使用里边的 fd 成员,用于存储待检测的文件描述符的值,在调用 epoll_wait() 函数的时候这个值会被传出。

看一下epoll_ctl的epoll_event结构体:

// 联合体, 多个变量共用同一块内存        
typedef union epoll_data {
 	void        *ptr;
	int          fd;	// 通常情况下使用这个成员, 和epoll_ctl的第三个参数相同即可
	uint32_t     u32;
	uint64_t     u64;
} epoll_data_t;

struct epoll_event {
	uint32_t     events;      /* Epoll events */
	epoll_data_t data;        /* User data variable */
};
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);

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

  • 函数参数:

  • epfd:epoll_create () 函数的返回值,通过这个参数找到 epoll 实例+ events:传出参数,这是一个结构体数组的地址,里边存储了已就绪的文件描述符的信息+ maxevents:修饰第二个参数,结构体数组的容量(元素个数)+ timeout:如果检测的 epoll 实例中没有已就绪的文件描述符,该函数阻塞的时长,单位 ms 毫秒

  • 0:函数不阻塞,不管 epoll 实例中有没有就绪的文件描述符,函数被调用后都直接返回+ 大于 0:如果 epoll 实例中没有已就绪的文件描述符,函数阻塞对应的毫秒数再返回+ -1:函数一直阻塞,直到 epoll 实例中有已就绪的文件描述符之后才解除阻塞

  • 函数返回值:

  • 成功:

  • 等于 0:函数是阻塞被强制解除了,没有检测到满足条件的文件描述符+ 大于 0:检测到的已就绪的文件描述符的总个数

  • 失败:返回 - 1

epoll的两种模式 LT 和 ET

二者的差异在于 level-trigger 模式下只要某个socket处于 readable/writable 状态,无论什么时候进行 epoll_wait 都会返回该 socket;而 edge-trigger 模式下只有某个 socket 从 unreadable 变为 readable 或从unwritable 变为 writable 时,epoll_wait 才会返回该 socket。

所以, 在epoll的ET模式下, 正确的读写方式为:

读: 只要可读, 就一直读, 直到返回0, 或者 errno = EAGAIN

写: 只要可写, 就一直写, 直到数据发送完, 或者 errno = EAGAIN

Wikipedia:https://en.wikipedia.org/wiki/Epoll

Man手册:epoll(7) - Linux manual page

Epoll详细解释系列:如果这篇文章说不清epoll的本质,那就过来掐死我吧! (1) - 知乎

如果这篇文章说不清epoll的本质,那就过来掐死我吧! (2) - 知乎

如果这篇文章说不清epoll的本质,那就过来掐死我吧! (2) - 知乎

IO多路转接(复用)之epoll | 爱编程的大丙


文章作者: AllenMirac
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 AllenMirac !
  目录