进行高性能的服务端编程, 所必不可少的就是—IO多路复用技术. 多路复用到底是在干什么? 我们简单的来说: 就是OS同时监控多个连接, 当有事件发生的时候, 进行通知 此即为IO多路复用.需要结合IO模型进行理解
我接下来们介绍几种常见的IO复用机制
首先需要明确的是IO复用进行事件通知的两种机制: 水平触发和边缘触发
- 水平触发: 若文件描述符上可以非阻塞的进行IO操作, 则认为它已经就绪
- 边缘触发: 当文件描述符上的状态发生改变的时候,自上次之后发生了新的IO活动, 认为已经就绪
我们提到IO多路复用, 还要明确, 一定要配合Nonblock IO进行使用.
- 非阻塞IO一般配合ET通知机制一起使用
- 因为多路复用是检查多个文件描述符, 若阻塞在一个文件描述符上, 从而阻止检查其他的文件描述符
- 即使写就绪, 如果写入大块数据还是会阻塞
基于以上3点理由, 我们使用IO多路复用, 必须配合Non-block IO
下面是三种常见的IO复用模型:
select
select(2)是BSD风格的IO复用模型.现在也是SUSV3标准中支持的IO多路复用接口
#include <sys/select.h>
/* According to earlier standards */
#include <sys/time.h>
#include <sys/types.h>
#include <unistd.h>
int select(int nfds, fd_set *readfds, fd_set *writefds,
fd_set *exceptfds, struct timeval *timeout);
void FD_CLR(int fd, fd_set *set);
int FD_ISSET(int fd, fd_set *set);
void FD_SET(int fd, fd_set *set);
void FD_ZERO(fd_set *set);
以上便是select(2)的接口使用API. 使用select(2)会一直阻塞, 直到文件描述符集合成为就绪态, 或者超时.
nfds: 设置为三个集合中最大集合数目 +1readfds: 用来检测输入是否就绪的文件描述符集合writefds: 用来检测输出是否就绪的文件描述符集合exceptfds: 用来检测异常情况是否发生的文件描述符集合
所有的文件描述符集合使用掩码实现, 具体是FD_xxx系列宏.
FD_ZERO: 将集合初始化为空FD_SET: 将fd ,添加进入集合FD_CLR: 将fd, 从集合中移除FD_ISSET: 判断fd, 是否在集合中
而每个集合有最大数目限制, FDSIZE, Linux上为FDSIZE = 1024, 是硬编码
我们使用select(2)的流程是: 设置FD_SET结构并初始化 -> 将fd添加如指定集合中 -> 设置超时时间 -> select返回 -> 检查集合并操作
select的返回是这样子的:
-1表示发生错误, 我们根据errno进行错误判定0表示在任何文件描述符就绪前已超时, 任何集合都会被清空- 正整数,表示就绪文件描述符总数. 切记: 会重复统计不同集合中的同一文件描述符
据此, 在select正常返回后, 我们应该这样处理:
int ready = ::select(nfds, &readfds, &writefds, &exceptfds, timeout);
if (ready == -1) { // error
::exit(EXIT_FAILURE);
} else if (ready == 0) { // timeout
FD_ZERO(&readfds);
FD_ZERO(&writefds);
FD_ZERO(&exceptfds);
} else { // fds ready
for (int i = 0; i < nfds; ++i) {
if (FD_ISSET(i, &readfds)) {
// deal with readable event
} else (FD_ISSET(i, &writefds)) {
// deal with writeable event
}
}
}
总结一下, select调用具有这样的特点:
- 使用三个专门的事件集合进行关注
- 使用宏进行置位, 集合是掩码操作, 所以其占用空间小
- 检测的fd有上限, 一般是:
FDSIZE = 1024 - 返回时, 只有就绪事件个数, 必须遍历每个集合, 使用指定宏
FD_ISSET进行判断是否就绪
System V版本的IO多路复用, 比此有一定程度上的性能提升, 即poll
poll
#include <poll.h>
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
struct pollfd {
int fd; // 文件描述符
short events; // 感兴趣的事件集合
short revents; // 返回的事件集合
};
fds:poll对于文件描述符的集合nfds: 关注的文件描述符总数timeout: 超时时间
至于poll感兴趣的事件集合, 可以详细查看man page timeout超时设定情况, poll和select行为一致;
poll的返回值:
-1: 表示发生了错误, 有可能是EINTR, 表示被信号中断0: 表示在任意一个描述符就绪前, 就超时了- 正整数: 表示就绪文件描述符数目, 不同于
select,poll不会重复统计
那么, 我们使用poll的实例是这样的:
struct pollfd *pollFd;
pollFd = calloc(num, sizeof(struct pollfd));
for (int i = 0; i < num; ++i) {
pollFd[i].fd = xx;
pollFd[i].events = POLLIN | xxx | xxx;
}
int ready = ::poll(pollFd, num, -1);
if (ready == -1) {
exit(EXIT_FRAILURE);
}
for (int i = 0; i < num; ++i) {
if (pollFd[i].revents & POLLIN)
// deal with readable event
else if(pollFd[i].revents & POLLOUT)
// deal with writeable event
....
}
那么, 我们总结一下poll的使用特点:
- 优化数据结构, 使用起来比
select明了 - 不会重复统计就绪文件描述符数目
- 仍然需要根据数目进行轮询判断
- 没有上限限制
- 可以只关注部分文件描述符
总结比较select与poll:
- 内核层面, 使用了相同的内核
poll例程集合, 基本同poll,select的实现是将poll事件转为select事件 poll没有1024的文件描述符上限限制select用同一集合, 多次调用的时候, 需要多次FD_CLR,FD_SET.poll则是用不同的字段避免select的超时精度高于poll
在性能上:
- 当文件描述符较少的时候,
select,poll都能获得不错的性能 - 当文件描述符较多, 且分布的密集时, 性能都还行
- 当分布的分散时,
poll性能远高于select, 因为poll只需要检查感兴趣的文件描述符即可
select和poll的性能瓶颈:
- 每次必须检查所有文件描述符, 耗费大量时间
select,poll每次都将感兴趣的事件集合拷贝进入内核, 随着感兴趣事件列表的扩增, 拷贝上的时间开销, 内存开销越来越大- 每次调用结束后, 必须检查返回的数据结构中的每个元素, 以此判断是否处于就绪态
实际上是因为select和epoll作为老式API的历史遗留问题, 如今我们可以放心的使用Linux kernel 2.6之后增加的epoll系列编程接口, 进行大量且高效的文件描述符管理
epoll
epoll作为目前Linux服务端编程的中流砥柱, 它具有这样的优点:
同select和poll相比:
- 大量文件描述符需要进行关注的时候, 基本不会损失太多性能
- 即支持水平触发, 也支持边缘触发
同Signal-driven IO相比:
- 避免了信号处理的复杂性, 多线程环境下使用无障碍
- 灵活性高, 可以指定我们感兴趣的事件类型
epoll的编程接口如下:
#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);
int epoll_pwait(int epfd, struct epoll_event *events,
int maxevents, int timeout,
const sigset_t *sigmask);
我们基本上分为三类:
epoll_create: 创建epoll实例epoll_ctl: 修改兴趣列表epoll_wait: 事件返回
epoll_create
使用epoll_create创建一个epoll实例, 这是epoll的核心数据结构. 对于epoll_create(int size),保证size为非负数即可 对于epoll_create1(int flags),设置为EPOLL_CLOEXEC即可
epoll_ctl
epoll_ctl是进行epoll实例感兴趣事件列表的修改的.基于struct epoll_event
struct epoll_event {
uint32_t events;
epoll_data_t data;
}
typedef union epoll_data {
void *ptr;
int fd;
uint32_t u32;
uint64_t u64;
} epoll_data_t;
我们一般怎么用呢?
- 设置感兴趣的事件列表, 使用
|连接 - 对于
data字段, 是共用体, 我们一般设置fd, 或者ptr进行数据保存
epoll_ctl支持三种操作: EPOLL_CTL_ADD, EPOLL_CTL_MOD, EPOLL_CTL_DEL分别对应着, 将event添加, 修改, 删除于epoll实例中
epoll的事件列表, 和poll是类似的. 我们着重解释下面几个事件:
EPOLLIN: 关注读事件EPOLLOUT: 关注写事件EPOLLRDHUP: 在ET模式下, 直接判断对端断开连接, 可以不使用read -> 0判断EPOLLET: 启用ET触发模式EPOLLONESHOT: 关联的文件描述符仅仅触发一次, 之后除非EPOLL_CTL_MOD重新激活epoll实例检查
注意一个关键点: /proc/sys/fs/epoll/max_user_watches定义了用户可注册到epoll实例的总数, 本机1638195
epoll_wait
epoll_wait是我们最最核心的IO复用接口.
epfd: 是epoll实例的文件描述符events: 是返回的事件实例数组, 这是一个值-结果参数maxevents: 上一个参数数组的大小timeout: 超时时间
对于, epoll, 我们有这样的使用实例:
int epfd = epoll_create(3);
struct epoll_event ep_event{};
ep_event.events = EPOLLIN | EPOLLET | EPOLLRDHUP | EPOLLET;
ep_event.data.fd = fd_;
std::vector<epoll_event> ep_vec(100);
int ret = epoll_wait(ep_fd, ep_vec.data(); 100, timeout);
for (auto &var : ep_vec) {
if (var.events & EPOLLIN)
// deal with readable event
if (var.events & EPOLLRDHUP)
// deal with RDHUP
....
}
epoll同select, poll区别
三种IO复用机制各有特点, 我们来分析epoll的独到之处:
select和poll需要内核进行调用中的所有文件描述符的检查,与之相反,epoll会在打开的文件描述符上下文相关联的列表中记录该描述符, 之后一旦就绪, 就在就绪列表中添加一个元素,epoll_wait仅仅是简单的取出这些元素select和poll调用结束后, 会返回传入的文件描述符集合, 我们需要遍历所有文件描述符.其中有的可能并没有事件, 而epoll中用epoll_ctl建立了一个数据结构, 会将监视的文件描述符都记录下来, 之后就在也不需要传递任何文件描述符相关的信息给内核了, 而返回中也只是包含了处于就绪态的描述符
因此可以得出: epoll适合处理多连接, 少活跃的网络需求
epoll核心: ET模式
坊间传闻: ET是epoll的高效模式,真的就是这样吗?, 不尽然
首先, 边缘触发模式, 指的是: 启用此模式的文件描述符,在有新IO活动发生之前, 不会重新通知 这就造成了我们每次处理要, 尽可能的处理完数据, 因为这样, 我们才能避免数据丢失
比如说: 有个文件描述符, 再也不触发事件了, 那么数据第一次没有读完, 就会永久丢失, 再不会触发了. 所以, ET模式, 配合非阻塞套接字使用更佳哟
据此, 我们边缘触发通知的基本框架如下:
- 设置为非阻塞文件描述符
- 通过
epoll_ctl()构建epoll实例感兴趣列表 - 通过如下循环处理I/O事件
- 通过
epoll_wait取得所有处于就绪态的描述符列表 - 针对每一个处于就绪态德文件描述符, 不断进行I/O处理直到相关的系统调用返回
EAGAIN或者EWOULDBLOCK
- 通过
那么, ET真的无敌吗? 并不是. ET极有可能造成文件描述符饥饿现象: 某一个就绪事件上是不间断的输入流 -> 造成其他文件描述符饥饿
解法就是: 在用户层面作更多的时间控制, 避免IO操作无限进行下去. 对于这样的情况, LT更加合适, 没错吧, 因为下一次epoll返回, 依旧会触发通知
之所以说ET高效, 是因为进行事件通知的次数更少, 但是当频繁多次IO操作, 也会造成时间开销. 因为, 要有EAGAIN, 至少要多读一次. chenshuo的编码是: 读一次, 如果ret < size, 则证明下一次一定会阻塞, 可以减少一次系统调用
所以说, LT, ET究竟谁更高效,更多的是要看实际测试情况, 并不能一言以蔽之. 因此, LT也是epoll的默认工作模式, 诸多网络库 也是选择LT模式, 如muduo, libevent, Boost.Asio
下次有机会了, 可以去尝试看看epoll的内核源码.