14.1 引言
本章讨论高级I/O相关主题,包括:
- 非阻塞I/O;
- 记录锁;
- I/O多路转接(
select
、poll
); - 异步I/O;
readv
和writev
函数;- 存储映射I/O(
mmap
)。
14.2 非阻塞I/O
非阻塞I/O使我们可以发出open
、read
和write
这样的I/O操作,并使这些操作不会永久阻塞。如果这种操作不能完成,则调用立即出错返回,表示该操作如继续执行将阻塞。
对于一个给定的描述符,有两种为其指定非阻塞I/O的方法:
(1)如果调用open
获得描述符,则可指定O_NONBLOCK标志;
(2)对于已经打开的一个描述符,则可调用fcntl
,由该函数打开O_NONBLOCK文件状态标志。
14.3 记录锁
记录锁的功能是:当第一个进程正在读或修改文件的某个部分时,使用记录锁可以阻止其他进程修改同一文件区。
1. 历史
2. fcntl记录锁
#include <fcntl.h>
int fcntl(int fd, int cmd, .../* struct flock *flockptr */);
// 返回值:若成功,依赖于cmd(见下),否则,返回-1
-
cmd 是 F_GETLK、F_SETLK 或 F_SETLFW;
-
第三个参数 flockptr 是一个指向
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 to EOF */ pid_t l_pid; /* returned with F_GETLK */ };
- 所希望的锁类型:l_type可取 F_RDLCK(共享读锁)、F_WRLCK(独占性写锁)或 F_UNLCK(解锁一个区域);
- 要加锁或解锁区域的起始字节偏移量:l_start 和 l_whence;
- 区域的字节长度:l_len;
- 进程的ID(l_pid)持有的锁能阻塞当前进程(仅由F_GETLK返回)。
共享读锁(F_RDLCK)和独占性写锁(F_WRLCK)的基本规则:任意多个进程在一个给定的字节上可以有一把共享的读锁,但是在一个给定的字节上只能有一个进程有一把独占写锁;如果在一个给定字节上已经有一把或多把读锁,则不能在该字节上再加写锁;如果在一个字节上已经有一把独占性写锁,则不能再对它加任何读锁。
fcntl
函数的3种命令:- F_GETLK:判断由flockptr所描述的锁是否会被另外一把锁所排斥(阻塞)。如果存在一把锁,它阻止创建由flockptr所描述的锁,则该现有锁的信息将重写flockptr指向的信息。如果不存在这种情况,则除了将l_type设置为设置为F_UNLCK之外,flockptr所指向结构中的其他信息保持不变;
- F_SETLK:设置由flockptr所描述的锁;此命令也用来清除由flockptr指定的锁(l_type为F_UNLCK);
- F_SETLKW:这个命令是 F_SETLK 的阻塞版本(命令名中的 W 表示等待(wait))。如果所请求的读锁或写锁因另一个进程当前已经对所请求区域的某部分进行了加锁而不能被授予,那么调用进程会被置为休眠。如果请求的锁已经可用,或者休眠由信号中断,则该进程被唤醒。
3. 锁的隐含继承和释放
关于记录锁的自动继承和释放有3条规则:
- 锁与进程和文件两者相关联:
- 当一个进程终止时,它所建立的锁全部释放;
- 无论一个描述符何时关闭,该进程通过这一描述符引用的文件上的任何一把锁都会释放(这些锁都是该进程设置的)。
- 由
fork
产生的子进程不继承父进程锁设置的锁; - 在执行
exec
后,新程序可以继承原执行程序的锁。
4. FreeBSD实现
5. 在文件尾端加锁
6. 建议性锁和强制性锁
14.4 I/O多路转接
I/O多路转接:先构造一张感兴趣的描述符(不止一个)的列表,然后调用一个函数(poll
、pselect
、select
),直到这些描述符中的一个已准备好进行I/O时,该函数才返回。
14.4.1 函数select和pselect
#include <sys/select.h>
int select(int maxfdp1, fd_set *restrict readfds,
fd_set *restrict writefds, fd_set *restrict exceptfds,
struct timeval *restrict tvptr);
// 返回值:准备就绪的描述符数目;若超时,返回0;若出错,返回-1
-
tvptr 指定愿意等待的时间长度:
- tvptr == NULL:永远等待,如果捕捉到一个信号则中断此无限期等待;
- tvptr->tv_sec == 0 && tvptr->tv_usec == 0:根本不等待,测试所有指定的描述符并立即返回;
- tvptr->tv_sec != 0 && tvptr->tv_usec != 0:等待指定的秒数和微秒数,当指定的描述符之一已准备好,或当指定的时间值已经超过时立即返回。
-
readfds、writefds和exceptfds是指向描述符集的指针, 这3个描述符集说明了我们关心的可读、可写或处于异常条件的描述符集合;每个描述符集存储在一个
fd_set
数据类型中,对这种类型的常用操作函数为:#include <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
测试描述符集中一个指定位是否已打开;- 在声明了一个描述符集之后,必须用
FD_ZERO
将这个描述符集置0,然后在其中设置我们关心的各个描述符的位。
-
第一个参数maxfdp1的意思是“最大文件描述符编号值加1”,考虑所有3个描述符集,在这3个描述符集中找出最大描述符编号值,然后加1,这就是第一个参数值;也可将第一个参数设置为FD_SETSIZE,该常量指定最大描述符数(经常是1024);因为描述符编号从0开始,所以要在最大描述符编号值上加1,该参数实际上是要检查的描述符数(从描述符0开始);
pselect
是POSIX.1中定义的select
函数变体:
#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
相同:
pselect
的超时值使用timespec结构指定,以秒和纳秒表示超时值,且被声明成const,这保证了调用pselect
不会改变此值;pselect
可使用可选信号屏蔽字;若sigmask为NULL,那么在与信号有关的方面,pselect
的运行状态和select
相同;否则,sigmask指向一信号屏蔽字,在调用pselect
时,以原子操作的方式安装该信号屏蔽字;在返回时,恢复以前的信号屏蔽字。
14.4.2 函数poll
#include <poll.h>
int poll(struct pollfd fdarray[], nfds_t nfds, int timeout);
// 返回值:准备就绪的描述符数目;若超时,返回0;若出错,返回-1
-
与
select
不同,poll
不是为每个条件构造一个描述符集,而是构造一个pollfd结构的数组,每个数组元素指定一个描述符编号以及我们对该描述符感兴趣的条件: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指定;
-
应将每个数组元素的events成员设置成下图中所示值的一个或几个,通过这些值告诉内核我们关心的是每个描述符的哪些事件;返回时,revents成员由内核设置,用于说明每个描述符发生了哪些事件:
- 当一个描述符被挂断(POLLHUP)后,就不能再写该描述符,但是有可能仍然可以从该描述符读取到数据;
-
timeout参数指定愿意等待多长时间:
- timeout == -1:永远等待,当所指定的描述符中的一个已准备好,或捕捉到一个信号时返回;
- timeout == 0:不等待,测试所有描述符并立即返回;
- timeout > 0:等待timeout毫秒,当指定的描述符之一已准备好,或timeout到期时立即返回。
14.5 异步I/O
14.5.1 System V 异步I/O
System V的异步I/O信号是SIGPOLL。
14.5.2 BSD 异步I/O
在BSD派生的系统中,异步I/O是信号SIGIO和SIGURG的组合。SIGIO是通用异步I/O信号,SIGURG则只用来通知进程网络连接上的带外数据已经到达。
14.5.3 POSIX 异步I/O
POSIX异步I/O接口使用AIO控制块来描述I/O操作。aiocb
结构定义了AIO控制块。该结构至少包括下面这些字段(具体的实现可能还包含有额外的字段):
struct aiocb {
int aio_fildes; /* file descriptor */
off_t aio_offset; /* file offset for I/O */
volatile void *aio_buf; /* buffer for I/O */
size_t aio_nbytes; /* number of bytes to transfer */
int aio_reqprio; /* priority */
struct sigevent aio_sigevent; /* signal information */
int aio_lio_opcode; /* operation for list I/O */
};
-
异步I/O操作必须显式地指定偏移量。如果使用异步I/O接口向一个以追加模式(使用O_APPEND)打开的文件中写入数据,AIO控制块中的aio_offset字段会被系统忽略;
-
aio_sigevent字段控制在I/O事件完成后,如何通知应用程序:
struct sigevent { int sigev_notify; /* notify type */ int sigev_signo; /* signal number */ union sigval sigev_value; /* notify argument */ void (*sigev_notify_function)(union sigval); /* notify function */ pthread_attr_t *sigev_notify_attributes; /* notify attrs */ };
- sigev_notify字段控制通知的类型,取值可能是以下3个中的一个:
- SIGEV_NONE:异步I/O请求完成后,不通知进程;
- SIGEV_SIGNAL:异步I/O请求完成后,产生由sigev_signo字段指定的信号;
- SIGEV_THREAD:当异步I/O请求完成时,由sigev_notify_function字段指定的函数被调用。sigev_value字段被传入作为它的唯一参数。
- sigev_notify字段控制通知的类型,取值可能是以下3个中的一个:
在进行异步I/O之前需要先初始化AIO控制块,调用aio_read
函数来进行异步读操作,或调用aio_write
函数来进行异步写操作:
#include <aio.h>
int aio_read(struct aiocb *aiocb);
int aio_write(struct aiocb *aiocb);
// 两个函数的返回值:若成功,返回0;若出错,返回-1
要想强制所有等待中的异步操作不等待而写入持久化的存储中,可以设立一个AIO控制块并调用aio_fsync
函数:
#include <aio.h>
int aio_fsync(int op, struct aiocb *aiocb);
// 返回值:若成功,返回0;若出错,返回-1
- AIO控制块中的aio_fildes字段指定了其异步写操作被同步的文件;
- 如果op参数设定为O_DSYNC,那么操作执行起来就会像调用了
fdatasync
一样; - 如果op参数设定为O_SYNC,那么操作执行起来就会像调用了
fsync
一样;
为了获知一个异步读、写或者同步操作的完成状态,需要调用aio_error
函数:
#include <aio.h>
int aio_error(const struct aiocb *aiocb);
// 返回值:(见下)
aio_error
返回值为下面4种情况中的一种:
- 0:异步操作成功完成,需要调用
aio_return
函数获取操作返回值; - -1:对
aio_error
的调用失败,这种情况下,errno会告诉我们为什么; - EINPROGRESS:异步读、写或同步操作仍在等待;
- 其他情况:其他任何返回值是相关的异步操作失败返回的错误码。
如果异步操作成功,可以调用aio_return
函数来获取异步操作的返回值:
#include <aio.h>
ssize_t aio_return(const struct aiocb *aiocb);
// 返回值:(见下)
aio_return
返回值:
aio_return
函数本身失败,会返回-1,并设置errno;- 其他情况下,它将返回异步操作的结果,即会返回
read
、write
或者fsync
在被成功调用时可能返回的结果。
注意:
- 直到异步操作完成之前,都需要小心不要调用
aio_return
函数,操作完成之前的结果是未定义的; - 对每个异步操作只调用一次
aio_return
; - 一旦调用了
aio_return
,操作系统就可以释放掉包含了I/O操作返回值的记录。
执行I/O操作时,如果还有其他事务要处理而不想被I/O操作阻塞,就可以使用异步I/O。然而,如果在完成了所有事务时,还有异步操作未完成时,可以调用aio_suspend
函数来阻塞进程,直到操作完成:
#include <aio.h>
int aio_suspend(const struct aiocb *const list[], int nent,
const struct timespec *timeout);
// 返回值:若成功,返回0;若出错,返回-1
- list参数是一个指向AIO控制块数组的指针,数组中的空指针会被跳过,其他条目都必须指向已用于初始化异步I/O操作的AIO控制块;
- nent参数表明了数组中的条目数。
aio_suspend
的可能返回值:
- 如果被一个信号中断,它将返回-1,并将errno设置为EINTR;
- 如果在没有任何I/O操作完成的情况下,阻塞的时间超过了函数中可选的timeout参数所指定的时间限制,那么将返回-1,并将errno设置为EAGAIN(不想设置任何时间限制的话,可以把空指针传给timeout参数);
- 如果有任何I/O操作完成,将返回0;如果在调用
aio_suspend
操作时,所有的异步I/O操作都已完成,那么aio_suspend
将在不阻塞的情况下直接返回。
当还有不想再完成的等待中的异步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
函数的可能返回值:- AIO_ALLDONE:所有操作在尝试取消它们之前已经完成;
- AIO_CANCELED:所有要求的操作已被取消;
- AIO_NOTCANCELED:至少有一个要求的操作没有被取消;
- -1:调用失败,错误码被存储在errno中。
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参数将被忽略; - 如果该参数被设定为LIO_NOWAIT,
lio_listio
函数将在I/O请求入队后立即返回;
- 如果该参数被设定为LIO_WAIT,
- 进程将在所有I/O操作完成后,按照sigev参数指定的,被异步地通知,如果不想被通知,可以把sigev设定为NULL;
- list参数指向AIO控制块列表,该列表指定了要运行的I/O操作,列表中可以包含NULL指针,这些条目将被忽略;
- nent参数指定了数组中的元素个数。
在每一个AIO控制块中,aio_lio_opcode字段指定该操作的类型:
- LIO_READ:读操作,按照对应的AIO控制块被传给
aio_read
函数来处理; - LIO_WRITE:写操作,按照对应的AIO控制块被传给
aio_write
函数来处理; - LIO_NOP:空操作。
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; /* starting address of buffer */ ssize_t iov_len; /* size of buffer */ };
-
iov数组中的元素数由iovcnt指定,其最大值受限于IOV_MAX,下图显式了这两个函数的参数和
iovec
结构之间的关系:
-
writev
函数从缓冲区中聚集输出数据的顺序是:iov[0]、iov[1]直至iov[iovcnt-1];writev
返回输出的字节总数,通常应等于所有缓冲区长度之和; -
readv
函数将读入的数据按iov[0]、iov[1]直至iov[iovcnt-1]的顺序散布到缓冲区中;readv
返回读到的总字节数,如果遇到文件尾端,已无数据可读,则返回0。
14.7 函数readn和writen
14.8 存储映射I/O
存储映射I/O(memory-mapped I/O)能将一个磁盘文件映射到存储空间中的一个缓冲区上,于是,当从缓冲区中取数据时,就相当于读文件中的相应字节,将数据存入缓冲区时,相应字节就自动写入文件。这样,就可以在不使用read
和write
的情况下执行I/O。
mmap
函数告诉内核将一个给定的文件映射到一个存储区域中:
#include <sys/mman.h>
void *mmap(void *addr, size_t len, int prot, int flag, int fd, off_t off);
// 返回值:若成功,返回映射区的起始地址;若出错,返回 MAP_FAILED
- addr参数用于指定映射存储区的起始地址,通常将其设置为0,表示由系统选择该映射区的起始地址;
- fd参数是指定要被映射文件的描述符;
- len参数是映射的字节数;
- off是要映射字节在文件中的起始偏移量;
- prot参数指定了映射存储区的保护要求,如下图:
- flag参数影响映射存储区的多种属性:
- MAP_FIXED:返回值必须等于addr,不鼓励使用该标志;
- MAP_SHARED:这一标志描述了本进程对映射区所进行的存储操作配置。此标志指定存储操作修改映射文件,也就是,存储操作相当于对该文件的
write
; - MAP_PRIVATE:此标志说明对映射区的存储操作导致创建该映射文件的一个私有副本。所有后来对该映射区的引用都是引用该副本。
调用mprotect
可以更改一个现有映射的权限:
#include <sys/mman.h>
int mprotect(void *addr, size_t len, int prot);
// 返回值:若成功,返回0;若出错,返回-1
- prot的合法值与
mmap
中的prot参数一样; - 地址参数addr的值必须是系统页长的整数倍。
如果共享映射中的页已修改,可以调用msync
将该页冲洗到被映射的文件中:
#include <sys/mman.h>
int msync(void *addr, size_t len, int flags);
// 返回值:若成功,返回0;若出错,返回-1
- 如果映射是私有的,那么不修改被映射的文件;
- addr地址必须与页边界对齐;
- flags参数指定对如何冲洗存储区有某种程度的控制,一定要指定MS_ASYNC和MS_SYNC中的一个:
- MS_ASYNC:简单调试要写的页;
- MS_SYNC:在返回之前等待写操作完成;
- MS_INVALIDATE:可选,通知操作系统丢弃那些与底层存储器没有同步的页。
当进程终止时,会自动解除存储映射区的映射,或者直接调用munmap
函数也可以解除映射区:
#include <sys/mman.h>
int munmap(void *addr, size_t len);
// 返回值:若成功,返回0;若出错,返回-1
- 关闭映射存储区时使用的文件描述符并不解除映射区;
munmap
并不影响被映射的对象,调用munmap
并不会使映射区的内容写到磁盘文件上。
实例代码
chapter14