select、poll、信号驱动、epoll 学习笔记
select
函数&结构
int select(int maxfdpl, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
参数说明
- maxfdpl : 最大文件描述符号+1, 他的值必须设为比
三个文件描述符集中
所包含的最大文件描述符大1
. 实际上是要检查的文件描述符数量, 数组下标从0开始. 有了这个值, 就不用检查所有的描述符. - readfds : 用来检测输入是否就绪的文件描述符集合
- writers : 用来检测输出是否就绪的文件描述符集合
- exceptfds : 用来检测异常情况是否发生的文件描述符集合
- timeval : 控制 select() 的阻塞, 设置为 NULL 的时候, select() 会一直阻塞
timeval结构
// 两个值都为0的话, 此时select()不回阻塞, 会一直轮询.
// 有一个不为0的话, 则会给 select() 设定一个等待时间的上限值
struct timeval{
time_t tv_sec; // 秒
suseconds tv_usec;// 微妙级别的精度
}
fd_set 结构
// 每个 unsigned long 型可以表示多少个bit, 是通过 bitmap 的记录, 一个bit可以记录一位数, 比如8位就可以标记8个fd
#define __NFDBITS (8 * sizeof(unsigned long))
// 默认的 FD_SETSIZE 为 1024, 要修改的话必须修改 glibc 中的头文件定义, 然后重新编译, 但是一般链接数量过多, 使用后面的 epoll 性能更佳
#define __FD_SETSIZE 1024
// 假设一共需要记录 __FD_SETSIZE(默认1024) 个fd, 需要多少个长整型数
#define __FDSET_LONGS (__FD_SETSIZE/__NFDBITS)
typedef struct {
// 使用 long 数组来表示
unsigned long fds_bits [__FDSET_LONGS];
} __kernel_fd_set;
typedef __kernel_fd_set fd_set;
返回值
- -1 : 表示
错误发生
- 0 : 表示在任何文件描述符就绪之前
select() 就已经调用超时
- 正整数 : 表示有一个或多个文件描述符已经达到就绪状态, 返回值表示有多少个fd就绪.如果一个文件描述符在3个集合中都被指定, 则这个返回值会 +3, 也就是会被
计算多次
.
优缺点
优点:
移植性高, 基本上各种主流 os 都支持
缺点:
只支持水平触发
数据需要从用户空间(程序)复制到内核空间
每次将3个数据集的数据发送到内核, 都需要先重置文件描述符集为全 0
fd非常多的时候, 3个描述符集合都需要轮询, 非常消耗CPU
select 返回后, 程序并不知道是哪些 fd 准备就绪, 而只知道一共有多少个就绪了, 需要程序自己对返回的数据结构的每个元素进行检查
poll
函数&结构
// 调用
int poll(struct pollfd fdarray[], nfds_t nfds, int timeout);
// 包含文件描述符的结构
struct pollfd{
int fd; // 自身的描述符号 fd
short events; // 订阅的事件
short revents; // 响应的事件
}
参数说明
调用
- fdarray : pollfd 结构的一个数组
- nfds : 指定 fdarray 的元素数量
- timeout : 表示愿意等待多长的时间
- -1 : 永远等待
- 0 : 不等待
- 大于0 : 等待 timeout(毫秒) 时间
包含 fd 的结构
- fd : 对应 fd 号
- events : 感兴趣/订阅 的事件
- revents : 表示触发了哪些事件, 由内核修改
返回值
- -1 : 失败
- 0 : 超时前没有任何事件发生
- 大于 0 : 指定的描述符准备好或者timeout到期返回.
优缺点
每个fd都有属于自身的 pollfd 结构, 它将 感兴趣事件和触发的事件分成了 events 和 revents
. events 的值告诉内核我们关心的是描述符的哪些事件
; 当某个 fd 有事件触发了之后, 就由 内核修改 revents
的数据, 互不干扰, 所以不必像 select 那样, 每次调用都必须重置 fd 集合.
优点:
不需要每次都初始化参数
数组大小没有限制
缺点:
只支持水平触发
跟 select() 一样需要将数据在用户空间和内核空间来回复制
可移植性较高但没有 select() 高
不适合 fd 数量多的时候, fd 多了性能不如 epoll
对传递过去的感兴趣的 pollfd 结构的数组进行遍历
跟 select() 一样, poll返回后, 程序并不知道是哪些 fd 准备就绪, 而只知道一共有多少个就绪了, 需要程序自己对返回的数据结构的每个元素进行检查
“通常程序调用这些系统调用(select() * poll() )所检查的文件描述符集合都是相同的, 但是内核并不会记录他们.”
后面信号驱动I/O 以及 epoll 都可以使内核记录下进程中感兴趣的文件描述符, 通过这种机制消除了 select() 和 poll() 的性能扩展问题.这种方案是
根据发生的 I/O 事件
来延展,而与被检查的文件描述符数量无关
, 当需要检查大量的文件描述符的时, 信号驱动 I/O 和 epoll 能提供更好的性能表现.
信号驱动I/O
后面的这两种信号驱动I/O 和 epoll 跟前面两种 select 和 poll 有所不同, select 和 poll 无法让内核记住进程所感兴趣的 fd , 所以每次都要将感兴趣的 fd 从用户空间复制到内核空中, 浪费CPU非常消耗CPU时间, 并且每次调用返回都要检查所有的fd, 才能知道是哪些fd触发了事件, 这也是他们不适合大量 fd 操作的原因.
信号驱动I/O, 进程请求内核: 当文件描述符上有课执行的I/O 操作时,向进程发送一个信号.
具体的步骤在后面
步骤
-
1.为内核发送的通知信号安装一个信号处理例程, 默认情况下, 这个通知信号时 SIGIO.
-
2.设定文件描述符的属主(owner) , 也就是当文件描述符上课执行I/O时会接收到通知信号的进程或进程组. 通常设置调用进程为属主. 可通过 fcntl()的 F_SETOWN 操作完成:
fcntl(fd, F_SETOWN, pid);
-
3.设定 O_NONBLOCK 标志使其能变成非阻塞 I/O
-
4.通过打开 O_ASYNC 标志使其能变成信号驱动I/O, 这个和第3步可以合并成一个操作, 因为它们都需要用到 fcntl()的 F_SETFL
flags = fcntl(fd, F_GETFL); fcntl(fd, F_SETFL, flags | O_ASYNC | O_NONBLOCK);
-
5.调用进程执行完这些后就可以执行其它任务了, 接下来如果有相应的 fd 有事件触发, 内核会给进程发送一个信号进行通知, 通过设置的信号例程
-
6.信号驱动I/O提供的是边缘触发通知, 这表示一旦进程被通知I/O就绪, 就应该尽可能多的执行I/O(尽可能的多读取字节), 如果fd是设置的非阻塞, 表示需要在循环中I/O系统调用直到失败位置, 此时的错误码为 EAGAIN 或者 EWOULDBLOCK.
一些特性
在 Linux2.4 或者更早的版本能应用于 套接字、终端、伪终端以及其它特定类型的设备上.Linux2.6 可用于管道和 FIFO.自Linux2.6.25之后, 也能在 inotify 文件描述符上使用.
-
在启动信号驱动I/O前安装信号处理例程 : 由于接收到 SIGIO 信号默认行为是终止进程, so 需要在驱动信号I/O前先为 SIGIO 信号安装处理例程, 如果先启动信号驱动/IO, 则在安装例程之前进程可能先被终止了.
在其它一些UNIX实现上, 信号 SIGIO 的默认行为是被忽略.
何时发送 “I/O就绪” 信号
终端和伪终端
产生新的输入会生成一个信号, 即使之前的输入没有被读取.终端出现文件结尾的情况, 此时也会发送输入就绪的信号(伪终端不会).终端没有输出就绪,断开链接也不会有信号.在Linux 中,2.4.19版本后对伪终端的从设备端提供了“输出就绪”的信号,当伪终端主设备侧读取了输入后就会产生这个信号.
管道和FIFO
管道和FIFO的读端
,信号的产生情况:
- 数据写入到管道中(即使已经有未读取的输入存在)
- 管道的写端关闭
对于管道或FIFO的写端
,信号会在下列情况中产生
- 对管道的读操作增加了管道中的空余空间大小,因此现在可以写入PIPE_BUF个字节而不被阻塞
- 管道的读端关闭
套接字
信号驱动I/O适用于UNIX和Internet下的数据报套接字,信号产生情况:
- 一个输入数据报到达套接字(即使已经有未读取的数据报正等待读取)
- 套接字上发生了异步错误
信号驱动I/O适用于UNIX和Internet下的流式套接字,产生情况:
- 监听套接字接收到新的连接
- TCP connect() 请求完成, 也就是TCP的主动端进入 ESTABLISHED 状态, 对于UNIX 域套接字,类似情况是不会发出信号.
- 套接字上接收到了新的输入(即使已经有未读取的输入存在)
- 套接字对端使用 shutdown() 关闭了写链接(半关闭), 或者通过 close() 完全关闭
- 套接字上输出就绪(例如发送缓冲区有了空间,则会触发写就绪事件)
- 套接字发生了异步错误
inotify 文件描述符
当 notify 文件描述符成为可读状态时会产生一个信号, 也就是由 inotify 文件描述符监视的其中一个文件上有事件发生时.
问题
信号队列溢出的处理
可以排队的实时信号数量是有限的, 当达到了数量限制之后, 通知会恢复为默认的SIGIO信号, 出现这种现象表示信号队列溢出了. 出现这种情况, 会失去有关fd上发生的I/O事件的信息, 因为 SIGIO 信号不会排队, SIGIO信号处理例程不接受 siginfo_t 结构体参数, so 信号处理例程不能确定是哪一个fd上产生了信号.
上面的问题的一种解决方式是增加事实信号数量的限制来减小信号队列溢出的可能性, 但是并不能完全排除.
采用 F_SETSIG 来建立实时信号作为 “I/O就绪” 通知的程序必须为 SIGIO 安装处理例程.如果发送了SIGIO信号, 程序可以先通过 sigwaitinfo() 先将队列中的实时信号全部获取, 临时切换到 select() 或 poll(), 通过它们获取剩余的发生 I/O 事件的文件描述符列表.
优缺点
优点:
当有事件触发的时候, 由内核通过发送信号的方式主动通知进程
不需要由用户进程复制fd数组到内核
缺点:
只支持边缘触发
信号处理不好可能会导致进程出问题.
epoll
epoll 同I/O多路复用和信号驱动一样, Linux的epoll(event poll) 可以检查多个文件描述符上的I/O就绪状态.
流程&函数&结构
epoll 是 Linux 系统中独有的, 在2.6版本后新增, epoll API 的核心数据结构称作 epoll 实例,它和一个打开的文件描述符相关联, 这个文件描述符不是用来做I/O操作的, 它是内核数据结构的句柄, 这些内核数据结构实现了两个目的:
- 记录了在进程中声明过的感兴趣的文件描述符列表 – interest list(兴趣列表)
- 维护了处于I/O就绪状态的文件描述符列表 – ready list(就绪列表)
(ready list 是 interest list 的子集)
使用流程
-
系统调用 epoll_create() 创建 epoll 实例, 返回代表该实例的文件描述符(对于每一个打开的fd创建一个与之对应的 epoll 实例表示该fd)
// size 仅仅是指明内部数据结构的初始大小划分,2.6.8之后这个参数被忽略使用 int epoll_create(int size);
在这个fd不再使用, 通过close()关闭, 当所有的 epoll 实例相关的文件描述符都被关闭的时候, 实例被销毁, 相关的资源都返还给系统(多个fd可能引用到了相同的 epoll 实例, 这是由于调用了 fork() 或者 dup() 这样的函数)
-
系统调用 epoll_ctl() 操作同 epoll 实例相关联的兴趣列表, 通过 epoll_ctl() 可以增加新的描述符到列表中、将已有的文件描述符从该列表删除, 以及修改代表文件描述符上事件类型的位掩码.(新增、删除感兴趣列表、感兴趣事件)
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *ev);
参数fd表明要修改的fd, 可以是管道、FIFO、套接字、POSIX消息队列、inotify实例、终端、设备,甚至是另一个 epoll 实例的fd.
event 事件的结构体
struct epoll_event{ uint32_t events; // epoll events(bit mask), 位操作,多个事件进行 逻辑& 操作 epoll_data_t data; // User data } typedef union epoll_data{ void *ptr; // int fd; uint32_t u32; uint64_t u64; }epoll_data_t;
-
系统调用 epoll_wait() 返回与 epoll 实例相关联的就绪列表中的成员.( 获取在感兴趣列表中已经就绪的)
int epoll_wait(int epfd, struct epoll_event * evlist, int maxevents, int timeout);
-
参数 evlist 所指向的结构体数组中返回的是有关
就绪态文件描述符的信息
-
events 字段返回了 在该描述符上已经发生的事件掩码
-
参数 timeout 用来确定 epoll_wait()的阻塞行为
- -1 : 一直阻塞
- 0 : 执行一次非阻塞的检查
- 大于0 : 阻塞至多 timeout 毫秒, 直到有事件发生或者捕捉到一个信号为止
调用成功后,epoll_wait()返回数组 evlist 中的元素个数。如果在 timeout 超时间隔内没有任何文件描述符处于就绪态的话,返回 0。出错时返回−1,并在 errno 中设定错误码以表示错误原因.
-
事件
对于 epoll 检查的每一个文件描述符, 可以指定位掩码来表示感兴趣的事件, 这些掩码和poll() 所使用的位掩码有紧密联系…
优点
主要优点:
-
检查大量文件描述符的时, epoll 的性能比 select() 和 poll() 高很多.
-
epoll 既支持水平触发, 也支持边缘触发. 与之相反, select() 和 poll() 只支持水平触发, 信号驱动I/O 只支持边缘触发.
性能表现上, epoll 跟信号驱动I/O差不多, 但是epoll有一些优点胜过信号驱动I/O:
-
可以避免复杂的信号处理流程(比如信号队列溢出时的处理)
-
灵活性高, 可以指定我们希望检查的事件类型(例如检查 socket 的读就绪事件、写就绪事件、或者两者都检查)
总结
关于水平和边缘触发
水平触发
当缓冲区中有数据可读/有空间可写的时候, 就会响应事件.
一个问题
使用水平触发的时候, 在socket缓冲区可写的时候, 会一直触发写事件, 所以一种处理方法就是, 在要写数据的时候才去注册写事件, 写完数据后取消写事件.
如果数据区一直有数据, 但是进程还没处理完数据, 会一直触发事件消耗性能, 这也是水平触发的一个缺点.
边缘触发
边缘触发的话, 是靠着新事件的产生才会触发的
.
你的事件已经反馈给进程了, 但是如果进程对这次事件的数据没读取完, 那剩余的数据就会在缓冲区里面, 不会再触发事件, 除非有新的数据到来(跟水平触发不同,水平对只要有数据,就会触发事件)
, 这样你就可以读取到之前的数据了, 所以这也是边缘触发的一个问题, 当有事件发生的时候, 尽可能的多读取数据, 防止数据停留在了缓冲区, 而进程读取不到完整的数据无法对请求进行处理. 客户端很可能就会请求超时.
如果开发团队的实力足够强的话,使用边缘触发进行开发.否则使用水平触发的性能已经足够.
在连接数多、并发高的情况下,使用边缘触发能更好的体现性能优势
特点
select() 和 poll() 相对于 信号驱动和epoll() 在不同os之间的可移植性更高, 但是当fd过多的时候, 效率也远低于后两者.
poll 和 select 只支持 水平触发
select() 和 poll() 的操作类似, 每次检查都是进程主动将数组 拷贝
到内核
select 传递的是3个事件(读、写、异常)数组,并且每次传递到内核前都需要清空原数据
poll 每次是将进程感兴趣的fd复制到内核中, 并且使用了 event 和 revent 将感兴趣事件和触发的事件分开了, 这样就不需要每次都初始化数组的数据.
select 和 poll 都是主动的进行检查, 因为这两种调用内核并不记录进程感兴趣的fd和事件.后面这两种信号驱动I/O 和 epoll, 都能使内核记住他们感兴趣的fd和事件, 所以不需要每次都进行数组的拷贝.
信号驱动只支持边缘触发
如上面所说, 信号驱动和epoll对于接收来说是类似的, 都是内核通知进程, 而不是进程每隔一段时间去检查对应的文件描述符上是否有事件发生. 当文件描述符数量过多的时候, 性能优势非常明显, 因为不需要将 fd 复制到内核中, 并且 都是由内核主动通知进程
.
epoll支持水平触发和边缘触发
epoll 支持水平触发和边缘触发. epoll 的机制类似于信号驱动, 都是进程告诉内核对哪些 fd 感兴趣, 然后对应的fd上有事件的时候, 由内核主动通知进程
, 进程再进行相应的处理.
总的对比
select、poll 和 信号驱动、epoll 的区别在于 主动 or 被动接受事件, select 和 poll 是主动去检查对应fd上是否有事件发生, 每次都需要复制数组到内核中, 然后由内核修改参数返回, 而 信号驱动和 epoll 在进程添加感兴趣fd和对应事件后, 每次通知都由内核主动的通知进程, 因为内核知道需要通知哪些, 不需要进程主动去询问和将数组从用户进程复制到内核, 所以信号驱动和epoll的性能显著的高于 select 和 poll.