目录
IO 简介
所有的 I/O 过程可以理解为等待和拷贝两个阶段,等待可以进行数据读写的条件,当等待的描述符就绪时进行数据的拷贝。一个低效的 I/O 大部分时间在等,在等待就绪条件和拷贝数据两个阶段等的比重大,高效的 I/O 大部分时间在拷贝数据,拷贝的比重相对更大。
五种 IO 模型
阻塞 IO
阻塞 I/O 就是在内核将数据准备好之前,系统调用会一直等待,直到条件就绪完成数据拷贝后再返回。所有的套接字,默认都是阻塞的。
非阻塞 IO
非阻塞 I/O 就是如果内核未将数据准备好时系统调用会直接返回,并且返回 EWOULDBLOCK 错误码。
非阻塞 I/O 往往需要程序员以循环的方式反复尝试去读写,这个过程称为轮询。这对于 CPU 来说是较大的浪费,一般只有特定场景下才使用。
信号驱动 IO
内核将数据准备好的时候,使用 SIGIO 信号通知应用程序进行 I/O 操作。
实现信号驱动 I/O 模型需要建立 SIGIO 的信号处理程序。用到的系统调用是:
#include <signal.h>
int sigaction(int signum, const struct sigaction *act,
struct sigaction *oldact);
IO 多路转接
I/O 多路转接和阻塞 I/O 本质类似,都是先等待就绪条件再拷贝数据。实际上 I/O 多路转接的核心在于能够同时等待多个文件描述符的就绪状态。
它是将串行化的等待并行化,某一时间段等待多个文件描述符的就绪状态,等待的时间是重叠的,从而提高了整个 I/O 过程的效率。
我们后面要详细说的 select、poll、epoll 都是在帮我们监控一定数量的文件描述符是否具备 I/O 条件,虽然他们的实现方式不一样。I/O 多路转接是高级 I/O 这块最核心的东西啦没有之一,我在参加阿里巴巴面试的时候面试官在这块问的特别详细。
异步 IO
前面的四种 I/O 模型各有区别,BUT! 最终完成数据拷贝的还是发起 I/O 操作者本身,这就是同步 I/O ,这个知识点同样被问到过,需要我们对这点有清晰的认知。
异步 I/O 是别人帮你你至到完成数据拷贝时才通知你。
IO 多路转接 — select
详解 select 系统调用
select 系统调用是用来让我们的程序监视多个文件描述符状态变化的,程序会停在 select 这里等待,直到被监视的文件描述符一个或多个发生了状态改变。
int select(int nfds, fd_set *readfds, fd_set *writefds,
fd_set *exceptfds, struct timeval *timeout);
函数参数
-
nfds :是一个数字,这个数字的值是要监视的最大的文件描述符值再加 1
-
readfds :对应于需要监视的可读文件描述的集合
-
writefds :对应于需要监视的可写文件描述符的集合
-
exceptfds :对应于异常文件描述符的集合
-
timeout :是一个结构体,用来设置 select 的等待时间
函数返回值
执行成功则返回监听的那些文件描述符中已经就绪的文件描述符个数。
如果返回 0 代表在文件描述符状态改变之前已经超过设置的 timeout 时间。
当有错误发生时则返回 -1,错误原因存于 errno,此时参数的值得变化是不可预测的。当文件描述符是无效的、该文件描述符已关闭、此调用被信号中断、参数 nfds 为负数、核心内存不足时可能会返回 -1。
fd_set 结构
/* The fd_set member is required to be an array of longs. */
typedef long int __fd_mask;
/* fd_set for select and pselect. */
typedef struct
{
/* XPG4.2 requires this member name. Otherwise avoid the name
from the global namespace. */
#ifdef __USE_XOPEN
__fd_mask fds_bits[__FD_SETSIZE / __NFDBITS];// __FD_SETSIZE=1024 __NFDBITS=32
# define __FDS_BITS(set) ((set)->fds_bits)
#else
__fd_mask __fds_bits[__FD_SETSIZE / __NFDBITS];
# define __FDS_BITS(set) ((set)->__fds_bits)
#endif
} fd_set;
这个结构就是一个数组,更严格的说,是一个“位图”,使用位图中的位来表示要监视的文件描述符。
可监控的文件描述符的个数取决于 sizeof(fd_set) 的值,我的 Centos7 上 sizeof(fd_set) = 128,每个比特位表示一个文件描述符,所以我的机器上支持的最大文件描述符时 128 * 8 = 1024 个。
这里提供了一组操作 fd_set 的接口,来供我们使用,可很方便的操作位图:
void FD_CLR(int fd, fd_set *set); //用来清除描述符词组 set 中相关 fd 的位
int FD_ISSET(int fd, fd_set *set); //用来测试描述符词组 set 中 fd 的位是否为真
void FD_SET(int fd, fd_set *set); //用来设置描述符词组 set 中 fd 的位
void FD_ZERO(fd_set *set); //用来清除描述符词组 set 的全部位
fd_set 的大小可以调整,可能会涉及到重新编译内核。
timeval 结构
用于描述一段时间长度,如果在这个时间内,需要监视的描述符没有状态改变,则函数直接返回 0.
select 执行过程
理解 select 模型的关键在于理解 fd_set 这个结构,而这个结构是一个数组,在 select 模型里当作位图来用。将需要监控的文件描述符添加到这个位图中,内核通过轮询的方式监控有无文件描述符就绪,当至少有一个文件描述符就绪时,内核将位图中没有就绪的文件描述符对应的位清楚掉,select 调用返回时,我们程序员用 array 数组作为源数据用 FD_ISSET 接口一一筛选就绪的文件描述符,针对就绪的文件描述符进行数据拷贝。再次调用 select 时将需要监控的文件描述符重新一一添加到 fd_set 集合中。
socket 就绪条件
读就绪
- socket 内核中,接收缓冲区中的字节数大于等于低水平标记 SO_RCVLOWAT,此时可以无阻塞的读该文件描述符,并且返回值大于 0
- socket TCP 通信中,对端关闭连接,此时对该 socket 读,返回 0
- 监听的 socket 上有新的连接请求
- socket 上有未处理的错误
写就绪
- socket 内核中,发送缓冲区中的可用字节数(发送缓冲区的空闲位置大小)大于等于低水位标记 SO_SNDLOWAT,此时可以无阻塞的写,并且返回值大于 0
- socket 的写操作被关闭(close 或 shutdown),对一个写操作被关闭的 socket 进行写操作,会触发 SIGPIPE 信号
- socket 使用非阻塞 connect 连接成功或失败之后
- socket 上有未读取的错误
异常就绪
(先放这儿),以后再说
select 的特点
将文件描述符加入 select 监控集合的同时,再使用一个数组 array 保存放到 select 监控集中的 fd。一是用于在 select 返回后,array 作为源数据使用 FD_ISSET 接口来一一筛选就绪的文件描述符;二是 select 返回后会把之前加入到监控集合的但无就绪的文件描述符清空,所以再次开始 select 之前需要从 array 取得文件描述符加入到监控集合中,扫描 array 的过程中找出值最大的文件描述符,用于设置 select 的第一个参数时用(maxfd+1)。
select 的缺点
- 监控的性能随着文件描述符的增多而降低
- 监控的描述符是向 fd_set 这个结构中添加,fd_set 这个结构的大小决定了能同时监控的文件描述符的最大数量,即 select 能同时监控的描述符是有上限的。在我机器上是 1024 个,这个数量太小了
- 每次调用 select,都需要手动设置 fd 集合,从接口使用角度来说也非常不便
- 每次调用 select 都需要把 fd 集合从用户态拷贝到内核态,这也是一部分开销
- 调用 select 需要在内核通过轮询的方式判断有无文件描述符就绪,在 fd 很大时开销也是比较大的
使用 select 的网络服务器
IO 多路转接 — poll
poll 函数接口
#include <poll.h>
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
函数参数
- fds :是一个 poll 函数监听的结构列表(数据结构:数组),其中的每一个元素,包含了三个部分内容:文件描述符、监听事件集合、返回的事件集合
- nfds :fds 数组的大小
- timeout :表示 poll 函数的超时时间,单位是毫秒(ms)
函数返回值
- 返回值小于 0,表示出错
- 返回值等于 0,表示 poll 函数等待超时
- 返回值大于 0,表示监控的文件描述符中已经就绪的文件描述符数量
pollfd 结构
struct pollfd
{
int fd; /* file descriptor */
short events; /* requested events */
short revents; /* returned events */
};
events 和 revents 的取值:取值全部是宏,只有其中一个是 1,其他全是 0
这里我简单的列几项:
- POLLIN :数据(普通数据、优先数据)可读
- POLLOUT :数据(普通数据、优先数据)可写
- POLLPRI :高级优先数据可读,比如 TCP 紧急数据
- POLLHUP :挂起,比如管道的写段被关闭后,读端描述符上将受到 POLLHUP 事件
poll 的优点
不同于 select 使用三个位图来表示三个 fdset 的方式,poll 使用一个 pollfd 的结构实现:输入输出参数分离
- poll 没有最大数量限制(但 poll 的性能也会随着监控文件描述符的增多而降低)
- pollfd 结构包含了要监视的 event 和发生的 event ,不再使用 select “参数-值”传递方式,接口使用比 select 更方便
poll 的缺点
poll 中监听的文件描述符增多时:
- 和 select 一样 poll 返回后需要用轮询的方式来获取就绪的文件描述符,当连接的大量客户端在一时刻只有很少的处于就绪状态,因此随着监控的文件描述数量的增长,其效率也会下降
- 每次调用 poll 都需要把大量的 pollfd 结构从用户态拷贝到内核态
使用 poll 的网络服务器
IO 多路转接 — epoll
epoll 几乎具备了之前所说的一切优点,被公认为 Linux2.6 下性能最好的 I/O 就绪通知方法。
epoll 相关的三个系统调用:
创建 epoll 模型函数
#include <sys/epoll.h>
int epoll_create(int size);
- 返回值占一个文件描述符
- 自从 Linux2.6.8 之后,size 参数是被忽略的
- 用完之后,必须调用 close 关闭
epoll 的事件注册函数
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
函数参数
- epfd :epoll 的句柄
- op :表示动作,用三个宏来表示
- fd :需要监听的文件描述符
- event :告诉内核需要监听什么事
第二个参数的取值:
- EPOLL_CTL_ADD :注册新的 fd 到 epfd 中
- EPOLL_CTL_MOD :修改已经注册的 fd 的监听事件
- EPOLL_CTL_DEL :从 epfd 中删除一个 fd
epoll_event 结构
typedef union epoll_data {
void *ptr;
int fd;
uint32_t u32;
uint64_t u64;
} epoll_data_t;
struct epoll_event {
uint32_t events; /* Epoll events */
epoll_data_t data; /* User data variable */
};
events 可以是一下几个宏的集合:
- EPOLLIN :表示对应的文件描述符可以读
- EPOLLOUT :表示对应的文件描述符可以写
- EPOLLRDHUP :表示流式套接字对端关闭连接
- EPOLLPRI :表示对应的文件描述符有紧急数据可读(这里应该表示有带外数据到来)
- EPOLLERR :表示对应的文件描述符发生错误
- EPOLLHUP :表示对应的文件描述符被挂断
- EPOLLET :将 epoll 设为边缘触发模式,这是相对于水平出发来说的
- EPOLLONESHOT :只监听一次事件,当监听完这次事件之后,如果还需要监听这个文件描述符的话,需要再次把这个文件描述符加入到 epoll 模型里
收集 epoll 监控的已经就绪的事件的函数
int epoll_wait(int epfd, struct epoll_event *events,
int maxevents, int timeout);
函数参数
- epfd :epoll 的句柄
- events :epoll 将会把就绪的文件描述符及对应的监控事件结构赋值到 events 数组中(内核只负责把数据复制到这个 events 数组中,不会去帮我们在用户态中分配内存)
- maxevents :表示 events 数组的大小
- timeout :表示超时时间,0 会立即返回,-1是永久阻塞
函数返回值
如果函数调用成功,返回对应 I/O 上已经准备好的文件描述符数目,如果返回0表示已超时,返回小于 0 表示函数调用失败。
epoll 工作原理
- 当某一进程调用 epoll_craete 方法时,Linux 内核会创建一个 eventpoll 结构体,这个结构体当中有两个成员与 epoll 的工作过程密切相关。
struct eventpoll{
....
/*红⿊黑树的根节点,这颗树中存储着所有添加到epoll中的需要监控的事件*/
struct rb_root rbr;
/*双链表中则存放着将要通过epoll_wait返回给⽤用户的满⾜足条件的事件*/
struct list_head rdlist;
....
};
- 每一个 epoll 对象都有一个独立的 eventpoll 结构体,用于存放通过 epoll_ctl 方法向 epoll 对象中添加进来的事件
- 这些事件都会挂载在红黑树中,因此,重复添加的事件可以通过红黑树高效的识别出来(红黑树的插入时间效率是 lgN)
- 而所有添加到 epoll 中的事件都会与设备(网卡)驱动程序建立回调关系,也就是说,当有就绪事件时会调用这个回调方法
- 这个回调方法在内核中叫 callback,它会将就绪的事件添加到 rdlist 双链表中
- 在 epoll 中,对于每一个事件,都会建立一个 epitem 结构体
struct epitem{
struct rb_node rbn;//红⿊黑树节点
struct list_head rdllink;//双向链表节点
struct epoll_filefd ffd; //事件句柄信息
struct eventpoll *ep; //指向其所属的eventpoll对象
struct epoll_event event; //期待发⽣生的事件类型
}
- 当调用 epoll_wait 检查是否有事件发生时,只需要检查 eventpoll 对象中的 rdlist 双向链表中是否有 epitem 元素即可
- 如果 rdlist 不为空,则把就绪的事件复制到用户态,同时将就绪事件的数目返回给用户。这个操作的时间复杂度是 O(1)
总结一下,epoll 的使用过程就是三部曲:
- 调用 epoll_craete 创建一个 epoll 句柄
- 调用 epoll_ctl 将要监控的文件描述符进行注册
- 调用 epoll_wait 等待文件描述符就绪
epoll 的优点
- 文件描述符数目无上限:通过 epoll_ctr 来注册一个文件描述符,内核中使用红黑树的数据结构来管理所有需要监控的文件描述符。
- 基于事件的就绪通知方式:一旦被监听的某个文件描述符就绪,内核会采用类似于 callback 的回调机制,迅速激活这个文件描述符,这样随着文件描述符数量的增加,也不会影响判定就绪的性能。
- 维护就绪队列当文件描述符就绪,就会被放到内核中的一个就绪队列中。这样调用 epoll_wait 获取就需文件描述符的时候,只要取到队列中的元素即可,操作的事件复杂度是 O(1)
- 内存映射机制:内核直接将就绪队列通过 mmap 的方式映射到用户态,避免了拷贝内存这样的额外性能开销
epoll 的工作方式
epoll 有两种工作方式:水平触发(LT)和边缘触发(ET)
epoll 的使用场景
对于多连接,且多连接中只有一部分连接比较活跃时,比较适合用 epoll。
比如 APP 的入口服务器。
epoll 中的惊群问题
(先放放)