IO模型
1.概念
- IO有内存IO, 网络IO和磁盘IO三种, 通常我们说的是后两者
- 阻塞和非阻塞, 指的是函数/方法的实现方式, 即在数据就绪之前是立刻返回还是等待, 即发起IO请求是否会被阻塞
- 以文件IO为例, 一个IO读过程是文件数据从磁盘 —> 内核缓冲区 —> 用户内存的过程. 同步和异步的区别主要在于数据从内核缓冲区 —> 用户内存这个过程 需不需要用户进程等待, 即实际的IO读写是否阻塞请求进程
从上面两点可以看出阻塞非阻塞和同步异步之间的区别, 其实对于一次network IO来说, 它会涉及到两个系统对象, 一个是调用这个IO的process(或者thread), 另一个就是系统内核(kernel). 当一个read操作发生时, 它会经历两个阶段 :
- 1.等待数据准备(waiting for the data to be ready)
- 2.将数据从内核拷贝到进程中(Copying the data from the kernel to the process)
2.IO模型
1>阻塞IO(blocking IO)
在linux中, 默认情况下所有的socket都是blocking, 其读操作流程如下 :
当用户进程调用了recfrom这个系统调用, kernel就开始了IO的第一个阶段 : 准备数据. 对于network IO来说, 很多数据在一开始还没有到达(比如还没有收到一个完整的UDP包). 这个时候kernel要等待足够的数据到来. 而在用户进程这边, 整个进程会被阻塞. 当kernel一直等待数据准备好了, 它就会将数据从kernel中拷贝到用户内存, 然后kernel返回结果, 用户进程才解除blocking状态, 重新运行起来
所以, blocking IO的特点就是在IO执行的两个阶段(等待数据和拷贝数据两个阶段都被Blocking)
2>非阻塞IO(non-blocking IO)
Linux下, 可以通过设置socket使其变为non-blockint. 当对一个non-blocking socket执行读操作时, 流程是这个样子
从图中可以看出, 当用户进程发出read操作时, 如果kernel中的数据还没有准备好, 那它并不会block用户进程, 而是立即返回一个error. 从用户进程角度来看, 它发起一个read操作后, 并不需要等待, 而是马上就得到了一个结果. 用户进程判断结果是一个error时, 它就知道数据还没有准备好, 于是它可以再此发送read操作. 一旦kernel中的数据准备好了, 并且又再次收到了用户进程的systemcall, 那么它马上把数据拷贝到用户内存, 然后返回
所以在非阻塞IO中, 用户进程其实是需要不断的主动询问kernel数据准备好了没有
3>多路复用IO(IO multiplexing)
有些地方将这种IO方式称为事件驱动IO. 我们都知道, select/epoll的好处就在于单个process就可以同时处理多个网络连接的IO. 它的基本原理就是select这个function会不断轮询所负责的所有socket, 当某个socket有数据到达了, 就通知用户进程
当用户进程调用了select, 那么整个进程会被block, 而同时, kernel会 "监视"所有select所负责的socket, 当任何一个socket中的数据准备好了, select就会返回. 这个时候用户进程再调用read操作, 将数据从kernel拷贝到用户进程
使用select以后最大的优势是用户可以在一个线程内同时处理多个socket的IO请求. 用户可以注册多个socket, 然后不断地调用select读取被激活的socket, 即可以在同一个线程内同时处理多个IO请求的目的. 而在同步阻塞模型中, 必须通过多线程才能达到这个目的. (所以在处理的连接数不是很高的时候, select/epoll的web server不一定比使用mult-threading + blocking IO的web server性能更好, 可能延迟还更大. select/epoll的优势并不是对于单个连接能处理的更快, 而是在于能处理更多的连接)
在多路复用模型中, 对于每一个socket, 一般都设置为non-blocking, 但是如上图所示, 整个用户Process其实是一直被block的. 只不过precess是被select()这个函数block, 而不是被socket IO给block. 所以select()与非阻塞IO类似
大部分Unix/Linux都支持select函数, 该函数用于探测多个文件句柄的状态变化. 下面给出select接口的原型 :
FD_ZERO(int fd, fd_set* fds)
FD_SET(int fd, fd_set* fds)
FD_ISSET(int fd, fd_set* fds)
FD_CLR(int fd, fd_set* fds)
int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds,
struct timeval *timeout)
这里, fd_set类型可以简单的理解为按bit位标记句柄的队列, 例如要在某fd_set中标记一个值为16的句柄, 则该fd_set的第16个bit位被标记为1. 具体 的置位, 验证可以使用FD_SET, FDISSET等宏实现. 在select函数中, readfds, writefds和exceptfds同时作为输入参数和输出参数. 如果输入的readfds标记了16号句柄, 则select()将检测16号句柄是否可读. 在select()返回后, 可以通过检查readfds是否标记16号句柄, 来判断该"可读"事件是否发生. 另外, 用户可以设置timeout时间.
参数readfds,writefds,exceptfds作为输入参数时, readfds应该标记所有的需要探测的"可读时间"的句柄. writefds和exceptfds应该标记所有需要探测的"可写事件"和"错误事件"的句柄
作为输出参数, readfds,writefds和exceptfds中保存了select()捕捉到的所有事件的句柄值. 程序员需要检查所有的标记位(用FD_ISSET()检查), 以确定哪些句柄发生了事件
上述模型主要模拟的是"一问一答"的服务流程, 所以如果select()发现某句柄捕捉到"可读事件", 服务器程序应及时做recv()操作, 并根据接收到的数据准备好待发送数据, 并将对应的句柄值加入writefds, 准备下一次"可写事件"的select()探测. 同样, 如果select()发现某句柄捕捉到的"可写事件", 则程序应及时做send()操作, 并准备好下一次的"可读事件"探测准备. 下图描述该模型的一个执行周期
这种模型的特征在于每一个执行周期都会探测一次或一组事件, 一个特定的事件会触发某个特定的响应. 我们可以将这总模型归类为**“事件驱动型”** 相比其他模型, 使用select()的事件驱动模型只用单线程(进程)执行, 占用资源少, 不消耗太多CPU,同时能够为多客户端提供服务.
但这个模型依然存在问题 : 首先select()接口并不是实现"事件驱动"的最好选择. 因为当需要探测的句柄值较大时, select()接口本身需要消耗大量事件去轮询各个句柄. 其次, 该模型将事件探测和事件响应混杂在一起, 一旦事件响应的执行体庞大, 则对整个模型是灾难性的.
select和poll的原理基本相同 :
- 注册待侦听的fd
- 每次调用都去检查这些fd的状态, 当有一个或多个fd就绪的时候就返回
- 返回结果中包括已就绪和未就绪的fd
int poll(struct pollfd *fds, unsigned int nfds, int timeout);
poll使用pollfd类型的数组来作为fd描述符
struct pollfd {
int fd; /* file descriptor */
short events; /* requested events */
short revents; /* returned events */
};
相比select, poll解决了单独线程能够打开的文件描述符数量有限制这个问题 : select受限与FD_SIZE的限制, 如果修改则需要修改这个宏重新编译内核; 而poll通过一个pollfd数组向内核传递需要关注的时间, 避开了文件描述符数量限制
此外, select和poll共同具有的一个很大的缺点就是包含大量fd的数组被整体复制于用户态和内核态地址空间之间, 开销会随着fd数量增多而线性增大
epoll :
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_ctl()用于向内核注册新的描述符或者是改变某个文件描述符的状态. 已注册的描述符在内核中会被维护在一棵红黑树上, 通过回调函数, 内核会将I/O准备好的描述符加入到一个链表中管理. 进程调用epoll_wait便可以得到事件完成的描述符
其实现如下 :
-
调用epoll_create()建立一个epoll对象(在epoll文件系统中为这个句柄对象分配资源)
-
调用epoll_ctl向epoll对象中添加连接的socket
-
调用epoll_wait收集发生的事件的连接
当某一进程调用epoll_create方法时,Linux内核会创建一个eventpoll结构体,这个结构体中有两个成员与epoll的使用方式密切相关。eventpoll结构体如下所示:
struct eventpoll{
....
/*红黑树的根节点,这颗树中存储着所有添加到epoll中的需要监控的事件*/
struct rb_root rbr;
/*双链表中则存放着将要通过epoll_wait返回给用户的满足条件的事件*/
struct list_head rdlist;
....
};
每个epoll对象都有一个独立的eventpoll结构体, 用于存放通过epoll_ctl方法向epoll对象中添加进来的事件. 这些事件都会挂载在红黑树中, 如此, 重复添加的事件就可以通过红黑树而高效的识别出来
而所有添加到epoll中的事件都会与设备(网卡)驱动程序建立回调关系, 当相应的事件发生时会调用这个回调方法.这个回调方法会将发生的事件添加到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; //期待发生的事件类型
}
当有事件发生时, 只需要检查eventpoll对象中rdlist双链表中是否有epitem元素即可. 如果rdlist不为空, 则把发生的事件复制到用户态, 同时将事件数量返回给用户
从上面的描述可以看出, epoll只需要将描述符从进程缓冲区向内核缓冲区拷贝一次, 并且进程不需要通过轮询来获得事件完成的描述符
epoll有以下优点 :
- 基于事件驱动的方式, 避免了每次都要把所有的fd都扫描一遍
- epoll_wait只返回就绪的fd
- epoll的fd数量上限是操作系统的最大文件句柄数目, 这个数目一般和内存有关
另外, 对于IO多路复用还有一个水平触发和边缘触发的概念
- 水平触发 : 当就绪的fd未被用户进程处理, 下一次查询依旧会返回
- 边缘触发 : 无论就绪的fd是否被处理, 下一次不再返回. 理论上性能更高, 但是实现较为复杂, 任何意外的丢失时间都会造成请求处理错误
4>信号驱动IO(signal driven IO)
- 开启socket信号驱动IO功能
- 系统调用sigaction执行信号处理函数(非阻塞, 立即返回)
- 数据就绪, 生成sigio信号, 通过信号回调应用来读取数据
5>异步IO(Asynchronous IO)
在用户发起read操作之后, 立即就可以去做其他的事. 另一方面, 在内核的角度, 当它收到一个asynchronous read之后, 首先它会立刻返回, 所以不会对用户进程产生任何block. 然后, 内核会等待数据准备完成, 然后将数据拷贝到用户内存, 当这一切都完成后, 内核会给用户进程发送一个signal, 告诉它read完成了