APUE 高级I/O

《UNIX环境高级编程》第14章 高级I/O 笔记

非阻塞I/O

将系统调用分成两类:“低速”系统调用和其他。低速系统调用是可能会使进程永远阻塞的一类系统调用,包括:

  • 如果某些文件类型(如读管道、终端设备和网络设备)的数据并不存在,读操作可能会使调用者永远阻塞;
  • 如果数据不能被相同的文件类型立即接受(如管道中无空间、网络流控制),写操作可能会使调用者永远阻塞
  • 在某种条件发生之前打开某些文件类型可能会发生阻塞(如要打开一个终端设备,需要先等待与之连接的调制解调器应答,又如若以只写模式打开FIFO,那么在没有其他进程已用读模式打开该FIFO时也要等待)
  • 对已经加上强制性记录锁的文件进行读写
  • 某些ioctl操作;
  • 某些进程间通信函数

虽然读写磁盘文件会暂时阻塞调用者,但并不能将与磁盘I/O有关的系统调用视为“低速”。

非阻塞IO使我们可以发出open、read和 write这样的IO操作,并使这些操作不会永远阻塞。如果这种操作不能完成,则调用立即出错返回,表示该操作如继续执行将阻塞。

对于一个给定的描述符,有两种为其指定非阻塞I/O的方法。

  1. 如果调用open获得描述符,则可指定O_NONBLOCK标志
  2. 对于已经打开的一个描述符,则可调用fcntl,由该函数打开O_NONBLOCK文件状态标志

记录锁

记录锁(record locking)的功能是:当第一个进程正在读或修改文件的某个部分时,使用记录锁可以阻止其他进程修改同一文件区。对于UNIX系统而言,“记录”这个词是一种误用,因为UNIX系统内核根本没有使用文件记录这种概念。一个更适合的术语可能是字节范围锁(byte-range locking),因为它锁定的只是文件中的一个区域(也可能是整个文件)

设置记录锁的方法是使用fcntl函数:

1
2
3
4
5
#include <fcntl.h>
int fcntl(int fd, int cmd, ... /* struct flock *flockptr */ );
// Returns: depends on cmd if OK (see following), −1 on error

对于记录锁,cmd是F_GETLKF_SELKF_SETLKW。第三个参数(我们将调用flockptr)是一个指向flock结构的指针。

1
2
3
4
5
6
7
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 to EOF */
pid_t l_pid; /* returned with F_GETLK */
};

flock结构说明如下:

  • 所希望的锁类型:F_RDLCK(共享读锁)、F_WRLCK(独占性写锁)或F_UNLCK(解锁个区域)。
  • 要加锁或解锁区域的起始字节偏移量(l_startl_whence)。
  • 区域的字节长度(1_1en)。
  • 进程的ID(l_pid)持有的锁能阻塞当前进程(仅由F_GETLK返回)

上面提到了两种类型的锁:共享读锁和独占性写锁。基本规则是:任意多个进程在一个给定的字节上可以有一把共享的读锁,但是在一个给定字节上只能有一个进程有把独占写锁。

在设置或释放文件上的一把锁时,系统按要求组合或分裂相邻区。其中一个部分解锁的时候进程分裂,再次将这个区域加锁的时候进行合并。

锁的隐含继承和释放

关于记录锁的自动继承和释放有3条规则:

  1. 锁与进程和文件两者相关联。这有两重含义:第一重很明显,当一个进程终止时,它所建立的锁全部释放;第二重则不太明显,无论一个描述符何时关闭,该进程通过这一描述符引用的文件上的任何一把锁都会释放(这些锁都是该进程设置的)。
  2. 由fork产生的子进程不继承父进程所设置的锁
  3. 在执行exec后,新程序可以继承原执行程序的锁。但是注意,如果对一个文件描述符设置了执行时关闭标志,那么当作为exec的一部分关闭该文件描述符时,将释放相应文件的所有锁。

FreeBSD实现

I/O多路转接

