阻塞I/O
一般的I/O操作为阻塞I/O,比如read和write。阻塞的意思,就是在得不到数据或资源时一直等着,直到拿到了数据或资源。应用的函数如果不能被完成,长时间处于等待结果的状态,我们就称为阻塞I/O。这样会造成CPU空闲。
非阻塞I/O
非阻塞I / O是指,如果这种I/O操作不能完成,则立即出错返回,表示该操作如果继续执行将会阻塞。在出错返回后,进程或线程可以接着处理下一函数,但需要定期轮询,以完成I/O操作。
对于一个给定的描述符有两种方法对其指定非阻塞I / O:
(1) 如果是调用o p e n以获得该描述符,则可指定O _ N O N B L O C K标志
(2) 对于已经打开的一个描述符,则可调用f c n t l打开O _ N O N B L O C K文件状态标志
/**********************使能非阻塞I/O******************** *int flags; *if(flags = fcntl(fd, F_GETFL, 0) < 0) *{ * perror("fcntl"); * return -1; *} *flags |= O_NONBLOCK; *if(fcntl(fd, F_SETFL, flags) < 0) *{ * perror("fcntl"); * return -1; *} *******************************************************/ /**********************关闭非阻塞I/O****************** flags &= ~O_NONBLOCK; if(fcntl(fd, F_SETFL, flags) < 0) { perror("fcntl"); return -1; } *******************************************************/
I/O多路复用
I/O多路复用是指,使用一个线程来检查多个文件描述符的就绪状态(主要是select和poll、epoll )。此时阻塞发生在select/poll/epoll的系统调用上,而不是阻塞在实际的I/O系统调用上。
IO多路复用的高级之处在于:它能同时等待多个文件描述符,而这些文件描述符其中的任意一个进入读就绪状态,select等函数就可以返回。
这个图和阻塞 IO的图其实并没有太大的不同,事实上,还更差一些。因为这里需要使用两个system call (select 和 recvfrom),而阻塞 IO只调用了一个system call (recvfrom)。但是,用select的优势在于它可以同时处理多个连接。
所以,如果处理的连接数不是很高的话,使用select/epoll的web server不一定比使用多线程 + 阻塞 IO的web server性能更好,可能延迟还更大。
select/epoll的优势并不是对于单个连接能处理得更快,而是在于能处理更多的连接。
在IO多路复用中,实际上,对于每一个socket,一般都设置成为非阻塞的,但是,如上图所示,整个用户的进程其实是一直被阻塞在select/poll/epoll处的。
Select
#include <sys/types.h>/* fd_set data type */
#include <sys/time.h> /* struct timeval */
#include <unistd.h> /* function prototype might be here */
int select (int maxfd+1, fd_set * readfds, fd_set * writefds, fd_set * exceptfds,
struct timeval * tvptr) ;
s e l e c t有三个可能的返回值。
(1) 返回值-1表示出错。这是可能发生的,例如在所指定的描述符都没有准备好时捕捉到
一个信号。
(2) 返回值0表示没有描述符准备好。若指定的描述符都没有准备好,而且指定的时间已经
超过,则发生这种情况。
(3) 返回一个正值说明了已经准备好的描述符数,在这种情况下,三个描述符集中仍旧打
开的位是对应于已准备好的描述符位。
先说明最后一个参数,它指定愿意等待的时间。
struct timeval{
long tv_sec; /* seconds */
long tv_usec; /* and microseconds */
};
有三种情况:
• t v p t r= =NULL : 永远等待;
• t v p t r- >t v _ s e c= =0 && t v p t r- >t v _ u s e c= =0 : 完全不等待;
• t v p t r- >t v _ s e c ! =0 | | t v p t r- >t v _ u s e c! =0 : 等待指定的秒数和微秒数。
中间三个参数re a d f d s、w r i t e f d s和e x c e p t f d s是指向描述符集的指针。这三个描述符集说明了我们关心的可读、可写或处于优先条件的各个描述符。每个描述符集存放在一个 f d _ s e t数据类型中。
s e l e c t第一个参数m a x f d p1的意思是“最大f d加1(max fd plus 1)”。内核就只需在此范围内寻找打开的位,而不必在数百位的大范围内搜索。
Poll
#include <stropts.h>
#include <poll.h>
int poll(struct pollfd fdarray[ ], unsigned long nfds, int timeout) ;
返回:准备就绪的描述符数,若超时则为 0,若出错则为- 1
与s e l e c t不同,p o l l不是为每个条件构造一个描述符集,而是构造一个 p o l l f d结构数组,每个数组元素指定一个描述符编号以及对其所关心的条件。
struct pollfd {
int fd ; /* file descriptor to check, or < 0 to ignore */
short events ; /* events of interest on fd */
short revents ; /* events that occurred on fd */
} ;
fdarray数组中的元素数由nfds说明。
应将e v e n t s成员设置为下表所示值的一个或几个。通过这些值告诉内核我们对该描述
符关心的是什么。返回时,内核设置r e v e n t s成员,以说明对该描述符发生了什么事件。(p o l l没有更改e v e n t s成员,这与s e l e c t不同,s e l e c t修改其参数以指示哪一个描述符已准备好了。)
Epoll
epoll是Linux内核为处理大批量文件描述符而作了改进的poll,是Linux下多路复用IO接口select/poll的增强版本,它能显著提高程序在大量并发连接中只有少量活跃的情况下的系统CPU利用率。另一点原因就是获取事件的时候,它无须遍历整个被侦听的描述符集,只要遍历那些被内核IO事件异步唤醒而加入Ready队列的描述符集合就行了。epoll除了提供select/poll那种IO事件的水平触发(Level Triggered)外,还提供了边缘触发(Edge Triggered),这就使得用户空间程序有可能缓存IO状态,减少epoll_wait/epoll_pwait的调用,提高应用程序效率。(水平触发:在一个状态时触发,在关心状态时有用;边沿触发:状态改变时触发,在关心事件时有用)
EPOLL的使用 :
文件描述符的创建
#include <sys/epoll.h>
int epoll_create ( int size );
注册监控事件
#include <sys/epoll.h>
int epoll_ctl ( int epfd, int op, int fd, struct epoll_event *event );
函数说明:
fd:要操作的文件描述符
op:指定操作类型
操作类型:
EPOLL_CTL_ADD:往事件表中注册fd上的事件
EPOLL_CTL_MOD:修改fd上的注册事件
EPOLL_CTL_DEL:删除fd上的注册事件
event:指定事件,它是epoll_event结构指针类型
epoll_event定义:
结构体说明:
events:描述事件类型,和poll支持的事件类型基本相同(两个额外的事件:EPOLLET和EPOLLONESHOT,高效运作的关键)
data成员:存储用户数据
epoll_wait函数
#include <sys/epoll.h>
int epoll_wait ( int epfd, struct epoll_event* events, int maxevents, int timeout );
函数说明:
返回:成功时返回就绪的文件描述符的个数,失败时返回-1并设置errno
timeout:指定epoll的超时时间,单位是毫秒。当timeout为-1是,epoll_wait调用将永远阻塞,直到某个时间发生。当timeout为0时,epoll_wait调用将立即返回。
maxevents:指定最多监听多少个事件
events:检测到事件,将所有就绪的事件从内核事件表中复制到它的第二个参数events指向的数组中。
select、poll和epoll三种I/O复用模式的比较
系统调用
select
poll
epoll
事件集合
用户通过3个参数分别传入感兴趣的可读,可写及异常等事件
内核通过对这些参数的在线修改来反馈其中的就绪事件
这使得用户每次调用select都要重置这3个参数
统一处理所有事件类型,因此只需要一个事件集参数。
用户通过pollfd.events传入感兴趣的事件,内核通过
修改pollfd.revents反馈其中就绪的事件
内核通过一个事件表直接管理用户感兴趣的所有事件。
因此每次调用epoll_wait时,无需反复传入用户感兴趣
的事件。epoll_wait系统调用的参数events仅用来反馈就绪的事件
应用程序索引就绪文件
描述符的时间复杂度
O(n)
O(n)
O(1)
最大支持文件描述符数
一般有最大值限制
65535
65535
工作模式
LT(水平触发)
LT
支持ET高效模式(边沿触发)
内核实现和工作效率
采用轮询方式检测就绪事件,时间复杂度:O(n)
采用轮询方式检测就绪事件,时间复杂度:O(n)
采用回调方式检测就绪事件,时间复杂度:O(1)
异步I/O
异步I/O是:告知内核启动某个操作,并让内核在整个操作(包括将数据从内核拷贝到我们自己的缓冲区)完成后通知我们。
信号驱动I/O
当数据准备完毕的时候,信号通知程序数据准备完毕,第2阶段阻塞;
我们首先开启套接口的信号驱动I/O功能,并通过sigaction系统调用安装一个信号处理函数。该系统调用立即返回,我们的进程继续工作,也就是说它没有被阻塞。当数据报准备好读取时,内核就为该进程产生一个SIGIO信号。我们随后既可以在信号处理函数中调用recvfrom读取数据报,并通知主循环数据已准备好待处理,也可以立即通知主循环,让它读取数据报。
无论如何处理SIGIO信号,这种模型的优势在于等待数据报到达期间,进程不被阻塞。主循环可以继续执行,只要不时等待来自信号处理函数的通知:既可以是数据已准备好被处理,
也可以是数据报已准备好被读取。
同步、异步、阻塞、非阻塞
同步 & 异步
同步与异步是针对多个事件(线程/进程)来说的。
- 如果事件A需要等待事件B的完成才能完成,这种串行执行机制可以说是同步的,这是一种可靠的任务序列,要么都成功,要么都失败。
- 如果事件B的执行不需要依赖事件A的完成结果,这种并行的执行机制可以说是异步的。事件B不确定事件A是否真正完成,所以是不可靠的任务序列。
同步异步可以理解为多个事件的执行方式和执行时机如何,是串行等待还是并行执行。同步中依赖事件等待被依赖事件的完成,然后触发自身开始执行,异步
中依赖事件不需要等待被依赖事件,可以和被依赖事件并行执行,被依赖事件执行完成后,可以通过回调、通知等方式告知依赖事件。阻塞 & 非阻塞
阻塞与非阻塞是针对单一事件(线程/进程)来说的。
- 对于阻塞,如果一个事件在发起一个调用之后,在调用结果返回之前,该事件会被一直挂起,处于等待状态。
- 对于非阻塞,如果一个事件在发起调用以后,无论该调用当前是否得到结果,都会立刻返回,不会阻塞当前事件。
阻塞与非阻塞可以理解为单个事件在发起其他调用以后,自身的状态如何,是苦苦等待还是继续干自己的事情。非阻塞虽然能提高CPU利用率,但是也带来了系统线程切换的成本,需要在CPU执行时间和系统切换成本之间好好估量一下。
同步阻塞
应用程序执行系统调用,应用程序会一直阻塞,直到系统调用完成。应用程序处于不再消费CPU而只是简单等待响应的状态。当响应返回时,数据被移动到用户空间的缓冲区,应用程序解除阻塞。
同步非阻塞
设备以非阻塞形式打开,I/O操作不会立即完成,read操作可能会返回一个错误代码。应用程序可以执行其他操作,但需要请求多次I/O操作,直到数据可用。
同步非阻塞形式实际上是效率低下的,因为:
- 应用程序需要在不同的任务之间切换。异步非阻塞是你只需要执行当前任务,系统调用会主动通知你,不用频繁切换。
- 数据在内核中变为可用到调用read返回数据之间存在时间间隔,会造成整体数据吞吐量降低
异步非阻塞
应用程序的其他处理任务与I/O任务重叠进行。读请求会立即返回,说明请求已经成功发起,应用程序不被阻塞,继续执行其它处理操作。当read响应到达,将数据拷贝到用户空间,产生信号或者执行一个基于线程回调函数完成I/O处理。应用程序不用在多个任务之间切换。
各种I/O模型的比较
非阻塞I/O和异步I/O区别在于,在非阻塞I/O中,虽然进程大部分时间不会被block,但是需要不停的去主动check,并且当数据准备完成以后,也需要应用程序主动调用recvfrom将数据拷贝到用户空间;异步I/O则不同,就像是应用程序将整个I/O操作交给了内核完成,然后由内核发信号通知。期间应用程序不需要主动去检查I/O操作状态,也不需要主动从内核空间拷贝数据到用户空间。
非阻塞I/O看起来是non-blocking的,但是只是在内核数据没准备好时,当数据准备完成,recvfrom需要从内核空间拷贝到用户空间,这个时候其实是被block住的。而异步I/O是当进程发起I/O操作后,再不用主动去请求,知道内核数据准备好并发出信号通知,整个过程完全没有block。
散布读和聚集写、多次读和多次写
r e a d v和w r i t e v函数用于在一个函数调用中读、写多个非连续缓存。有时也将这两个函数称为散布读(scatter re a d)和聚集写(gather write)。
#include <sys/types.h>
#include <sys/uio.h>
ssize_t readv(int filedes, const struct iovec iov[ ], int iovcnt) ;
ssize_t writev(int filedes, const struct iovec iov[ ], int iovcnt) ;
两个函数返回:已读、写的字节数,若出错则为 - 1
这两个函数的第二个参数是指向i o v e c结构数组的一个指针:
struct iovec {
void *iov_base; /* starting address of buffer */
size_t iov_len; /* size of buffer */
} ;
i o v数组中的元素数由i o v c n t说明。
r e a d n和w r i t e n的功能是读、写指定的N字节数据,并处理返回值小于
要求值的情况。这两个函数只是按需多次调用r e a d和w r i t e直至读、写了N字节数据。
#include "o u r h d r . h"
ssize_t readn(int filedes, void *buff, size_t nbytes) ;
ssize_t writen(int filedes, void *buff, size_t nbytes) ;
两个函数返回:已读、写字节数,若出错则为 - 1
存储映射I/O
存储映射I / O使一个磁盘文件与存储空间中的一个缓存相映射。于是当从缓存中取数据,
就相当于读文件中的相应字节。与其类似,将数据存入缓存,则相应字节就自动地写入文件。
这样,就可以在不使用r e a d和w r i t e的情况下执行I / O。
为了使用这种功能,应首先告诉内核将一个给定的文件映射到一个存储区域中。这是由
m m a p函数实现的。
#include <sys/types.h>
#include <sys/mman.h>
caddr_t mmap(caddr_t addr, size_t len, int prot, int flag, int filedes, off_t off) ;
返回:若成功则为映射区的起始地址,若出错则为 – 1
数据类型c a d d r _ t通常定义为char *。a d d r参数用于指定映射存储区的起始地址。通常将其设置为0,这表示由系统选择该映射区的起始地址。此函数的返回地址是:该映射区的起始地址。
f i l e d e s指定要被映射文件的描述符。在映射该文件到一个地址空间之前,先要打开该文件。
l e n是映射的字节数。o f f是要映射字节在文件中的起始位移量。p ro c参数说明映射存储区的保护要求。