本章主要讨论的是高级I/O话题,有:非阻塞I/O、记录锁、I/O多路转接、异步I/O、存储映射I/O等。
非阻塞I/O
系统调用可以分为:”低速“系统调用和其他,低速系统调用是指可能会使进程永远阻塞的系统调用,对于像读写磁盘文件的I/O会暂时阻塞调用者,不能称为低速I/O。
非阻塞I/O可以在我们使用open、read、write等I/O操作时,保证这些操作不会阻塞。如果该操作不能完成,调用会立即出错并返回,表示该操作如果继续就会阻塞。
实例
下面是使用非阻塞I/O的实例,它从标准输入中读取500000字节,然后试图将它们写到标准输出上,它将标准输出设置为非阻塞
1 |
|
这里使用while循环的方式进行调用write函数,该方式称为轮询,若标准输出是终端时(因为终端是行缓冲,超过缓冲上限,缓冲会被冲洗,冲洗时write调用就会失败),会反复调用write系统调用,并且大多数会返回错误,这会浪费CPU的时间,后续会讲到可以使用I/O多路转接,很好的解决这类问题。
记录锁
记录所(record locking,又称字节范围锁)的功能是:当一个进程正在读或写文件的某个部分时,记录锁可以阻止其他进程修改同一文件区。注意,这里是锁住文件区域,可以是一个文件,也可以是一个文件中的一个字节。
在Linux中可以使用fcntl方法设置记录锁。
1 |
|
对于记录锁,cmd是F_GETLK、F_SETLK、F_SETLKW,第三个参数是指向flock结构的指针:
1 | struct flock { |
由l_type可知,记录锁的效果和线程中的读写锁效果类似,读锁共享,写锁独占。
若一个进程在同一个文件区两次加锁,则新锁会替换旧锁。
fcntl的cmd参数可以有下面3个(记录锁情况下):
- F_GETLK:用于判断是否创建锁,如果相应的位置已经有锁存在,则将现有锁的信息重写flockptr中,4;如果没有锁,则将flockptr中的l_type修改为F_UNLCK,表示该锁可以被获取。
- F_SETLK:用户尝试向文件建立锁,如果系统阻止我们获取锁,则fcntl立即出错返回,errno设置为EACCES或EAGAIN。
- F_SETLKW:F_SETLK的阻塞版本(后面的W即wait),如果进程企图加锁的区域被其他进程占有而导致无法获取,则该进程进入阻塞状态,直到锁可用或被信号唤醒。
注意,若想使用F_GETLK测试是否可以获取锁,然后用F_SETLK或F_SETLKW获取锁,这两者之间不是原子操作,不能保证在两个操作之间没有其他进程企图获取相同的锁。
实例:死锁
如果两个进程相互等待对方持有并且不释放锁定的资源时,则两个进程就会处于死锁状态。下面是死锁的例子,子进程对第0字节加锁,父进程对第1字节加锁,并且它们试图向对方加锁的字节加锁。我在原书的基础上添加了一些打印动作,以便直观的看到父进程和子进程的动作。
1 |
|
下面是上述自定义函数的实现,也添加的一些打印动作:
1 |
|
在linux中,上述的程序输出为:
parent: got the lock, byte 1
父进程发送信号SIGUSR1
父进程进入休眠
child: got the lock, byte 0
子进程发送信号SIGUSR2
子进程进入休眠
子进程解除休眠
子进程尝试获取字节1
父进程解除休眠
父进程尝试获取字节0
parent: writew_lock error: Resource deadlock avoided
child: got the lock, byte 1
检查到死锁时,内核必须选择一个进程接受出错返回,这里内核决定的是父进程出错返回,子进程成功获取父进程控制的字节。
锁的隐含继承和释放
记录锁的自动继承和释放有3条规则:
锁与进程和文件两者关联,这里有两重含义:(1)当一个进程终止,其建立的锁全部释放;(2)一个文件描述符关闭时,进程通过该文件描述符引用的文件上的锁都会被释放。如下:
1
2
3
4fd1 = open(pathname, ...);
read_lock(fd1, ...); //自定义函数,功能是:在fd1上创建一个读锁
fd2 = dup(fd1); //复制一个文件描述符
close(fd2); //关闭fd2关联的文件
在执行close(fd2)后,通过fd1创建的锁也会被释放,因为fd2和fd1指向同一个文件
- 由fork产生的子进程不继承父进程锁设置的锁。这是有意义的,因为锁本身的目的就是为了阻止多个进程同时写同一个文件,如果子进程继承了父进程的锁,那么就会导致有两个进程同时写同一个文件。
- 在执行exec后,新程序可以继承原执行程序的锁。这可以理解为新创建了一个进程,原进程已经终止。注意,如果对文件描述符设置了执行时关闭表示,则exec后,该文件描述符会被关闭,并释放所有锁。
在文件尾端加锁
文件尾端会一直变化,因此在向文件尾端加锁或解锁时需要小心。考虑下面代码:
1 | writew_lock(fd, 0, SEEK_END, 0); //自定义函数,功能是:向文件尾端添加写锁 |
在文件尾端添加写锁,后续向文件写的任何数据也会被锁上。上述代码的效果如下:
如果想要解除包括第一次write所写字节的锁,则在un_lock函数中的第二个参数设置为-1,表示解锁的区域从当前位置(这里是文件末尾)的上一个字节开始,这样就可以释放所有锁了。
I/O多路转接
当从一个文件描述符读,然后写到另一个文件描述符,可以使用下述的阻塞I/O:
1 | while ((n = read(STDIN_FILENO, buf, BUFSIZ)) > 0) |
但是,如果必须从两个文件描述符读,就不能使用这种阻塞I/O了,因为我们不能在一个描述符上阻塞read,如果此时另一个文件描述符有数据,就无法调用read进行处理,例如:
telnet程序有两个输入,两个输出。因为不知道是哪一个输入会有数据,不能对两个输入的任何一个进行阻塞。
解决这个问题较好的技术是I/O多路转接。先构造一个描述符列表,然后调用一个函数,直到这些描述符中的一个已经准备好I/O时,函数才返回。poll、pselect、select这3个函数可以执行I/O多路转接。
函数select和pselect
通过select参数可以告诉内核:
- 我们关心的描述符;
- 关心的描述符的条件(读、写、异常);
- 愿意等待的时间(永远、一段时间、不等待)。
select返回后,内核告诉我们:
- 已准备好的描述符数量
- 对于读、写、异常三个条件中哪些描述符已经准备好
根据select返回的信息,就可以调用相应的I/O函数,并且保证该函数不会阻塞。
1 |
|
对于参数tvptr有三种情况:
- tvptr == NULL:永远等待。当指定描述符中一个已准备好或捕捉到一个信号则返回。如果捕捉到信号,则返回-1,errno设置为EINTR。
- tvptr->tv_sec == 0 && tvptr->tv_usec == 0:根本不等待,测试所有指定描述符后立即返回。用于轮询找到多个描述符状态而不阻塞select的方法;
- tvptr->tv_sec != 0 || tvptr->tv_usec != 0:等待指定的描述和微秒数。当指定描述符已准备好,或指定时间超时后返回。若超时后没有描述符准备好,则返回0。
中间三个参数readfds、writefds、execptfds是指向描述符集的指针,每个描述符集存储在fd_set结构中,可以认为它是一个很大的数组。
参数maxfdp1表示”最大文件描述符编号值加1“。通过我们给定最大描述符,则内核只需要在此范围内寻找即可,而不需要在没有使用的位内搜索。
select函数有三种返回值:
- 返回值-1,表示出错。例如没有描述符准备好时捕捉到一个信号,此时一个描述符集都不修改;
- 返回值0,表示没有描述符准备好。超时后,一个描述符都没准备好,此时描述符集都置0;
- 返回值正数,表示准备好的描述符数,3个描述符集已准备好的描述符之和,若同一描述符准备好了读和写,则返回值计数两次。此时,描述符集对应已准备好的描述符置1。
pselect是select的变体,它可以安装信号屏蔽字。
1 |
|
pselect和select有以下不同:
- select超时值使用timeval,pselect使用timespec。timeval使用秒和微秒,timespec使用秒和纳秒;
- pselect的超时值声明为const,保证pselect不会修改该值;
- pselect可以使用信号屏蔽字,pselect保证以原子方式安装屏蔽字,返回后,恢复以前的信号屏蔽字。
函数poll
poll类似select,但接口不同:
1 |
|
与select不同,poll通过pollfd数组,每个数组元素指定一个描述符编号和对描述符感兴趣的条件。
1 | struct pollfd { |
fdarry数组中元素个数由nfds指定。pollfd中的events告诉内核我们关心的描述符对应的事件。返回时,内核设置revents,说明对应描述符发生的事件。(注意,poll没有修改events成员)。
poll中的timeout表示我们愿意等待的时间。如同select,有3个情形:
- timeout == -1:永远等待。当指定描述符中的一个已准备好,或捕捉到一个信号,则返回。如果捕捉到信号,返回-1,并且errno设置为EINTR;
- timeout == 0:不等待。测试所有描述的状态(从revents获得),并不阻塞poll函数;
- timeout > 0:等待timeout毫秒,若给定描述符之一已准备好,或超时后,立即返回。若超时后没有描述符准备好,则poll返回0。
与select相同,一个文件描述符阻塞并不影响poll阻塞。
异步I/O
异步I/O使用一个信号(System V中是SIGPOLL,BSD中是SIGIO)通知进程,表示某个描述符关心的时间已经发生。但信号只有一个,当如果有多个描述符使用异步I/O,进程接收到该信号时不知道其对应的是哪一个文件描述。
POSIX异步I/O
异步I/O接口使用AIO控制块来描述I/O操作,aiocb结构描述了AIO控制块,至少包括一下字段:
1 | struct aiocb { |
异步I/O接口的偏移量并不影响操作系统维护的文件偏移量。只要在一个进程中,不将异步I/O函数和传统I/O函数(指read、write)一起使用,就不会出问题。
aio_sigevent字段结构如下:
1 | struct sigevent { |
sigve_notify字段控制通知的类型,取值有3种:
- SIGEV_NONE:异步I/O完成后,不通知进程;
- SIGEV_SIGNAL:异步I/O完成后,产生由sigev_signo字段指定的信号;
- SIGEV_THREAD:异步I/O完成后,由sigev_notify_function字段指定函数被调用,sigev_value作为它的唯一参数。
函数aio_read进行异步读操作,函数aio_write进行异步写操作。
1 |
|
当函数返回成功时,异步I/O请求被操作系统放入等待队列中。注意,两个函数的返回值与I/O操作无任何关系,在I/O完成之前,AIO控制块和缓冲区不能被复用。
函数aio_fsync可以强制所有等待中的异步操作立即执行写入持久化存储过程,也就是执行数据同步操作。
1 |
|
在异步同步操作完成前,数据不会被持久化。
函数aio_error可以获取异步读、写、同步操作的完成状态。
1 |
|
aio_error有返回值有四种情况:
- 0:表示异步操作(指读、写、同步等操作)成功完成,此时可以调用aio_return获取异步操作返回值;
- -1:aio_error调用失败,可以从error获取与原因值;
- EINPROGRESS:异步写、读、同步操作正在等待中;
- 其他情况:其他返回值是异步操作(指读、写、同步操作)失败返回的错误码。
异步操作成功后,可以调用aio_return获取异步操作返回值
1 |
|
aio_return的返回值
- -1 : aio_return调用失败,并设置errno;
- 其他:返回异步操作的结果,即读、写、同步操作的返回结果
注意,在异步操作完成之前,不要调用aio_return,此时操作未定义;并且对一个异步操作只能调用一次aio_return。调用该函数后,操作系统会删除I/O操作的返回值。
执行I/O操作时,不想被阻塞就可以使用异步I/O。当所有事务都完成,还有异步操作没有完成,则可以调用aio_suspend阻塞进程,直到异步操作完成。
1 |
|
如果调用aio_suspend的阻塞过程中,被信号中断,则它返回-1,并在errno中设置EINTR;
如果没有任何的I/O操作完成,阻塞时间超过timeout参数,则它返回-1,并将errno设置EAGAIN(不想设置时间限制,可以将timeout传入为NULL);
如果任何I/O操作完成,则它返回0;
如果在调用aio_suspend时,所有异步I/O以完成,则aio_suspend不阻塞直接返回。
参数list表示指向aiocb数组的指针,参数nent表示数组中的条目数量,除了空指针,其他条目必须指向初始化I/O操作的AIO控制块。
函数aio_cancel可以取消等待中的异步I/O操作。
1 |
|
aio_cancel返回值有:
- AIO_ALLDONE:所有操作在尝试取消它们前已完成
- AIO_CANCELED:所有请求的操作已被取消
- AIO_NOTCANCELED:至少一个请求的操作没被取消
- -1:aio_cancel调用失败,并在errno中设置错误码
参数fd指定了执行异步操作的文件描述符,如果aiocb设置为NULL,则系统尝试取消fd指向的文件上的所有异步操作。其他情况下,系统尝试取消单个异步操作。之所以描述为“尝试”,因为操作系统无法保证能成功取消正在进程中的异步操作。
aio_cancel操作成功,对相应的AIO控制块调用aio_error会返回错误ECANCELED。如果操作不成功,AIO控制块无变化。
函数lio_listio可以提交一系列有AIO控制块列表描述的I/O请求。
1 |
|
参数mode有:
- LIO_WAIT:函数将在列表指定的所有I/O完成后返回;
- LIO_NOWAIT:函数将I/O操作插入等待队列后立即返回,进程将在对应I/O完成后,由sigev参数决定如何异步通知。如果进程不想被通知,则将sigev设置为NULL。注意,每个AIO对应也有其各自操作完成时的异步通知,sigev参数的异步通知是另加的,并且只会在所有I/O操作完成后发送。
参数list指向AIO控制块列表,指代所有要进行的I/O操作。
参数nent指定数组元素的个数,如果list为NULL,该参数被忽略。
引入POSIX异步操作I/O接口的目的是为了避免在执行I/O操作时阻塞进程。
实例
下面使用异步I/O翻译一个文件
1 |
|
这里使用了8个缓冲区,同时最多可以有8个异步I/O操作处于等待状态。使用off偏移量,可以实现多个异步I/O同时进程翻译文件的不同位置。
函数readv和writev
readv和writev用于一次函数调用中读、写多个非连续的缓冲区,这两个函数也称为散布读、聚集写。
1 |
|
第一个参数fd是文件描述符;
第二个参数iov是一个指向iovec结构数组的指针,第三个参数iovcnt是数组的大小(最大为IOV_MAX),iovec结构如下:
1 | struct iovec { |
下图是iovec结构的描述:
readv按上述顺序将读入的数据散布到各个缓冲区中,readv总是先填满一个缓冲区,在写入下个缓冲区。readv返回读的总字节数,如果是文件末尾,返回0。
writev按上述顺序从各个缓冲区中输出数据。writev返回输出的总字节数,通常为所谓缓冲区长度之和。
存储映射I/O
存储映射I/O将磁盘文件映射到一个缓冲区中,当从缓冲区中取数据,相当于从文件中读取相应字节数;当向缓冲区写数据,相应的字节会自动写入文件。这就可以不使用read和write的情况下I/O。
使用之前,要将给定的文件映射到一个存储区域中,该过程由mmap函数实现。
1 |
|
参数addr指定映射区域地址,通常设为0,表示由系统分配映射区域;参数fd指映射的文件,在映射之前,必须打开该文件;参数len为映射的字节数;off为映射字节在文件中的偏移位置;prot参数为映射存储区的保护要求,如下表所示:
| prot | 说明 |
|---|---|
| PROT_READ | 映射区可读 |
| PROT_WRITE | 映射区可写 |
| PROT_EXEC | 映射区可执行 |
| PROT_NONE | 映射区不可访问 |
prot可设为上述参数的任意组合的按位或。对映射区的保护要求不能超过文件open模式访问权限。例如文件open只读打开,那么prot不能设为PROT_WRITE。
flag通常有3中参数:
- MAP_FIXED:返回值必须等于addr。不建议使用该标志,这会降低可移植性,并且addr为非0,内核只是作为参考,并不保证会使用所要求的地址。addr使用0可获取最大可移植性。
- MAP_SHARED:表示对映射区域的存储操作会修改映射文件,存储文件相当于向文件write操作
- MAP_PRIVATE:映射区为映射文件的一个副本,对映射区的修改不会影响映射文件,只会修改文件的副本。
flag可能还有其他参数,但都是其他实现特有的。
函数mprotect可以更改一个现有映射的权限。
1 |
|
注意,此处的addr必须是系统页长(linux一般为4096)的整数倍。prot与mmap中的相同。
如果mmap的flag参数设为MAP_SHARED,那么修改不会立即写回到文件,写回的时机由内核的守护进程决定。而且,就算只修改了一页中的一个字节,修改也会将整个页写回。
如果共享映射的页已修改,可以调用msync将该页冲洗到被映射的文件中。该函数与fsync相似,但仅作用于映射区,fsync冲洗整个文件。
1 |
|
如果映射私有,则不修改映射的文件。与其他映射函数一样,addr必须是系统页长的整数倍。
flags有两个参数:
- MS_AYNC:即简单的调试写的页,函数返回之前写操作不一定成功;
- MS_SYNC:函数在写操作完成之后才返回。
进程终止时,会自动解除存储映射区的映射,也可以调用munmap解除映射区。注意,关闭映射区对应的文件描述符并不解除映射区。
1 |
|
调用munmap不会将映射区的内容写到磁盘文件上。解除映射区后,对MAP_PRIVATE存储区的修改会被丢弃。