在上一篇博文中提到了五种 IO 模型, 关于这五种 IO 模型可以参考博文 IO 模型浅析 - 阻塞, 非阻塞, IO 复用, 信号驱动, 异步 IO, 同步 IO, 本篇主要介绍 IO 多路复用的使用和编程.
IO 多路复用的概念
多路复用是一种机制, 可以用来监听多种描述符, 如果其中任意一个描述符处于就绪的状态, 就会返回消息给对应的进程通知其采取下一步的操作.
当进程需要等待多个描述符的时候, 通常情况下进程会开启多个线程, 每个线程等待一个描述符就绪, 但是多路复用可以同时监听多个描述符, 进程中无需开启线程, 减少系统开销, 在这种情况下多路复用的性能要比使用多线程的性能要好很多.
相关 API 介绍
在 linux 中, 关于多路复用的使用, 有三种不同的 API,select,poll 和 epoll
Select 介绍
select 的使用需要引入 sys/select.h 头文件, API 函数比较简单, 函数原型如下:
- int select (int __nfds, fd_set *__restrict __readfds,
- fd_set *__restrict __writefds,
- fd_set *__restrict __exceptfds,
- struct timeval *__restrict __timeout);
- fd_set
其中有一个很重要的结构体 fd_set, 该结构体可以看作是一个描述符的集合, 可以将 fa_set 看作是一个位图, 类似于操作系统中的位图, 其中每个整数的每一 bit 代表一个描述符,.
举个简单的例子, fd_set 中元素的个数为 2, 初始化都为 0, 则 fd_set 中含有两个整数 0, 假设一个整数的长度 8 位 (为了好举例子), 则展开 fd_set 的结构就是 00000000 0000000, 如果这个时候添加一个描述符为 3, 则对应 fd_set 编程 00000000 00001000, 可以看到在这种情况下, 第一个整数标记描述符 0~7, 第二个整数标记 8~15, 依次类推.
fd_set 有四个关联的 api
- void FD_ZERO(fd_set *fdset) // 清空 fdset, 将所有 bit 置为 0
- void FD_SET(int fd, fd_set *fdset) // 将 fd 对应的 bit 置为 1
- void FD_CLR(int fd, fd_set *fdset) // 将 fd 对应的 bit 置为 0
- void FD_ISSET(int fd, fd_set *fdset) // 判断 fd 对应的 bit 是否为 1, 也就是 fd 是否就绪
select 函数中存在三个 fd_set 集合, 分别代表三种事件,__readfds 表示读描述符集合,__writefds 表示读描述符集合,__exceptfds 表示读描述符集合, 当对应的 fd_set = NULL 时, 表示不监听该类描述符.
__nfds
__nfds 是 fd_set 中最大的描述符 + 1, 当调用 select 的时候, 内核态会判断 fd_set 中描述符是否就绪,__nfds 告诉内核最多判断到哪一个描述符.
- timeval
- struct timeval {
- long tv_sec; // 秒
- long tv_usec; // 微秒
- }
参数__timeout 指定 select 的工作方式:
__timeout= NULL, 表示 select 永远等待下去, 直到其中至少存在一个描述符就绪
__timeout 结构体中秒或者微妙是一个大于 0 的整数, 表示 select 等待一段固定的事件, 若该短时间内未有描述符就绪则返回
__timeout= 0, 表示不等待, 直接返回
函数返回
select 函数返回产生事件的描述符的数量, 如果为 - 1 表示产生错误
值得注意的是, 比如用户态要监听描述符 1 和 3 的读事件, 则将 readset 对应 bit 置为 1, 当调用 select 函数之后, 若只有 1 描述符就绪, 则 readset 对应 bit 为 1, 但是描述符 3 对应的位置为 0, 这就需要注意, 每次调用 select 的时候, 都需要重新初始化并赋值 readset 结构体, 将需要监听的描述符对应的 bit 置为 1, 而不能直接使用 readset, 因为这个时候 readset 已经被内核改变了.
Poll 介绍
select 中, 每个 fd_set 结构体最多只能标识 1024 个描述符, 在 poll 中去掉了这种限制, 使用 poll 需要引入头文件 sys/poll.h,poll 调用的 API 如下:
- int poll (struct pollfd *__fds, nfds_t __nfds, int __timeout);
- pollfd
- struct pollfd {
- int fd; // poll 的文件描述符
- short int events; // poll 关心的事件类型
- short int revents; // 发生的事件类型
- };
Poll 使用结构体 pollfd 来指定一个需要监听的描述符, 结构体中 fd 为需要监听的文件描述符, events 为需要监听的事件类型, 而 revents 为经过 poll 调用之后返回的事件类型, 在调用 poll 的时候, 一般会传入一个 pollfd 的结构体数组, 数组的元素个数表示监控的描述符个数, 所以 pollfd 相对于 select, 没有最大 1024 个描述符的限制.
事件类型有多种, 在 bits/poll.h 中定义了多种事件类型, 主要如下:
- #define POLLIN 0x001 // 有数据可读
- #define POLLPRI 0x002 // 有紧迫数据可读
- #define POLLOUT 0x004 // 现在写数据不会导致阻塞
- # define POLLRDNORM 0x040 // 有普通数据可读
- # define POLLRDBAND 0x080 // 有优先数据可读
- # define POLLWRNORM 0x100 // 写普通数据不会导致阻塞
- # define POLLWRBAND 0x200 // 写优先数据不会导致阻塞
- #define POLLERR 0x008 // 发生错误
- #define POLLHUP 0x010 // 挂起
- #define POLLNVAL 0x020 // 无效文件描述符
当一个文件描述符要同时监听读写事件时, 可以写成 events = POLLIN | POLLOUT
可以看到, poll 中使用结构体保存一个文件描述符关心的事件, 而在 select 中, 统一使用 fd_set, 一个 fd_set 中可以是所有需要监听读事件的文件描述符, 也可以是所有需要写事件的文件描述符.
相比来说, poll 比 select 更加的灵活, 在调用 poll 之后, 无需像 select 一样需要重新对文件描述符初始化, 因为 poll 返回的事件写在了 pollfd->revents 成员中.
__fds
__fds 的作用同 select 中的__nfds, 表示 pollfd 数组中最大的下标索引
__timeout
__timeout = -1:poll 阻塞直到有事件产生
__timeout = -0:poll 立刻返回
__timeout != -1 && __timeout != 0:poll 阻塞__timeout 对应的时候, 如果超过该时间没有事件产生则返回
函数返回
poll 函数返回产生事件的描述符的数量, 如果返回 0 表示超时, 如果为 - 1 表示产生错误
Epoll 介绍
epoll 中, 使用一个描述符来管理多个文件描述符, 使用 epoll 需要引入头文件 sys/epoll.h,epoll 相关的 api 函数如下:
- int epoll_create (int __size);
- 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_event
- typedef union epoll_data {
- void *ptr; // 可以用改指针指向自定义的参数
- int fd; // 可以用改成员指向 epoll 所监控的文件描述符
- uint32_t u32;
- uint64_t u64;
- } epoll_data_t;
- struct epoll_event {
- uint32_t events; // epoll 事件
- epoll_data_t data; // 用户数据
- } __EPOLL_PACKED;
epoll_event 结构体中, 首先是一个 events 的整型变量, 类似于 pollfd->events, 表示要监控的事件, events 支持的事件类型在 sys/epoll.h 的头文件中, 跟 pollfd 中的事件类型基本移植, 如下, 这里只写出一部分:
- enum EPOLL_EVENTS {
- EPOLLIN = 0x001,
- #define EPOLLIN EPOLLIN // 有数据可读
- EPOLLPRI = 0x002,
- #define EPOLLPRI EPOLLPRI // 有紧迫数据可读
- EPOLLOUT = 0x004,
- #define EPOLLOUT EPOLLOUT // 现在写数据不会导致阻塞
- EPOLLRDNORM = 0x040,
- #define EPOLLRDNORM EPOLLRDNORM // 有普通数据可读
- EPOLLRDBAND = 0x080,
- #define EPOLLRDBAND EPOLLRDBAND // 有优先数据可读
- EPOLLWRNORM = 0x100,
- #define EPOLLWRNORM EPOLLWRNORM // 写普通数据不会导致阻塞
- EPOLLWRBAND = 0x200,
- #define EPOLLWRBAND EPOLLWRBAND // 写优先数据不会导致阻塞
- ...
- EPOLLERR = 0x008,
- #define EPOLLERR EPOLLERR // 发生错误
- EPOLLHUP = 0x010,
- #define EPOLLHUP EPOLLHUP // 挂起
- EPOLLRDHUP = 0x2000,
- ...
- };
epoll_event 中的 data 指向一个共用体结构, 可以用该共用体保存自定义的参数, 或者指向被监控的文件描述符.
- epoll_create
- int epoll_create (int __size);
epoll_create 函数创建一个 epoll 实例并返回, 该实例可以用于监控__size 个文件描述符
- epoll_ctl
- int epoll_ctl (int __epfd, int __op, int __fd, struct epoll_event *__event);
该函数用来向 epoll 中注册事件函数, 其中__epfd 为 epoll_create 返回的 epoll 实例,__op 表示要进行的操作,__fd 为要进行监控的文件描述符,__event 要监控的事件.
__op 可用的类型定义在 sys/epoll.h 头文件中, 如下:
- #define EPOLL_CTL_ADD 1 // 添加文件描述符
- #define EPOLL_CTL_DEL 2 // 删除文件描述符
- #define EPOLL_CTL_MOD 3 // 修改文件描述符 (指的是 epoll_ctl 中传入的__event)
该函数如果调用成功返回 0, 否则返回 - 1.
- epoll_wait
- int epoll_wait (int __epfd, struct epoll_event *__events, int __maxevents, int __timeout);
epoll_wait 类似与 select 中的 select 函数, poll 中的 poll 函数, 等待内核返回监听描述符的事件产生, 其中__epfd 是 epoll_create 创建的 epoll 实例,__events 数组为 epoll_wait 要返回的已经产生的事件集合, 其中第 i 个元素成员的__events[i]->data->fd 表示产生该事件的描述符,__maxevents 为希望返回的最大的事件数量 (通常为__events 的大小),__timeout 和 poll 中的__timeout 相同. 该函数返回已经就绪的事件的数量, 如果为 - 1 表示出错.
select,poll,epoll 比较
select 和 poll 的机制基本相同, 只不过 poll 没有 select 最大文件描述符的限制, 在具体使用的时候, 有如下缺点:
每次调用 select 或者 poll, 都需要将监听的 fd_set 或者 pollfd 发送给内核态, 如果需要监听大量的文件描述符, 这样的效率是很低下的
在内核态中, 每次需要对传入的文件描述符进行轮询, 查询是否有对应的事件产生.
epoll 的高效在于将这些分开, 首先 epoll 不是在每次调用 epoll_wait 的时候, 将描述符传送给内核, 而是在 epoll_ctl 的时候传送描述符给内核, 当调用 epoll_wait 的收, 不用每次都接收
不像 select 和 poll 使用一个单独的 API 函数, 在 epoll 中, 使用 epoll_create 创建一个 epoll 实例, 然后当调用 epoll_ctl 新增监听描述符的时候, 这个时候才将用户态的描述符发送到内核态, 因为 epoll_wait 调用的频率肯定要比 epoll_create 的频率要高, 所以当 epoll_wait 的时候无需传送任何描述符到用户态;
关于第二点, 在内核态中, 使用一个描述符就绪的链表, 当描述符就绪的时候, 在内核态中会使用回调函数, 该函数会将对应的描述符添加入就绪链表中, 那么当 epoll_wait 调用的时候, 就不需要遍历所有的描述符查看是否有就绪的事件, 而是直接查看链表是否为空.
总结
可以使用一个生活中的场景来对三者的区别做个总结, 仍然接着笔者的上一篇博文 IO 模型浅析 - 阻塞, 非阻塞, IO 复用, 信号驱动, 异步 IO, 同步 IO 中吃饭的例子:
在这个例子中, 服务员和餐厅代表内核, 客户 "你" 就是用户态进程, 可能觉得这个例子写的不好, 在这里写下加深记忆.
select 和 poll: 你去餐厅请客吃饭, 你是个豪爽的人, 点了很多菜, 你告诉服务员对应种类的菜有多少上多少, 服务员将菜名一一写在纸上. 然后你开始问服务员饭菜有好了么, 服务员看着你的菜单一大串, 头皮发麻, 于是按着菜单的顺序去厨房查看饭菜有没有好, 如果菜没有好就划掉菜单中对应的菜, 终于找出了所有已经烧好的饭菜, 服务员把饭菜端给了你. 可是这个时候菜单上只能看到已经准备好的菜了, 没准备好的菜看不清了, 你觉得这个服务员做事很傻逼, 没办法将就点, 谁让你性格好呢, 于是你重新写了一份菜单 (可能这个过程中你又想点一些新的菜或者删除一些菜). 接下来你又去问饭菜好没好, 服务员又开始按照菜单的顺序去厨房查看饭菜有没有好...(select 和 poll 的主要区别就在于, select 中的菜单是有限的, 而 poll 中的菜单是无限的, 你可以点任意种类的菜)
epoll: 你去餐厅请客吃饭, 你是个豪爽的人, 点了很多菜, 你告诉服务员对应种类的菜有多少上多少, 服务员将菜名一一录入到餐厅后台的菜单管理软件中, 厨房的师傅烧好一道菜在管理软件中标记完成一下, 然后在烧好的菜上挂上对应的桌号放在取菜区, 这个时候你来问服务员饭菜有准备好的么, 服务员于是查一下管理软件, 有标记欸, 于是从取菜区取出对应桌号的饭菜送给你, 清空标记. 过了段时间, 你又想点一道新的菜, 于是叫来服务员, 服务员在菜单软件中添加一栏. 接下来你又去问饭菜好没好, 服务员又开始看菜单软件中是否有标记完成的信息...
另外关于 epoll 的高效还有很多细节, 例如使用 mmap 将用户空间和内核空间的地址映射到同一块物理内存地址, 使用红黑树存储要监听的事件等等, 具体的细节可以参考博文 select,poll,epoll 之间的区别总结整理, 高并发网络编程之 epoll 详解, Linux 下的 I/O 复用与 epoll 详解, 彻底学会使用 epoll(一)--ET 模式实现分析等几篇文章.
来源: https://www.cnblogs.com/yearsj/p/9647135.html