有的时候我们需要从多个文件描述符读的时候,我们不能在任一个描述符上进行阻塞读(read),否则可能会因为被阻塞在一个描述符的读操作上而导致另一个描述符即使有数据也无法处理。

一种比较好的技术是使用I/O多路转接(I/O multiplexing)。为了使用这种技术,先构造一张我们感兴趣的描述符(通常都不止一个)的列表,然后调用一个函数,直到这些描述符中的一个已准备好进行I/O时,该函数才返回。

函数select和pselect

在所有POSIX兼容的平台上, select函数使我们可以执行I/O多路转接。传给se1ect的参数告诉内核:

  • 我们所关心的描述符:
  • 对于每个描述符我们所关心的条件(是否想从一个给定的描述符读,是否想写一个给定的描述符,是否关心一个给定描述符的异常条件)
  • 愿意等待多长时间(可以永远等待、等待一个固定的时间或者根本不等待)
  • 从se1ect返回时,内核告诉我们已准备好的描述符的总数量
  • 对于读、写或异常这3个条件中的每一个,哪些描述符已准备好
1
2
3
4
5
#include <sys/select.h>
int select(int maxfdp1, fd_set *restrict readfds, fd_set *restrict writefds, fd_set *restrict exceptfds, struct timeval *restrict tvptr);
// Returns: count of ready descriptors, 0 on timeout, −1 on error

中间3个参数 readfds、 writefds和exceptfds是指向描述符集的指针。这3个描述符集说明了我们关心的可读、可写或处于异常条件的描述符集合。每个描述符集存储在一个fd_set数据类型中。这个数据类型是由实现选择的,它可以为每一个可能的描述符保持一位。

对于fd_set数据类型,唯一可以进行的处理是:分配一个这种类型的变量,将这种类型的一个变量值赋给同类型的另一个变量,或对这种类型的变量使用下列4个函数中的一个。

1
2
3
4
5
6
7
8
#include <sys/select.h>
int FD_ISSET(int fd, fd_set *fdset);
// Returns: nonzero if fd is in set, 0 otherwise
void FD_CLR(int fd, fd_set *fdset);
void FD_SET(int fd, fd_set *fdset);
void FD_ZERO(fd_set *fdset);

另外一个select函数的变体pselect:

1
2
3
4
5
#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);
// Returns: count of ready descriptors, 0 on timeout, −1 on error

和select的不同点在于:

  • 超时使用的数据结构不一样,能够提供更加精准的超时时间
  • 超时值被声明为const,保证了pselect不会改变这一个值
  • 可使用可选信号屏蔽字

函数poll

poll函数和select差不多,就是函数的接口不一样

1
2
3
4
5
#include <poll.h>
int poll(struct pollfd fdarray[], nfds_t nfds, int timeout);
// Returns: count of ready descriptors, 0 on timeout, −1 on error

与 select不同,pol1不是为每个条件(可读性、可写性和异常条件)构造一个描述符集,而是构造一个po11fd结构的数组,每个数组元素指定一个描述符编号以及我们对该描述符感兴趣的条件。

异步I/O

在用异步I/O的时候,要通过选择来灵活处理多个并发操作,这会使应用程序的设计复杂化。更简单的做法可能是使用多线程,使用同步模型来编写程序,并让这些线程以异步的方式运行。

使用POSIX异步I/O接口,会带来这些麻烦:

  • 每个异步操作有3处可能产生错误的地方:一处在操作提交的部分,一处在操作本身的结果,还有一处在用于决定异步操作状态的函数中。
  • 设计大量额外的设置和处理规则
  • 从错误中恢复会比较困难

函数readv和writev

readv和 writev函数用于在一次函数调用中读、写多个非连续缓冲区。有时也将这两个函数称为散布读(scatter read)和聚集写(gather write)

1
2
3
4
5
6
7
#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);
// Both return: number of bytes read or written, −1 on error

其中中间参数的数据结构iov如下:

函数readn和writen

