高级IO包含很多内容,如非阻塞IO、记录锁、IO多路转接(select和poll函数)、异步IO、readv和writev函数以及存储映射IO(mmap)等。
非阻塞IO使我们可以发出open、read和write这样的I/O操作,并使这些操作不会永远阻塞。
它的特点是:
对于一个给定的文件描述符,有两种为其指定非阻塞IO的方法:
非阻塞IO模型:
在大多Unix系统中,当两个人同时编辑一个文件时,该文件最后的状态取决于写该文件的最后一个进程。但大多数时候我们并不希望看到这样的结果,例如在数据库中。为了解决这个问题,Unix提供提供了记录锁机制。
记录锁(record locking)的功能是:当一个进程正在读或修改文件的某个部分时,使用记录锁可以阻止其他进程修改同一文件区。记录锁也可以理解为字节范围锁,因为它锁定的只是文件中的一个区域。
POSIX.1使用fcntl函数来进行记录锁相关操作,其函数原型为:
#include
int fcntl(int fd, int cmd, .../* struct flock *flockptr */);
//若成功,依赖于cmd,否则,返回-1
可选参数flockptr是一个flock结构的指针,其结构如下:
struct flock{
//锁类型 可选值是: 共享读锁:F_RDLCK 独占写锁:F_WRLCK 解锁一个区域F_UNLCK
short l_type;
//起始位置 文件头:SEEK_SET 当前位置:SEEK_CUR 文件尾:SEEK_END
short l_whence;
//相对于l_whence的偏移量,单位是byte
off_t l_start;
//区域的字节长度
off_t l_len;
//进程ID
pid_t l_pid;
}
关于该结构需要注意的是:
cmd参数决定函数fcntl的行为,其可选值如下:
注意F_GETLK与FSETLK不是原子操作,在两个函数调用之间可能有新的进程进行了加锁操作。在设置或释放文件上的一把锁时,系统会按要求组合或分裂相邻区。
记录锁在使用过程中也可能会造成死锁,需要当心。
记录锁的自动继承和释放有3条规则:
若一个进程得到一把锁,然后调用fork,那么对于父进程获得的锁而言,子进程被视为另一个进程。
p397页有图文详解。
在守护进程章节,我们知道守护进程可用一把文件锁来保证只有该守护进程的唯一副本在运行,下面展示了一种实现:
#include
#include
int lockfile(int fd)
{
struct flock fl;
fl.l_type = F_WRLCK;
fl.l_whence = SEEK_SET;
fl.l_start = 0;
fl.l_len = 0;
return (fcntl(fd,F_SETLK, &fl));
}
对于多任务系统,传统的阻塞IO、非阻塞IO以及异步IO都存在这样那样的局限性,不能很好地拓展和使用。
一种比较好的技术是使用IO多路转接(IO multiplexing)。大致过程是,先构造一张描述符表,然后调用一个函数,直到这些描述符中的一个已准备好进行IO时,该函数返回。select,pselect,poll和epoll这4个函数使我们能够执行IO多路转接。
传给select函数的参数告诉核心:
从select返回时,内核告诉我们:
有了这些信息,就可以调用相应的IO函数,并且确定该函数不会阻塞。
select函数原型:
#include
int select(int maxfdpl, fd_set *restrict readfds, fd_set *restrict writefds, fd_set *restrict exceptfds,struct timeval *restrict tvptr);
//返回值:准备就绪的描述符数目;若超时,返回0;若出错,返回-1
参数说明如下(从最后一个开始):
tvptr是一个timeval结构,指定愿意等待的时间长度,单位为秒和微妙。有以下3中情况:
中间三个参数readfds、writefds和exceptfds是指向描述符集的指针。这3个描述符集说明了我们关心的可读、可写或处于异常条件的描述符集合。每个描述符集存储在一个fd_set结构中,可以将该结构看作一个字节数组,每一位代表一个可能的描述符。
该结构的可用操作是:
#include
int FD_ISSET(int fd, fd_set *fdset); //测试在fdset中,fd所在的位是否处于打开状态。
void FD_CLR(int fd, fd_set *fdset); //清除fdset中fd所代表的位。
void FD_SET(int fd, fd_set *fdset); //开启fdset中代表fd的位
void FD_ZERO(fd_set *fdset); //将fdset全部设置为0
如果这3个参数都为NULL,则select提供比sleep更精确的定时器(微妙)。
该参数的意思是调用者设置的readfds、writefds和exceptfds3个结构中最大描述符的编号再加1。该参数的作用是帮助内核缩小描述符扫描范围,内核只需要在maxfdpl范围扫描描述符即可。
select最多只能监听1024个文件描述符,这是由于fd_set类型在内核中限定为最大1024.
pselect是select的一个变体,它提供纳秒级别的时间控制和额外的信号屏蔽功能。
#include
int pselect(int maxfdpl, fd_set *restrict readfds, fd_set *restrict writefds, fd_set *restrict exceptfds, const struct timespec *restrict tsptr, const sigset_t *restrict sigmask);
//返回值:准备就绪的描述符数目;若超时,返回0;若出错,返回-1
与select不同,poll不是为每个条件(可读性、可写性和异常条件)构造一个描述符集,而是构造一个pollfd结构的数组,每个数组元素指定一个描述符编号以及我们对该描述符该兴趣的条件。
#include
int poll(struct pollfd fdarray[], nfds_t nfds, int timeout);
struct pollfd{
int fd; //要检查的文件描述符,如果小于0则忽略
short events; //在fd上感兴趣的事件
short revents; //在fd上发生的事件
};
fdarray数组中的元素数量由nfds指定。
events和revents用于告诉内核我们关心的是每个描述符的哪些事件,可能的事件有可以不阻塞地读/写普通/高优先级的数据,具体可参考P409.返回时,revents成员由内核设置,用于说明每个描述符发生了哪些事件。
timeout参数指定等待事件,如同select一样,有3种不同的情形。
原理和Select相似,没有数量限制,但IO数量大扫描线性性能下降(随着数组元素的增多,数组的扫描性能会线性下降);
epoll是针对select/poll的不足而设计的改良方式,它的IO效率不随FD数目增加而线性下降。Epoll只会对"活跃"的socket进行操作—这是因为在内核实现中epoll是根据每个fd上面的callback函数实现的。那么,只有"活跃"的socket才会主动的去调用 callback函数,其他idle状态socket则不会。
同时,epoll还使用存储映射IO(mmap)来加速内核与用户空间的消息传递。
关于epoll有以下操作:
#include
//创建一个新的epoll实例,从linux2.6.8开始,size参数被忽略,但必须大于0.
//成功,其返回值是一个文件描述符,该描述指向新的epoll实例。失败,返回-1.
int epoll_create(int size);
//类似于epoll_create,但可以为epoll设置运行时关闭标志(close-on-exec)。
int epoll_create1(int flags);
//用于添加、修改或移除epoll实例中的兴趣列表中的条目。
//即它要求epfd所代表的epoll实例对fd执行操作op。
//具体信息参考手册:http://man7.org/linux/man-pages/man2/epoll_ctl.2.html
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
//等待epoll实例中的特定事件发生。
int epoll_wait(int epfd, struct epoll_event *events,int maxevents, int timeout);
//该函数与epoll_wait的区别于select和pselect的区别类似。
//epoll_pwait可以设置信号屏蔽字,屏蔽某些信号,防止函数被某些信号中断。
int epoll_pwait(int epfd, struct epoll_event *events,int maxevents, int timeout,const sigset_t *sigmask);
LT(level triggered)是缺省的工作方式,并且同时支持block和no-block socket.在这种做法中,内核告诉你一个文件描述符是否就绪了,然后你可以对这个就绪的fd进行IO操作。如果你不作任何操作,内核还是会继续通知你的,所以,这种模式编程出错误可能性要小一点。传统的select/poll都是这种模型的代表.
ET (edge-triggered)是高速工作方式,只支持no-block socket。在这种模式下,当描述符从未就绪变为就绪时,内核通过epoll告诉你。然后它会假设你知道文件描述符已经就绪,并且不会再为那个文件描述符发送更多的就绪通知,直到你做了某些操作导致那个文件描述符不再为就绪状态了(比如,你在发送,接收或者接收请求,或者发送接收的数据少于一定量时导致了一个EWOULDBLOCK 错误)。但是请注意,如果一直不对这个fd作IO操作(从而导致它再次变成未就绪),内核不会发送更多的通知(only once),不过在TCP协议中,ET模式的加速效用仍需要更多的benchmark确认。
Linux中IO复用的实现方式主要有select、poll和epoll:
Select:注册IO、阻塞扫描,监听的IO最大连接数不能多于FD_SIZE;
Poll:原理和Select相似,没有数量限制,但IO数量大扫描线性性能下降;
Epoll :事件驱动不阻塞,mmap实现内核与用户空间的消息传递,数量很大,Linux2.6后内核支持;
进程发起一个IO操作,进程返回(不阻塞);内核把整个IO处理完后,会通知进程结果。进程和内核间通过一个指定的缓冲区进行沟通,在异步读中,内核会向缓冲区中写数据;在异步写中,内核会从缓冲区中读数据。
POSIX为异步IO定义了一套统一的接口,现在所有的平台都被要求支持这些接口。
POSIX中的异步IO接口使用AIO控制块来描述IO操作,aiocb结构定义了AIO控制块:
struct aiocb{
int aio_fildes; //被打开用来读或写的文件描述符
off_t aio_offset; //多一些操作开始处的偏移量
volatile void *aio_buf; //IO缓冲区
size_t aio_nbytes; //要读或写的字节数
int aio_reqprio; //为异步IO请求提示顺序,系统不一定采用
struct sigevent aio_sigevent; //决定IO事件完成后,如何通知应用程序。sigevent结构下面介绍
int aio_lio_opcode; //对list IO的操作编码
//......
};
注意,异步IO操作必须显示地指定偏移量。异步IO接口并不影响操作系统维护的文件偏移量,只要不在同一个进程里把异步IO函数和传统IO函数混在一起用在同一个文件上,就不会导致什么问题。
sigevent结构:
struct sigevent{
int sigev_notify; //通知类型。如不通知、信号通知或函数调用
int sigev_signo; //信号编号
union sigval sigev_value; //函数sigev_nofity_function的唯一参数
void (*sigev_notify_function)(union sigval); //通知函数
pthread_attr_t *sigev_notify_attributes; //通知属性
};
sigev_notify字段控制通知的类型,取值可能是以下3个中的一个:
#include
//异步读
int aio_read(struct aiocb *aiocb);
//异步写
int aio_write(struct aiocb *aiocb);
//强制所有等待中的异步操作不等待而写入持久化存储中
//当op等于O_DSYNC,则类似于调用了fdatasync。当op为O_SYNC,那么操作类似于调用了fsync一样。
int aio_fsync(int op, struct aiocb *aiocb);
//获取一个异步读、写或者同步操作的完成状态
//返回值 0:异步操作完成 -1:对aio_error调用失败
//EINPROGRESS:异步读、写或同步操作仍在等待;其他情况:失败错误的返回码
aio_error(const struct aiocb *aiocb);
//如果异步调用成功,可以调用aio_return函数来获取异步操作的返回值
//异步操作完成前调用此函数的结果是未定义的;
//每个异步操作只能调用以此aio_return,一旦调用了该函数,操作系统就可以释放掉包含了IO操作返回值的记录
ssize_t aio_return(const struct aiocb *aiocb);
//如果在完成了所有事务时,还有异步操作未完成时,可以调用aio_suspend函数来阻塞进程,直到操作完成
//可能返回三种情况。 被信号中断时返回-1,并将errorno设置为EINTR;阻塞时间超过timeout时返回-1,并将errono设置为EAGAIN。
//如果有任何IO操作完成,返回0.
int aio_suspend(const struct aiocb *const list[], int nent, const struct timespec *timeout);
//取消等待中的异步IO操作
//如果aiocb为null,则系统会尝试取消所有该文件上未完成的异步IO操作
//可能的返回值有4个。AIO_ALLDONE:所有异步操作都已经完成;AIOCANCELD:所有要求的操作已被取消;
//AIO_NOTCANCELED:至少有一个要求的操作没有被取消;-1:对aio_cancel调用失败,错误码存储在errorno中
int aio_cancel(int fd, struct aiocb *aiocb);
readv和writev函数用于在一次函数调用中读、写多个非连续缓冲区。有时也将这两个函数称为散布读(scatter read)和聚集写(gather write)。
#include
//将输入数据写到不连续的缓冲区中
ssize_t readv(int fd, const struct iovec *iov, int iovcnt);
//将不连续缓冲区中的数据输出
ssize_T writev(int fd, const struct iovec *iov, int iovcnt);
//返回值:已读或已写的字节数;若出错,返回-1
将两个缓冲区中的内容连续地写到一个文件中有3种方式:
在内存足够大的情况下,2的效率最高,而1的效率最低。但随着数据量的增大,要找到一块足够大的连续缓冲区的难度越来越高,因此writev的优势也越来越大。
存储映射IO(memory-mapped I/O)能将一个磁盘文件映射到存储空间中的一个缓冲区上,当从缓冲区上读数据时,就相当于读文件中的相应字节。将数据存入缓冲区时,相应字节也会在某一个时刻(系统选择)自动写入文件。这样,就可以在不适用read和write的情况下执行I/O。
mmap性能并不一定比read、write效率高,但mmap在某些特殊场景下非常有用(如对帧缓冲设备的操作)。
#include
//存储映射建立函数
//addr:映射存储区的起始地址,通常将其设置为0,这表示由系统选择该映射区起始地址。此函数的返回值是该映射区的起始地址。
//fd:要被映射文件的描述符,在映射前要先打开该文件
//len:映射的字节数; off:要映射字节在文件中的起始偏移量
//prot:指定了映射存储区的保护要求,包含可读、可写、可执行以及不可访问4种,可以相互组合,但保护要求不能超过文件open模式访问权限。可选值参考P423或linux手册
//flag:定义对映射相关操作的特性,详情见P423或linux手册
void *mmap(void *addr, size_t len, int prot, int flag, int fd, off_t off);
//更改一个现有映射的权限
int mprotect(void *addr, size_t len, int prot);
//将已经修改的页冲洗到被映射的文件中
//如果映射是私有的(mmap中flag参数),那么不修改被映射的文件。
//flags需要至少指定为MS_ASYNC和MS_SYNC中的一个。MS_ASYNC:指定安排更新,但调用立即返回;MS_SYNC:等待冲洗完成才返回。
int msync(void *addr, size_t len, int flags);
//解除映射
//munmap并不影响被映射的对象,即调用munmap并不会使映射区的内容写到磁盘文件上。在解除映射后,对MAP_SHARED区磁盘的更新,会在后面某个时刻由内核写入。对MAP_PRIVATE存储区的修改会被丢弃。
int munmap(void *addr, size_t len);
对于mmap,需要注意,off的值和addr的值,通常被要求是系统虚拟存储页长度的整数倍。子进程能通过fork继承存储映射区,但新程序不能通过exec继承。
当进程终止时,会自动解除存储映射区的映射,或者直接调用munmap函数手动解除。关闭被映射的文件描述符并不解除映射区。
https://blog.csdn.net/tjiyu/article/details/52959418