Unix14-高级I/O (加Linux的epoll)
Unix编程第14章
本章涵盖众多概念和函数,统称为高级I/O:非阻塞I/O、记录锁、I/O多路转接(select和poll函数)、异步I/O、readv和writev函数以及存储映射I/O(mmap)。
14.2 非阻塞I/O
之前曾将系统调用分为两类:低速系统调用和其它。低速系统调度是可能会使进程永远阻塞的一类系统调用,包括:
-如果某些文件类型(如读管道、终端设备和网络设备)的数据并不存在,读操作可能会使调用者永远阻塞;
-如果数据不能被相同的文件类型立即接受(如管道中无空间、网络流控制),写操作可能会使调用者永远阻塞;
-在某种条件发生之前打开某些文件类型可能会发生阻塞(如要打开一个终端设备,需要先等待与之连接的调制解调器应答,又如若以只写模式打开FIFO,那么在没有其它进程已用读模式打开该FIFO时也要等待)。
-对已经加上强制性记录锁的文件进行读写;
-某些ioctl操作;
-某些进程间通信函数。
对于一个给定的描述符,有两种为其指定非阻塞I/O的方法:
1)如果调用open获得描述符,则可指定O_NONBLOCK标志。
2)对于已经打开的一个描述符,则可调用fcntl,由该函数打开O_NONBLOCK文件状态标志。
14.3 记录锁
文件有可能被多个进程同时编辑。Unix提供记录锁机制来保证进程可以单独写一个文件。
记录锁(record locking)的功能是:当第一个进程正在读或修改文件的某个部分时,使用记录锁可以阻止其它进程修改同一文件区。它锁定的只是文件中的一个区域。
POSIX.1标准的基础方法是fcntl方法。
#include<fcntl.h>
int fcntl(int fd, int cmd, .../* struct flock *flockptr */);
返回值:若成功,依赖于cmd,否则返回-1.
对于记录锁,cmd是F_GETLK、F_SETLK或F_SETLKW。第三个参数是一个指向flock结构的指针。
struct flock{
short l_type; //F_RDLCK, F_WRLCK,or F_UNLCK
short l_whence; //SEEK_SET, SEEK_CUR or SEEK_END
off_t l_start; //offset in bytes, relative to l_whence
off_t l_len; //length, in bytes; 0 means lock ro EOF
pid_t l_pid; //returned with F_GETLK
};
对flock结构说明如下:
-所希望的锁类型:F_RDLCK(共享读锁)、F_WRLCK(独占性写锁)或F_UNLCK(解锁一个区域)
-要加锁或解锁区域的起始字节偏移量(l_start和l_whence)。
-区域的字节长度(l_len)。
-进程的ID(l_pid)持有的锁能阻塞当前进程(仅由F_GETLK返回)。
加锁和解锁区域的注意事项:
-指定区域起始偏移量的两个元素与lseek函数中最后两个参数类似。l_whence可选的值是SEEK_SET、SEEK_CUR或SEEK_END。
-锁可以在当前文件尾端处开始或者越过尾端处开始,但是不能在文件起始位置之前开始。
-如若l_len为0,则表示锁的范围可以扩大到最大可能偏移量。这意味着不管向该文件中追加写多少数据,它们都可以处于锁的范围内,而且起始位置可以是文件中的任意一个位置。
-为了对整个文件加锁,我们设置l_start和l_whence指向文件的起始位置,并且指定长度l_len为0.
共享读锁和独占性写锁,基本规则是:任意多个进程在一个给定的字节上可以有一把共享的读锁,但是在给定字节上只能有一个进程有一把独占写锁。
如果在一个给定进程上已经有一把或多把读锁,则不能在该字节上再加写锁。如果有一把独占性写锁,则不能加任何锁。
在单进程中,如果该进程对一个文件区间已经有了一把锁,后来该进程又企图在同一文件区间再加一把锁,那么新锁将替换已有锁。(但要确保读锁变写锁时该区间没有其它进程加上的读锁。)
加读锁时,该描述符必须是读打开。加写锁时,该描述符必须是写打开。
记录锁的三种命令:
F_GETLK 判断由flockptr所描述的锁是否会被另外一把锁所排斥(阻塞)。如果存在一把锁,它阻止创建由flockptr所描述的锁,则该现有锁的信息将重写flockptr指向的信息。如果不存在这种情况,则除了将l_type设置为F_UNLCK之外,flockptr所指向结构中的其它信息保持不变。
F_SETLK 设置由flockptr所描述的锁。如果我们试图获得一把读锁(l_type为F_RDLCK)或写锁(l_type为F_WRLCK),而兼容性规则阻止系统给我们这把锁,那么fcntl会立即出错返回,此时errno设置为EACCES或EAGAIN。此命令也用来清除由flockptr指定的锁(l_type为F_UNLCK)。
F_SETLKW 这个命令是F_SETLK的阻塞版本(命令名中的W表示等待(wait))。如果所请求的读锁或写锁因另一个进程当前已经对所请求区域的某部分进行了加锁而不能被授予,那么调用进程会被置为休眠。如果请求创建的锁已经可用,或者休眠由信号中断,则该进程被唤醒。
应当理解,用F_GETLK测试能否建立一把锁,然后用S_SETLK或F_SETLKW企图建立那把锁,这两者不是一个原子操作。因此不能保证在这两次fcntl调用之间不会有另一个进程插入并建立一把相同的锁。如果不希望在等待锁变为可用时产生阻塞,就必须处理由F_SETLK返回的可能的出错。
在设置或释放文件上的一把锁时,系统按要求组合或分裂相邻区。
检测到死锁时,内核必须选择一个进程接收出错返回。
锁的隐含继承和释放:
1)锁与进程和文件两者相关联。当一个进程终止时,它所建立的锁全部释放;无论一个描述符何时关闭,该进程通过这一描述符引用的文件上的任何一把锁都会释放(这些锁都是该进程设置的)。
2)由fork产生的子进程不继承父进程所设置的锁。(每个锁会指向对应的进程标识符)这意味着,若一个进程的到一把锁,然后调用fork,那么对于父进程获得的锁而言,子进程被视为另一个进程。对于通过fork从父进程处继承过来的描述符,子进程需要调用fcntl才能获得它自己的锁。这个约束是有道理的,因为锁的作用是阻止多个子进程同时写同一个文件。如果子进程通过fork继承父进程的锁,则父进程和子进程就可以同时写同一个文件(X)。
3)在执行exec后,新程序可以继承原执行程序的锁。但是注意,如果对一个文件描述符设置了执行时关闭标志,那么作为exec的一部分关闭该文件描述符时,将释放相应文件的所有锁。
建议性锁和强制性锁:
考虑数据库访问例程库。如果该库中所有函数都以一致的方法处理记录锁,则称使用这些函数访问数据库的进程集为合作进程。如果这些函数是唯一地用来访问数据库的函数,那么它们使用建议性锁是可行的。但是建议性锁并不能阻止对数据库文件有写权限的任何其它进程写这个数据库文件。不使用数据库访问例程库协同一致的方法来访问数据库的进程是非合作进程。
强制性锁会让内核检查每一个open、read和write,验证调用进程是否违背了正在访问的文件上的某一把锁。强制性锁有时也称为强迫方式锁。
对一个特定文件打开其设置组ID位,关闭其组执行位便开启了该文件的强制性锁机制。因为当组执行位关闭时,设置组ID位不再有意义。
如果一个进程试图读或写一个强制性锁起作用的文件,而欲读、写的部分又由其他进程加上了锁,会发生什么呢?这取决于操作类型(read或write)、其它进程持有的锁的类型(读锁或写锁)以及read或write的描述符是阻塞的还是非阻塞的。
其它进程在该区域上持有的现有锁的类型 | 阻塞描述符 | 非阻塞描述符 | ||
read | write | read | write | |
读锁 | 允许 | 阻塞 | 允许 | EAGAIN |
写锁 | 阻塞 | 阻塞 | EAGAIN | EAGAIN |
除了read和write函数外,另一个进程持有的强制性锁也会对open函数产生影响。通常,即使正在打开的文件具有强制性记录锁,该open也会成功。随后的read或write依从上表规则。但是,如果想要打开的文件具有强制性锁,而且open调用中的标志指定为O_TRUNC或O_CREAT,则无论是否指定O_NONBLOCK,open都立即出错返回,errno设置为EAGAIN。
14.4 I/O多路转接
当从一个描述符读,然后又写到另一个描述符时,可以在循环中使用阻塞I/O。(read----->write------>read---)
但是如果必须从两个描述符读,这时我们不能在任一个描述符上进行阻塞读,否则可能会因为被阻塞在一个描述符的读操作上而导致另一个描述符即使有数据也无法处理。
解决方法有:
1)用多个进程分别处理不同的数据。这会使程序复杂。
2)使用一个进程,但是用非阻塞I/O读取数据。这种形式为轮询,浪费CPU时间,大多数时间实际上是无数据可读,因此执行read系统调用浪费了时间。
3)异步I/O。这种技术,进程告诉内核:当描述符准备好可以进行I/O时,用一个信号通知它。这种技术有两个问题:一是标准化,二是这种信号对每个进程而言只有1个(SIGPOLL或SIGIO)。如果使该信号对两个描述符都起作用,那么进程在接到此信号时将无法判断是哪一个描述符准备好了。
4)另一种比较好的技术是使用I/O多路转接(I/O multiplexing)。为了使用这种技术,先构造一张我们感兴趣的描述符的列表,然后调用一个函数,直到这些描述符中的一个已经准备好进行I/O时,该函数才返回。
14.4.1 函数select和pselect
在所有POSIX兼容的平台上,select函数使我们可执行I/O多路转接。传给select的参数告诉内核:
-我们所关心的描述符;
-对于每个描述符我们所关心的条件(是否想从一个给定的描述符读,是否想写一个给定的描述符,是否关心一个给定描述符的异常条件)。
-愿意等待多长时间(可以永远等待、等待一个固定的时间或者根本不等待)。
从select返回时,内核告诉我们:
-已准备好的描述符的总数量;
-对于读、写或异常这3个条件中的每一个,哪些描述符已准备好。
使用这种返回信息,就可以调用相应的I/O函数(一般是read/write),并且确知该函数不会阻塞。
#include<sys/select.h>
int select{int maxfdp1, fd_set *restrict readfs, fd_set *restrict writefds, fd_set *restrict exceptfds, struct timeval *restrict tvptr);
返回值:准备就绪的描述符数目;若超时,返回0;若出错,返回-1.
最后一个参数tvptr,它指定愿意等待的时间长度,单位为秒和微秒,有以下3种情况:
tvptr==NULL 永远等待。如果捕捉到一个信号则中断此无限期等待。当所指定的描述符中的一个已经准备好或捕捉到一个信号则返回。如果捕捉到一个信号,则select返回-1,errno设置为EINTR。
tvptr->tv_sec==0 && tvptr->tv_usec==0 根本不等待。测试所有指定的描述符并立即返回。这是轮询系统找到多个描述符状态而不阻塞select函数的方法。
tvptr->tv_sec != 0 || tvptr->tv_usec != 0 等待指定的秒数和微秒数。当指定的描述符之一已准备好,或当指定的时间值已经超过时立即返回。如果在超时到期时还没有一个描述符准备好,则返回值是0.(如果系统不提供微秒级的精度,则tvptr->tv_usec值取整到最接近的支持值。)与第一种情况一样,这种等待可以被信号中断。
中间三个参数readfds、writefds和exceptfds是指向描述符集的指针。这3个描述符集说明了我们关心的可读、可写或处于异常条件的描述符集合。每个描述符集存储在一个fd_set数据类型中。这个数据类型是由实现选择的,它可以为每一个可能的描述符保持一位。我们可以认为它只是一个很大的字节数组。
对于fd_set数据类型,唯一可以进行的处理是:分配一个这种类型的变量,将这种类型的一个变量赋给同类型的另一个变量,或对这种类型的变量使用下列4个函数中的一个。
#includ<sys/select.h>
int FD_ISSET(int fd, fd_set *fdset);
返回值:若fd在描述符集中,返回非0值;否则,返回0
void FD_CLR(int fd, fd_set *fdset);
void FD_SET(int fd, fd_set *fdset);
void FD_ZERO(fd_set *fdset);
这些接口可实现为宏或函数。调用FD_ZERO将一个fd_set变量的所有位设置为0.要开启描述符集中的一位,可以调用FD_SET。调用FD_CLR可以清除一位。最后,可以调用FD_ISSET测试描述符集中的一个指定位是否已打开。
select函数中的这三个fd_set参数中的任意一个或全部可以是空指针,这表示对相应条件并不关心。如果3个指针都是NULL,则select提供了比sleep更精确的定时器。(sleep等待整数秒,select精度取决于系统时钟)。
select函数第一个参数maxfdp1的意思是“最大文件描述符编号值加1”。考虑3个描述符集,在其中找到最大描述符编号值,然后加1,这就是第一个参数值。也可以将第一个参数设置为FD_SETSIZE,它指定的是最大描述符数(通常1024),但太大所以不适合大部分情况。
三个fd_set参数调用后的值经过了重新赋值,调用后的值是发生了读、写或异常的三种描述符集。
select返回值:
1)-1,出错。在所有描述符一个都没准备好时捕捉到一个信号。此时,三个描述符集此时不会修改。
2)0,表示没有描述符准备好。若指定的描述符一个都没准备好,但超时时间已到。此时,所有描述符集都为空。
3)正的返回值,说明了已经准备好的描述符数。该值是3个描述符集中已准备好的描述符数之和,所以如果同一描述符已准备好读和写,那么在返回值中会对其计两次数。此时,3个描述符集中仍旧打开的位对应于已准备好的描述符。
一个描述符”准备好“的意思是:
-若对读集readfds中的一个描述符进行的read操作不会阻塞,则认为此描述符是准备好的。
-若对写集writefds中的一个描述符进行的write操作不会阻塞,则认为此描述符是准备好的。
-若对异常条件集exceptfds中的一个描述符有一个未决异常条件,则认为此描述符是准备好的。现在,异常条件包括:在网络连接上到达带外的数据,或者在处于数据包模式的伪终端上发生了某些条件。
-对于读、写和异常条件,普通文件的文件描述符总是返回准备好。
一个描述符是否阻塞不影响select是否阻塞。如果希望读一个非阻塞描述符,超时值为5秒,则在select最多阻塞5秒(假设无准备好的描述符)。如果指定一个无限的超时值,则在描述符数据准备好之前,select一直阻塞。
如果在一个描述符上碰到了文件尾端,则select会认为该描述符是可读的。然后调用read,它返回0,这时Unix系统指示到达文件尾端的方法。
POSIX.1也定义了一个select的变体,称为pselect:
#include<sys/select.h>
int pselect( int maxfdp1, fd_set *restrict readfds, fd_set *restrict writefds, fd_set *restrict exceptfds, const struct timespec *restrict tsptr, const sigset_t *restrict sigmask);
返回值:准备就绪的描述符数目;若超时,返回0;若出错,返回-1.
除了下列几点,pselect与select相同:
-select的超时值用timeval结构指定,但pselect使用timespec结构。timespec结构以秒和纳秒表示超时值,而非秒和微秒。如果平台支持这样的时间精度,那么timespec就能提供更精准的超时时间。
-pselect的超时值被声明为const,这保证了调用pselect不会改变此值。
-pselect可使用可选信号屏蔽字。若sigmask为NULL,那么在与信号有关的方面,pselect的运行状况和select相同。否则,sigmask指向一信号屏蔽字,在调用pselect时,以原子操作的方式安装信号屏蔽字。在返回时,恢复以前的信号屏蔽字。
14.4.2 函数poll
poll函数类似于select,但是程序接口有所不同。poll函数可用于任何类型的文件描述符。
#include<poll.h>
int poll( struct pollfd fdarray[], nfds_t nfds, int timeout);
返回值:准备就绪的描述符数目;若超时,返回0;若出错,返回-1。
与select不同,poll不是为了每个条件(可读性、可写性和异常条件)构造一个描述符集,而是构造一个pollfd结构的数组,每个数组元素指定一个描述符编号以及我们对该描述符感兴趣的条件。
struct pollfd{
int fd; //要检查的文件描述符
short events; //fd上的感兴趣事件
short revents; //fd上发生的事件
};
fdarray数组中的元素数目由nfds指定。
fdarray数组中每个pollfd的成员events可由下表中的一个或几个的按位或:返回时revents成员由内核设置,用于说明每个描述符实际发生了哪些事件。
标志名 | 输入至events? | 从revents得到结果 | 说明 |
POLLIN | Y | Y | 可以不阻塞地读高优先级数据以外的数据(等效于POLLRDNORM | POLLRDBAND) |
POLLRDNORM | Y | Y | 可以不阻塞地读普通数据 |
POLLRDBAND | Y | Y | 可以不阻塞地读优先级数据 |
POLLPRI | Y | Y | 可以不阻塞地读高优先级数据 |
POLLOUT | Y | Y | 可以不阻塞地写普通数据 |
POLLWRNORM | Y | Y | 与POLLOUT相同 |
POLLWRBAND | Y | Y | 可以不阻塞地写优先级数据 |
POLLERR | Y | 已出错 | |
POLLHUP | Y | 已挂断 | |
POLLNVAL | Y | 描述符没有引用一个打开文件 |
前4行测试可读性,接下来的3行测试可写性,后3行只用于内核写revenrs返回,即使events不能指定后3行数据,如果相应条件发生,在revents中也会返回它们。
当一个操作符被挂断POLLHUP后,就不能再写该描述符,但是有可能仍然可以从该描述符读取到数据。
poll函数的最后一个参数指定我们愿意等待多长时间:
timeout==-1 永远等待。当所指定的描述符中的一个已准备好,或捕捉到一个信号时返回。如果捕捉到一个信号,则poll返回-1,errno设置为EINTR。
timeout==0 不等待。测试所有描述符并立即返回。这是一种轮询系统的方法,可以找到多个描述符地状态而不阻塞poll函数。
timeout > 0 等待timeout毫秒。当指定的描述符之一已准备好,或timeout到期时立即返回。如果timeout到期时还没有一个描述符准备好,则返回值是0.如果系统不提供毫秒级,则timeout取整到最近的支持值。
一个描述符是否阻塞也不会影响poll是否阻塞。
select和poll的可中断性
中断系统调用自动重启是4.2BSD引入的。(select和poll分别有对应的系统调用)
14.5 异步I/O
异步I/O是受限制的:它们并不能用在所有的文件类型上,而且只能使用一个信号。如果要对一个以上的描述符进行异步I/O,那么在进程接收到该信号时并不知道这一信号对应于哪一个描述符。
在应用异步I/O时,要通过选择来灵活处理多个并发操作,这会使应用程序的设计复杂化。更简单的做法可能是使用多线程,使用同步模型来编写程序,并让这些线程以异步的方式运行。
使用POSIX异步I/O接口,会带来下列麻烦:
-每个异步操作有3处可能产生错误的地方:一处在操作提交的部分,一处在操作本身的结果,还有一处在用于决定异步操作状态的函数中。
-与POSIX异步I/O接口的传统方法相比,它们本身涉及大量的额外设置和处理规则。
-从错误中恢复可能会比较困难。例如,如果提交了多个异步写操作,其中一个失败了,下一步应该怎么做?如果这些写操作是相关的,那么可能还需要撤销所有成功的写操作。
14.5.3 POSIX异步I/O
POSIX异步I/O接口使用AIO控制块来描述I/O操作。aiocb结构定义了AIO控制块。该结构至少包括下面这些字段(具体的实现可能还包含额外的字段):
struct aiocb{
int aio_fields; //文件描述符
off_t aio_offset; //文件I/O偏移
volatile void *aio_buf; //I/O缓冲
size_t aio_nbytes; //需要transfer的字节数
int aio_reqprio; //优先级
struct sigevent aio_sigevent; //信号信息
int aio_lio_opcode; //operation for list I/O,用于列表,因为单个的异步操作可以用aio_write和aio_read调用,列表时多个aiocb数组中每一个需要该标识指定操作
};
aio_fields字段表示被打开用来读或写的文件描述符。读或写操作从aio_offset指定的偏移量开始。对于读操作,数据会复制到缓冲区中,该缓冲区从aio_buf指定的地址开始。对于写操作,数据会从这个缓冲区中复制出来。aio_nbytes字段包含了要读或写的字节数。
注意,异步I/O操作必须显式指定偏移量。异步I/O接口并不影响由操作系统维护的文件偏移量。只要不在同一个进程里把异步I/O函数和传统I/O函数混在一起用在同一个文件上,就不会导致什么问题。同时值得注意的是,如果使用异步I/O接口向一个以追加模式(使用O_APPEND)打开的文件中写入数据,AIO控制块中的aio_offset字段会被系统忽略。
应用程序使用aio_reqprio字段为异步I/O请求提示顺序。然而,系统对于该顺序只有有限的控制能力,因此不一定能遵循该提示。
aio_lio_opcode字段只能用于基于列表的异步I/O。
aio_sigevent字段控制在I/O事件完成后,如何通知应用程序,这个字段通过sigevent结构来描述。
struct sigevent{
int sigev_notify; //notify 类型
int sigev_signo; //信号编号
union sigval sigev_value; //notify实参
void (*sigev_notify_function) (union sigval); //notify函数
pthread_attr_t *sigev_notify_attributes; //notify属性
};
sigev_notify字段控制通知的类型。取值可能是下列3个中的一个:
1)SIGEV_NONE 异步I/O请求完成后,不通知进程。
2)SIGEV_SIGNAL 异步I/O请求完成后,产生由sigev_signo字段指定的信号。如果应用程序已选择捕捉该信号,其在建立信号处理程序的时候指定了SA_SIGINFO标志,那么该信号将被入队(如果实现支持排队信号)。信号处理程序会传送给一个siginfo结构,该结构的si_value字段被设置为sigev_value(如果使用了SA_SIGINFO标志)。
3)SIGEV_THREAD 当异步I/O请求完成时,由sigev_notify_function字段指定的函数被调用。sigev_value字段被传入作为它的唯一参数。除非sigev_notify_attributes字段被设定为pthread属性结构的地址,且该结构指定了一个另外的线程属性,否则该函数将在分离状态下的一个单独的线程中执行。
在进行异步I/O之前需要先初始化AIO控制块,调用aio_read函数来进行异步操作,或调用aio_write函数来进行异步写操作。
#include<aio.h>
int aio_read( struct aiocb *aiocb);
int aio_write( struct aiocb *aiocb);
两个函数的返回值:若成功,返回0;若出错,返回-1。
当这些函数返回成功时,异步I/O请求便已经被操作系统放入等待处理的队列中了。这些返回值与实际I/O操作的结果没有任何关系。I/O操作在等待时,必须注意确保AIO控制块和数据库缓冲区保持稳定;它们下面对应的内存必须始终是合法的,除非I/O操作完成,否则不能被复用。
要想强制所有等待中的异步操作不等待而写入持久化的存储中,可以设立一个AIO控制块并调用aio_fsync函数。
#include<aio.h>
int aio_fsync( int op, struct aiocb *aiocb);
返回值:若成功,返回0;出错则返回-1。
AIO控制块中的aio_fields字段指定了其异步写操作被同步的文件。如果op参数设定为O_DSYNC,那么操作执行起来就会像调用了fdatasync一样。否则,如果op参数设定为O_SYNC,那么操作执行起来就会像调用了fsync一样。
为了获知一个异步读、写或者同步操作的完成状态,需要调用aio_error函数。
#include<aio.h>
int aio_error( const struct aiocb *aiocb);
返回值为下面4种情况之一:
0 异步操作成功完成。需要调用aio_return函数获取操作返回值。
-1 对aio_error的调用失败。这种情况下,errno会告诉我们为什么。
EINPROGRESS 异步读、写或同步操作仍在等待。
其它情况 其它任何返回值是相关的异步操作失败返回的错误码。
如果异步操作成功,可以调用aio_return函数来获取异步操作的返回值。
#include<aio.h>
ssize_t aio_return( const struct aiocb *aiocb); //异步操作的返回值保存在操作系统中,但不是在这个aiocb数据结构中
直到异步操作完成之前,都需要小心不要调用aio_return函数。操作完成之前的结果是未定义的。还需要小心对每个异步操作只调用一次aio_return。一旦调用了该函数,操作系统就可以释放掉包含了I/O操作返回值的记录。
如果aio_return函数本身失败,会返回-1,并设置errno。其它情况下,它将返回异步操作的结果,即会返回read、write或者fsync在被成功调用时可能返回的结果。
执行I/O操作时,如果还有其它事务要处理而不想被I/O操作阻塞,就可以使用异步I/O。然而,如果在完成了所有事务时,还有异步操作未完成时,可以调用aio_suspend函数来阻塞进程,直到操作完成。
#include<aio.h>
int aio_suspend( const struct aiocb *const list[], int nent, const struct timspec *timeout);
返回值:成功返回0,出错返回-1。
aio_suspend可能会返回3种情况中的一种。如果我们被一个信号中断,它将返回-1,并将errno设置为EINTR。如果在没有任何I/O操作完成的情况下,阻塞的时间超过了函数中可选的timeout参数所指的时间限制,那么aio_suspend将返回-1,并将errno设置为EAGAIN(不想设置任何时间限制的话,可以把空指针传给timeout参数)。如果有任何I/O操作完成,aio_suspend将返回0.如果在我们调用aio_suspend操作时,所有的异步I/O操作都已完成,那么aio_suspend将在不阻塞的情况下直接返回。
list参数是一个指向AIO控制块数组的指针,nent参数表明了数组中的条目数。数组中的空指针会被跳过,其它条目都必须指向已用于初始化异步I/O操作的AIO控制块。
当还有我们不想再完成的等待中的异步I/O操作时,可以尝试使用aio_cancel函数来取消它们。
#include<aio.h>
int aio_cancel( int fd, struct aiocb *aiocb);
fd参数指定了那个未完成的异步I/O操作的文件描述符。如果aiocb参数为NULL,系统将会尝试取消所有该文件上未完成的异步I/O操作。其它情况下,系统将尝试取消由AIO控制块描述的单个异步I/O操作。我们之所以说系统尝试取消操作,是因为无法保证系统能够取消正在进程中的任何操作。
aio_cancel函数可能会返回以下4个值中的一个:
AIO_ALLDONE 所有操作在尝试取消它们之前已经完成。
AIO_CANCELED 所有要求的操作已被取消。
AIO_NOTCANCELED 至少有一个要求的操作没有被取消。
-1 对aio_cancel的调用失败,错误码将被存储在errno中。
如果异步I/O操作被成功取消,对相应的AIO控制块调用aio_error函数将会返回错误ECANCELED。如果操作不能被取消,那么相应的AIO控制块不会因为对aio_cancel的调用而被修改。
还有一个函数也被包含在异步I/O接口中,尽管它既能以同步的方式来使用,又能以异步的方式来使用,这个函数就是lio_listio。该函数提交一系列由一个AIO控制块列表描述的I/O请求。
#include<aio.h>
int lio_listio( int mode, struct aiocb *restrict const list[restrict], int nent, struct sigevent *restrict sigev);
返回值:若成功,返回0;若出错,返回-1。
mode参数决定了I/O是否真的是异步的。如果该参数被设定为LIO_WAIT,lio_listio函数将在所有由列表指定的I/O操作完成后返回。在这种情况下,sigev参数将被忽略。如果mode参数被设定为LIO_NOWAIT,lio_listio函数将在I/O请求入队后立即返回。进程将在所有I/O操作完成后,按照sigev参数指定的,被异步地通知。如果不想被通知,可以把sigev设定为NULL。注意,每个AIO控制块本身也可能启用了在各自操作完成时的异步通知。被sigev参数指定的异步通知是在此之外另加的,并且只会在所有的I/O操作完成后发送。
list参数指向AIO控制块列表,该列表指定了要运行的I/O操作的。nent参数指定了数组中的元素个数。AIO控制块列表可以包含NULL指针,这些条目将被忽略。
在每一个AIO控制块中,aio_lio_opcode字段指定了该操作是一个读操作(LIO_READ)、写操作(LIO_WRITE),还是将被忽略的空操作(LIO_NOP)。读操作会按照对应的AIO控制块被传送给aio_read函数来处理。类似地,写操作会按照对应的AIO控制块被传给了aio_write函数来处理。
实现会限制我们不想完成的异步I/O操作的数量。这些限制都是运行时不变量。
名称 | 描述 | 可接受的最小值 |
AIO_LISTIO_MAX | 单个列表I/O调用中的最大I/O操作数 | _POSIX_AIO_LISTIO_MAX |
AIO_MAX | 未完成的异步I/O操作的最大数目 | _POSIX_AIO_MAX |
AIO_PRIO_DELTA_MAX | 进程可以减少的其异步I/O优先级的最大值 |
0 |
14.6 函数readv和writev
readv和writev函数用于在一次函数调用中读、写多个非连续缓冲区。有时也将这两个函数称为散布读(scatter read)和聚集写(gather write)。
#include<sys/uio.h>
ssize_t readv( int fd, const struct iovec *iov, int iovcnt); //从描述符所指文件中读取数据到缓冲区中
ssize_t writev( int fd, const struct iovec *iov, int iovcnt); //将缓冲区中数据写到描述符指向文件中
两个函数的返回值:已读或已写的字节数;若出错,返回-1。
这两个函数的第二个参数是指向iovec结构数组的一个指针:
struct iovec{
void *iov_base; //缓冲区其实地址
size_t iov_len; //缓冲区大小
};
iov数组中的元素由iovcnt指定,其最大值受限于IOV_MAX。
writev函数从缓冲区中聚集输出数据的顺序是:iov[0]、iov[1]直至iov[iovcnt-1]。writev返回输出的字节总数,通常应等于所有缓冲区长度之和。
readv函数则将读入的数据按上述同样顺序散布到缓冲区中。readv总是先填满一个缓冲区,然后再填写下一个。readv返回读到的字节总数。如果遇到文件尾端,已无数据可读,则返回0。
14.7 函数readn和writen
管道、FIFO以及某些设备(特别是终端和网络)有以下两种性质:
1)一次read操作所返回的数据可能少于所要求的数据,即使还没达到文件尾端也可能是这样。这不是一个错误,应当继续读该设备。
2)一次write操作的返回值也可能少于指定输出的字节数。这可能是由某个因素造成的,例如,内核输出缓冲区变满。这也不是错误,应当继续写余下数据。(通常只有非阻塞描述符,或捕捉到一个信号时,才发生这种write的中途返回)。
14.8 存储映射I/O
存储映射I/O能将一个磁盘文件映射到存储空间中的一个缓冲区上,于是,当从缓冲区中取数据时,就相当于读文件中的相应字节。与此类似,将数据存入缓冲区时,相应字节就自动写入文件。这样,就可以在不使用read和write的情况下执行I/O。
为了使用这种功能,应首先告诉内核将一个给定的文件映射到一个存储区域中。这是由mmap函数实现的。
#include<sys/mman.h>
void *mmap( void *addr, sizr_t len, int prot, int flags, int fd, off_t off);
返回值:若成功,返回映射区的起始地址;若出错,返回MAP_FAILED。
addr参数用于指定映射区的起始地址。通常将其设置为0,这表示由系统选择该映射区的起始地址。此函数的返回值是该映射区的起始地址。
fd参数是指定要被映射的文件的描述符。在文件映射到地址空间之前,必须先打开该文件。len参数是映射的字节数,off是要映射字节在文件中的起始偏移量。
prot参数指定了映射存储区的保护要求,如下表:
prot | 说明 |
PROT_READ | 映射区可读 |
PROT_WRITE | 映射区可写 |
PROT_EXEC | 映射区可执行 |
PROT_NONE | 映射区不可访问 |
可将prot参数指定为PROT_NONE,也可指定为PROT_READ、PROT_WRITE和PROT_EXEC的任意组合的按位或。对指定映射区的保护要求不能超过文件open模式访问权限。
映射区位于堆和栈之间:这属于细节,各种实现之间可能不同。
图中“起始地址”是mmap函数的返回值。
下面是flags参数影响映射存储区的多种属性:
MAP_FIXED 返回值必须等于addr。因为这不利于可移植性,所以不鼓励使用此标志。如果未指定此标志,而且addr非0,则内核只把addr视为在何处设置映射区的一种建议,但是不保证会使用所要求的地址。将addr指定为0可获得最大可移植性。
MAP_SHARED 这一标志描述了本进程对映射区所进行的存储操作的配置。此标志指定存储操作修改映射文件,也就是,存储操作相当于对该文件的write。必须指定本标志或下一个标志(MAP_PRIVATE)之一,但不能同时指定两者。
MAP_PRIVATE 本标志说明,对映射区的存储操作导致创建该映射文件的一个私有副本。所有后来对该映射区的引用都是引用该副本。(此标志的一种用途是用于调试程序,它将程序文件的正文部分映射至存储区,但允许用户修改其中的指令。任何修改只影响程序文件的副本,而不影响源文件。)
off的值和addr的值(如果指定了MAP_FIXED)通常被要求是系统虚拟存储页长度的倍数。虚拟存储页长可用带参数SC_PAGESIZE或SC_PAGE_SIZE的sysconf函数得到。因为off和addr常常指定为0,所以这种要求一般并不重要。
与映射区相关的信号有SIGSEGV和SIGBUS。信号SIGSEGV通常用于指示进程试图访问对它不可用的存储区。如果映射存储区被mmap指定成了只读的,那么进程试图将数据存入这个映射存储区的时候,也会产生此信号。如果映射区的某个部分在访问时已不存在,则产生SIGBUS信号。例如,假设文件长度影射了一个文件,但在引用该映射区之前,另一个进程已将该文件截断,此时如果进程试图访问对应于该文件已截去部分的映射区,将会接收到SIGBUS信号。
子进程能通过fork继承存储映射区(因为子进程复制父进程地址空间,而存储映射区是该地址空间中的一部分),但是由于同样的原因,新程序则不能通过exec继承存储映射区。
调用mprotect可以更改一个现有映射的权限。
#include<sys/mman.h>
int mprotect( void *addr, size_t len, int prot);
返回值:若成功,返回0;若出错,返回-1。
prot的合法值与mmap中prot参数的一样。地址参数addr必须是系统页长度的整数倍。
如果修改的页是通过MAP_SHARED标志映射到地址空间的,那么修改并不会立即写回到文件中。相反,何时写回脏页由内核的守护进程决定,决定的依据是系统负载和用来限制在系统失败事件中的数据损失的配置参数。因此,如果只修改了一页中的一个字节,当修改被写回到文件中时,整个页都会被写回。
如果共享映射中的页已修改,那么可以调用msync将该页冲洗到被映射的文件中。msync函数类似于fsync,但作用于存储映射区。
#include<sys/mman.h>
int msync( void *addr, size_t len, int flags);
返回值:若成功,返回0;若出错,返回-1。
如果映射是私有的,那么不修改被映射的文件。
flags参数使我们对如何冲洗存储区有某种程度的控制。可以指定MS_ASYNC标志来简单地调试要写的页。如果希望在返回之前等待写操作完成,则可指定MS_SYNC标志。一定要指定MS_ASYNC和MS_SYNC中的一个。
MS_INVALIDATE是一个可选标志,允许我们通知操作系统丢弃那些与底层存储器没有同步的页。
当进程终止时,会自动解除存储映射区的映射,或者直接调用munmao函数也可以解除映射区。关闭映射存储区时使用的文件描述符并不解除映射区。
#include<sys/mman.h>
int munmap( void *addr, size_t len);
返回值:若成功,返回0;若出错,返回-1。
munmap并不影响被映射的对象,也就是说,调用munmap并不会使映射区的内容写到磁盘文件上。对于MAP_SHARED区磁盘文件的更新,会在我们将数据写到存储映射区后的某个时刻,按内核虚拟存储算法自动进行。在存储区解除映射后,对MAP_PRIVATE存储区的修改会被丢弃。
PS:I/O多路转接第三个接口epoll(用于Linux服务器中)
Linux服务器编程 9.3 epoll系列系统调用
1. 内核事件表
epoll是Linux特有的I/O复用函数。它在实现和使用上与select、poll有很大差异。首先,epoll使用一组函数来完成任务,而不是单个函数。其次epoll把用户关心的文件描述符上的事件放在内核里的一个事件表中,从而无须像select和poll那样每次调用都要重复传入文件描述符集或事件集。但epoll需要使用一个额外的文件描述符,来唯一标识内核中的这个事件表。这个文件描述符使用如下epoll_create函数来创建:
#include<sys/epoll.h>
int epoll_create( int size)
size参数现在并不起作用,只是给内核一个提示,告诉它事件表需要多大。该函数返回的文件描述符将用作其它所有epoll系统调用的第一个参数,以指定要访问的内核事件表。
下面的函数用来操作epoll的内核事件表:
#include<sys/epoll.h>
int epoll_ctl( int epfd, int op, int fd, struct epoll_event *event)
fd参数是要操作的文件描述符,op参数则指定操作类型。操作类型有如下3种:
-EPOLL_CTL_ADD 往事件表中注册fd上的事件。
-EPOLL_CTL_MOD 修改fd上的注册事件。
-EPOLL_CTL_DEL 删除fd上的注册事件。
event参数指定事件,它是epoll_event结构指针类型。epoll_event的定义如下:
struct epoll_event{
__uint32_t events; //epoll事件
epoll_data_t data; //用户数据
};
其中events成员描述事件类型。epoll支持的事件类型和poll基本相同。表示epoll事件类型的宏是在poll对应的宏前加“E”,比如EPOLLIN。但epoll有两个额外的事件类型-EPOLLET和EPOLLONESHOT。它们对于epoll的高效运作非常关键。
data成员用于存储用户数据,其类型epoll_data_t的定义如下:
typedef union epoll_data{
void *ptr;
int fd;
uint32_t u32;
uint64_t u64;
}epoll_data_t;
epoll_data_t是一个联合体,其4个成员中使用最多的是fd,它指定事件所从属的目标文件描述符。ptr成员可用来指定与fd相关的用户数据。但由于epoll_data_t是一个联合体,我们不能同时使用其ptr成员和fd成员,因此,如果要将文件描述符和用户数据关联起来,以实现快速的数据访问,只能使用其它手段,比如放弃使用epoll_data_t的fd成员,而在ptr指向的用户数据中包含fd。
epoll_ctl成功时返回0,失败则返回-1并设置errno。
2. epoll_wait函数
epoll系列系统调用的主要接口是epoll_wait函数。它在一段超时时间内等待一组文件描述符上的事件,其原型如下:
#include<sys/epoll.h>
int epoll_wait( int epfd, struct epoll_event *events, int maxevents, int timeout);
该函数成功时返回就绪的文件描述符的个数,失败时返回-1并设置errno。
timeout参数的含义与poll接口的timeout参数相同,是毫秒的超时时间。
maxevents参数指定最多监听多少个事件,它必须大于0.
epoll_wait函数如果检测到事件,就将所有就绪的事件从内核事件表(由epfd指定)中复制到它的第二个参数events指向的数组中。这个数组只用于输出epoll_wait检测到的就绪事件,而不像select和poll的数组那样既用于传入用户注册的事件,有用于输出内核检测到的就绪事件。这就极大地提高了应用程序索引就绪文件描述符的效率。
3. LT和ET模式
epoll对文件描述符的操作有两种模式:LT(Level Trigger,电平触发)模式和ET(Edge Trigger,边沿触发)模式。
LT模式是默认的工作模式,这种模式下epoll相当于一个效率较高的poll。当往epoll内核事件表中注册一个文件描述符上的EPOLLET事件时,epoll将以ET模式来操作该文件描述符。ET模式是epoll的高效工作模式。
对于采用LT工作模式的文件描述符,当epoll_wait检测到其上有事件发生并将此事件通知应用程序后,应用程序可以不立即处理该事件。这样,当应用程序下一次调用epoll_wait时,epoll_wait还会再次向应用程序通告此事件,直到该事件被处理。
而对于采用ET工作模式的文件描述符,当epoll_wait检测到其上有事件发生并将此事件通知用用程序后,应用程序必须立即处理该事件,因为后续的epoll_wait调用将不再向应用程序通知这一事件。
可见,ET模式在很大程度上降低了同一个epoll事件被重复触发的次数。因此效率要比LT模式高。
4. EPOLLONESHOT事件
即使使用ET模式,一个socket上的某个事件还是可能被触发多次。这在并发程序中就会引起一个问题。比如一些线程(或进程)在读取完某个socket上的数据后开始处理这些数据而在数据的处理过程中该socket上又有新数据可读(EPOLLIN再次被触发),此时另外一个线程被唤醒来读取这些新的数据。于是就出现了两个线程同时操作一个socket的局面。这当然不是我们期望的。我们期望的是一个socket连接在任一时刻都只被一个线程处理。这一点可以使用epoll的EPOLLONESHOT事件处理。
对于注册了EPOLLONESHOT事件的文件描述符,操作系统最多触发其上注册的一个可读、可写或者异常事件,且只触发一次,除非我们使用epoll_ctl函数重置该文件描述符上注册的EPOLLONESHOT事件。这样,当一个线程在处理某个socket时,其它线程是不可能有机会操作该socket的。但反过来思考,注册了EPOLLONESHOT事件的socket一旦被某个线程处理完毕,该线程就应该立即重置这个socket上的EPOLLONESHOT事件,以确保这个socket下一次可读时,其EPOLLIN事件能被触发,进而让其它工作线程有机会继续处理这个socket。
PS 2:三组I/O复用(多路转接)函数的比较
select、poll和epoll都能同时监听多个文件描述符。它们将等待一个特定的超时时间。在任何描述符事件就绪前发生了信号中断I/O复用的话,会使函数返回-1,errno设为EINTR。它们的不同之处:
1)事件类型:select有3种事件(读写异常)、poll可监听7种POLLXXX并且内核返回时可有其它选择POLLHUP/POLLERR/POLLNVAL、epoll事件和poll类似但开头加上“E”,同时还可以设置EPOLLET和EPOLLONESHOT事件