管道、FIFO以及某些设备(特别是终端和网络)有下列两种性质

  1. 一次read操作所返回的数据可能少于所要求的数据,即使还没达到文件尾端也可能是这样。这不是一个错误,应当继续读该设备。
  2. 一次 write操作的返回值也可能少于指定输出的字节数。

下面两个函数 readn和 writen的功能分别是读、写指定的N字节数据,并处理返回值可能小于要求值的情况。这两个函数只是按需多次调用xead和 write直至读、写了N字节数据。

1
2
3
4
5
6
#include "apue.h"
ssize_t readn(int fd, void *buf, size_t nbytes);
ssize_t writen(int fd, void *buf, size_t nbytes);
// Both return: number of bytes read or written, −1 on error

存储映射I/O

存储映射I/O(memory-mapped I/O)能将一个磁盘文件映射到存储空间中的一个缓冲区上。于是,当从缓冲区中取数据时,就相当于读文件中的相应字节。与此类似,将数据存入缓冲区时相应字节就自动写入文件。这样,就可以在不使用read和 write的情况下执行I/O。

为了使用这种功能,应首先告诉内核将一个给定的文件映射到一个存储区域中。这是由mmap函数实现的。

1
2
3
4
#include <sys/mman.h>
void *mmap(void *addr, size_t len, int prot, int flag, int fd, off_t off );
// Returns: starting address of mapped region if OK, MAP_FAILED on error

addr参数用于指定映射存储区的起始地址。通常将其设置为0,这表示由系统选择该映射区的起始地址。此函数的返回值是该映射区的起始地址。

fd参数是指定要被映射文件的描述符。在文件映射到地址空间之前,必须先打开该文件。len参数是映射的字节数,off是要映射字节在文件中的起始偏移量

prot参数指定了映射存储区的保护要求

存储映射文件的基本情况

flag参数可以使用的属性:

  • MAP_FIXED 返回值必须等于addr。因为这不利于可移植性,所以不鼓励使用此标志。
  • MAP_SHARED 这一标志描述了本进程对映射区所进行的存储操作的配置。此标志指定存储操作修改映射文件,也就是,存储操作相当于对该文件的 write
  • MAP_PRIVATE 本标志说明,对映射区的存储操作导致创建该映射文件的一个私有副本

off的值和addr的值(如果指定了 MAP_FIXED)通常被要求是系统虚拟存储页长度的倍数

与映射区相关的信号有 SIGSEGV和 SIGBUS:

  • 信号 SIGSEGV通常用于指示进程试图访问对它不可用的存储区。如果映射存储区被map指定成了只读的,那么进程试图将数据存入这个映射存储区的时候,也会产生此信号。
  • 如果映射区的某个部分在访问时已不存在,则产生 SIGBUS信号。例如,假设用文件长度映射了一个文件,但在引用该映射区之前,另一个进程已将该文件截断。此时,如果进程试图访问对应于该文件已截去部分的映射区,将会接收到 SIGBUS信号

子进程能通过fork继承存储映射区(因为子进程复制父进程地址空间,而存储映射区是该地址空间的一个部分),新程序则不能通过exec继承存储映射区

调用mprotect可以更改一个现有映射的权限。

1
2
3
4
5
#include <sys/mman.h>
int mprotect(void *addr, size_t len, int prot);
// Returns: 0 if OK, −1 on error

如果共享映射中的页已修改,那么可以调用 msync将该页冲洗到被映射的文件中。

1
2
3
4
5
#include <sys/mman.h>
int msync(void *addr, size_t len, int flags);
// Returns: 0 if OK, −1 on error

如果映射是私有的,那么不修改被映射的文件。与其他存储映射函数一样,地址必须与页边界对齐。

当进程终止时,会自动解除存储映射区的映射,或者直接调用 munmap函数也可以解除映射区。关闭映射存储区时使用的文件描述符并不解除映射区。

1
2
3
4
5
#include <sys/mman.h>
int munmap(void *addr, size_t len);
// Returns: 0 if OK, −1 on error