APUE笔记:Advanced I/O(十四)

本文记录《UNIX环境高级编程》第3版 第14章 Advanced I/O 的一些知识点。


非阻塞I/O

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

  • 如果某些文件类型(如读管道、终端设备和网络设备)的数据并不存在,读操作可能会使调用者永远阻塞;

  • 如果数据不能被相同的文件类型立即接受(如管道中无空间、网络流控制),写操作可能会使调用者永远阻塞;

  • 在某种条件发生之前打开某些文件类型可能会发生阻塞(如要打开一个终端设备,需要先等待与之连接的调制解调器应答,又如若以只写模式打开FIFO,那么在没有其他进程已用读模式打开该FIFO时也要等待);

  • 对已经加上强制性记录锁的文件进行读写;

  • 某些ioctl操作;

  • 某些进程间通信函数。

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

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

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

(1)如果调用 open 获得描述符,则可指定 O_NONBLOCK 标志。

(2)对于已经打开的一个描述符,则可调用 fcntl,由该函数打开 O_NONBLOCK 文件状态标志。


记录锁定

当两个人同时编辑一个文件时,在大多数UNIX系统中,该文件的最后状态取决于写该文件的最后一个进程。但是对于有些应用程序,如数据库,进程有时需要确保它正在单独写一个文件。为了向进程提供这种功能,商用UNIX系统提供了记录锁定机制。

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

早期的伯克利版本只支持 flock 函数。该函数只能对整个文件加锁,不能对文件中的一部分加锁。

SVR3通过 fcntl 函数增加了记录锁功能。在此基础上构造了 lockf 函数,它提供了一个简化的接口。这些函数允许调用者对一个文件中任意字节数的区域加锁,长至整个文件,短至文件中的一个字节。

POSIX.1标准的基础是 fcntl 方法。下图列出了各种系统提供的不同形式的记录锁。

System Advisory Mandatory fcntl lockf flock
SUS XSI
FreeBSD 8.0
Linux 3.2.0
Mac OS X 10.6.8
Solaris 10

本节最后部分将说明建议性锁和强制性锁之间的区别。

fcntl Record Locking

fcntl 函数的原型如下:

1
2
3
#include <fcnt1.h>
int fcnt1(int fd, int cmd, .../* struct flock *flockptr */);
// 返回值:若成功,依赖于cmd(见下),否则,返回−1

对于记录锁定,cmdF_GETLKF_SETLKF_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)。

  • 区域的字节长度(l_len)。

  • 持有可能会阻塞当前进程的锁的进程的ID(l_pid

如果一个进程对一个文件区间已经有了一把锁,后来该进程又企图在同一文件区间再加一把锁,那么新锁将替换已有锁。因此,若一进程在某文件的16~32字节区间有一把写锁,然后又试图在16~32字节区间加一把读锁,那么该请求将成功执行,原来的写锁会被替换为读锁。

加读锁时,该描述符必须是读打开。加写锁时,该描述符必须是写打开。

下面说明一下 fcntl函数的3种命令。

F_GETLK 判断由 flockptr 所描述的锁是否会被另外一把锁所排斥(阻塞)。如果存在一把锁,它阻止创建由 flockptr 所描述的锁,则该现有锁的信息将重写flockptr 指向的信息。如果不存在这种情况,则除了将 l_type 设置为 F_UNLCK 之外, flockptr 所指向结构中的其他信息保持不变。

F_SETLK 设置由 flockptr 所描述的锁。如果我们试图获得一把读锁(l_typeF_RDLCK)或写锁(l_typeF_WRLCK),而兼容性规则阻止系统给我们这把锁,那么 fcntl 会立即出错返回,此时errno 设置为 EACCESEAGAIN。此命令也用来清除由 flockptr 指定的锁(l_typeF_UNLCK)。

F_SETLKW 这个命令是 F_SETLK 的阻塞版本(命令名中的W表示等待(wait))。如果所请求的读锁或写锁因另一个进程当前已经对所请求区域的某部分进行了加锁而不能被授予,那么调用进程会被置为休眠。如果请求创建的锁已经可用,或者休眠由信号中断,则该进程被唤醒。

应当了解,用 F_GETLK 测试能否建立一把锁,然后用 F_SETLKF_SETLKW 企图建立那把锁,这两者不是一个原子操作。因此不能保证在这两次 fcntl 调用之间不会有另一个进程插入并建立一把相同的锁。如果不希望在等待锁变为可用时产生阻塞,就必须处理由 F_SETLK 返回的可能的出错。

实例:请求和释放一把锁

为了避免每次分配 flock 结构,然后又填入各项信息,可以用函数 lock_reg 来处理所有这些细节。

1
2
3
4
5
6
7
8
9
10
11
#include "apue.h"
#include <fcntl.h>
int lock_reg(int fd, int cmd, int type, off_t offset, int whence, off_t len)
{
struct flock lock;
lock.l_type = type; /* F_RDLCK, F_WRLCK, F_UNLCK */
lock.l_start = offset; /* byte offset, relative to l_whence */
lock.l_whence = whence; /* SEEK_SET, SEEK_CUR, SEEK_END */
lock.l_len = len; /* #bytes (0 means to EOF) */
return(fcntl(fd, cmd, &lock));
}

因为大多数锁调用是加锁或解锁一个文件区域(命令 F_GETLK 很少使用),故通常使用下列5个宏中的一个。

1
2
3
4
5
#define read_lock(fd,offset,whence,len) 	lock_reg((fd), F_SETLK, F_RDLCK, (offset), (whence), (len))
#define readw_lock(fd,offset,whence,len) lock_reg((fd), F_SETLKW, F_RDLCK, (offset), (whence), (len))
#define write_lock(fd,offset,whence,len) lock_reg((fd), F_SETLK, F_WRLCK, (offset), (whence), (len))
#define writew_lock(fd,offset,whence,len) lock_reg((fd), F_SETLKW, F_WRLCK, (offset), (whence), (len))
#define un_lock(fd,offset,whence,len) lock_reg((fd), F_SETLK, F_UNLCK, (offset), (whence), (len))

实例:测试一把锁

下图中定义了一个函数 lock_test,我们将用它测试一把锁。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include "apue.h"
#include <fcntl.h>
pid_t lock_test(int fd, int type, off_t offset, int whence, off_t len)
{
struct flock lock;
lock.l_type = type; /* F_RDLCK or F_WRLCK */
lock.l_start = offset; /* byte offset, relative to l_whence */
lock.l_whence = whence; /* SEEK_SET, SEEK_CUR, SEEK_END */
lock.l_len = len; /* #bytes (0 means to EOF) */
if (fcntl(fd, F_GETLK, &lock) < 0)
err_sys("fcntl error");
if (lock.l_type == F_UNLCK)
return(0); /* false, region isn’t locked by another proc */
return(lock.l_pid); /* true, return pid of lock owner */
}

如果存在一把锁,它阻塞由参数指定的锁请求,则此函数返回持有这把现有锁的进程的进程ID,否则此函数返回0。通常用下面两个宏来调用此函数。

1
2
#define is_read_lockable(fd, offset, whence, len) (lock_test((fd), F_RDLCK, (offset), (whence), (len)) == 0)
#define is_write_lockable(fd, offset, whence, len) (lock_test((fd), F_WRLCK, (offset), (whence), (len)) == 0)

注意,进程不能使用 lock_test 函数测试它自己是否在文件的某一部分持有一把锁。F_GETLK 命令的定义说明,返回信息指示是否有现有的锁阻止调用进程设置它自己的锁。因为 F_SETLKF_SETLKW 命令总是替换调用进程现有的锁(若已存在,新锁将替换已有锁),所以调用进程决不会阻塞在自己持有的锁上,于是,F_GETLK 命令决不会报告调用进程自己持有的锁。

锁的隐含继承和释放

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

(1)锁与进程和文件两者相关联。这有两重含义:第一重很明显,当一个进程终止时,它所建立的锁全部释放;第二重则不太明显,无论一个描述符何时关闭,该进程通过这一描述符引用的文件上的任何一把锁都会释放。

(2)锁不会在fork操作中由子进程继承。这意味着,如果一个进程获取了锁,然后调用fork,那么对于父进程所获取的锁而言,子进程会被视为另一个进程。子进程必须调用 fcntl,以在通过fork继承的任何描述符上获取自己的锁。这种限制是合理的,因为锁的作用是防止多个进程同时写入同一个文件。如果子进程在fork时继承了锁,那么父进程和子进程就可能同时写入同一个文件。

(3)在执行exec时,新程序会继承锁。不过需要注意的是,如果为文件描述符设置了执行时关闭(close-on-exec)标志,那么当该描述符作为exec的一部分被关闭时,底层文件的所有锁都会被释放。

FreeBSD实现

先简要地观察FreeBSD实现中使用的数据结构。这会帮助进一步理解记录锁的自动继承和释放的第一条规则:锁与进程和文件两者相关联。

考虑一个进程,它执行下列语句(忽略出错返回)。

1
2
3
4
5
6
7
8
9
fd1 = open(pathname, ...);
write_lock(fd1, 0, SEEK_SET, 1);  /* parent write locks byte 0 */
if ((pid = fork()) > 0) {      /* parent */
fd2 = dup(fd1);
fd3 = open(pathname, ...);
} else if (pid == 0) {
read_lock(fd1, 1, SEEK_SET, 1); /* child read locks byte 1 */
}
pause();

下图显示了父进程和子进程暂停(执行pause())后的数据结构情况。

The FreeBSD data structures for record locking

每个 lockf 结构描述了一个给定进程的一个加锁区域(由偏移量和长度定义的)。图中显示了两个 lockf 结构,一个是由父进程调用 write_lock 形成的,另一个则是由子进程调用 read_lock 形成的。每一个结构都包含了相应的进程ID。

在父进程中,关闭fd1、fd2或fd3中的任意一个都将释放由父进程设置的写锁。在关闭这3个描述符中的任意一个时,内核会从该描述符所关联的i节点开始,逐个检查 lockf 链接表中的各项,并释放由调用进程持有的各把锁。内核并不清楚(也不关心)父进程是用这3个描述中的哪一个来设置这把锁的。

实例

守护进程可用一把文件锁来保证只有该守护进程的唯一副本在运行。下图展示了 lockfile 函数的实现,守护进程可用该函数在文件上加写锁。

1
2
3
4
5
6
7
8
9
10
11
#include <unistd.h>
#include <fcntl.h>
int lockfile(int fd)
{
struct flock fl;
fl.l_type = F_WRLCK;
fl.l_start = 0;
fl.l_whence = SEEK_SET;
fl.l_len = 0;
return(fcntl(fd, F_SETLK, &fl));
}

另一种方法是用 write_lock 函数定义 lockfile 函数。

#define lockfile(fd) write_lock((fd), 0, SEEK_SET, 0)


I/O多路复用

为了使用这种技术,先构造一张我们感兴趣的描述符(通常都不止一个)的列表,然后调用一个函数,直到这些描述符中的一个已准备好进行I/O时,该函数才返回。pollpselectselect 这3个函数使我们能够执行I/O多路复用。在从这些函数返回时,进程会被告知哪些描述符已准备好可以进行I/O。

函数select和pselect

传给select的参数告诉内核:

  • 我们所关心的描述符;

  • 对于每个描述符我们所关心的条件(是否想从一个给定的描述符读,是否想写一个给定的描述符,是否关心一个给定描述符的异常条件);

  • 愿意等待多长时间(可以永远等待、等待一个固定的时间或者根本不等待)。

从select返回时,内核告诉我们:

  • 已准备好的描述符的总数量;

  • 对于读、写或异常这3个条件中的每一个,哪些描述符已准备好。

使用这种返回信息,就可调用相应的I/O函数(一般是read或write),并且确知函数不会阻塞。

1
2
3
4
#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

先来说明最后一个参数,它指定愿意等待的时间长度,单位为秒和微秒。有以下3种情况。

  1. tvptr == NULL 永远等待。如果捕捉到一个信号则中断此无限期等待。当所指定的描述符中的一个已准备好或捕捉到一个信号则返回。如果捕捉到一个信号,则select返回-1,errno设置为 EINTR

  2. tvptr->tv_sec == 0 && tvptr->tv_usec == 0 根本不等待。测试所有指定的描述符并立即返回。这是轮询系统找到多个描述符状态而不阻塞select函数的方法。

  3. tvptr->tv_sec != 0 || tvptr->tv_usec != 0 等待指定的秒数和微秒数。当指定的描述符之一已准备好,或当指定的时间值已经超过时立即返回。如果在超时到期时还没有一个描述符准备好,则返回值是 0。与第一种情况一样,这种等待可被捕捉到的信号中断。

POSIX.1允许实现修改 timeval 结构中的值,所以在select返回后,你不能指望该结构仍旧保持调用select之前它所包含的值。FreeBSD 8.0、Mac OS X 10.6.8和Solaris 10都保持该结构中的值不变。但是,若在超时时间尚未到期时,select就返回,那么Linux 3.2.0将用剩余时间值更新该结构。

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

Specifying the read, write, and exception descriptors for select

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

1
2
3
4
5
6
#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 将这个描述符集置为0,然后在其中设置我们关心的各个描述符的位。具体操作如下所示:

1
2
3
4
5
fd_set rset;
int  fd;
FD_ZERO(&rset);
FD_SET(fd, &rset);
FD_SET(STDIN_FILENO, &rset);

select 返回时,可以用 FD_ISSET 测试该集中的一个给定位是否仍处于打开状态:

1
2
3
if (FD_ISSET(fd, &rset)) {
...
}

select 的中间3个参数(指向描述符集的指针)中的任意一个(或全部)可以是空指针,这表示对相应条件并不关心。如果所有3个指针都是NULL,则 select 提供了比sleep更精确的定时器。(sleep 等待整数秒,而 select 的等待时间则可以小于1秒,其实际精度取决于系统时钟。)

select 第一个参数 maxfdp1 的意思是“最大文件描述符编号值加1”。考虑所有3个描述符集,在3个描述符集中找出最大描述符编号值,然后加1,这就是第一个参数值。也可将第一个参数设置为 FD_SETSIZE,这是 <sys/select.h> 中的一个常量,它指定最大描述符数(经常是1 024),但是对大多数应用程序而言,此值太大了。确实,大多数应用程序只使用3~10个描述符(某些应用程序需要更多的描述符,但这种UNIX程序并不典型)。通过指定我们所关注的最大描述符,内核就只需在此范围内寻找打开的位,而不必在3个描述符集中的数百个没有使用的位内搜索。

select有3个可能的返回值。

(1)返回值-1表示出错。这是可能发生的,例如,在所指定的描述符一个都没准备好时捕捉到一个信号。在此种情况下,一个描述符集都不修改。

(2)返回值0表示没有描述符准备好。若指定的描述符一个都没准备好,指定的时间就过了,那么就会发生这种情况。此时,所有描述符集都会置0。

(3)一个正返回值说明了已经准备好的描述符数。该值是3个描述符集中已准备好的描述符数之和,所以如果同一描述符已准备好读和写,那么在返回值中会对其计两次数。在这种情况下, 3个描述符集中仍旧打开的位对应于已准备好的描述符。

如果在一个描述符上碰到了文件尾端,则select会认为该描述符是可读的。然后调用read,它返回0,这是UNIX系统指示到达文件尾端的方法。

POSIX.1也定义了一个 select 的变体,称为 pselect

1
2
3
4
#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

除下列几点外,pselectselect 相同。

  • select 的超时值用 timeval 结构指定,但 pselect 使用 timespec结构。timespec 结构以秒和纳秒表示超时值,而非秒和微秒。如果平台支持这样的时间精度,那么 timespec 就能提供更精准的超时时间。

  • pselect 的超时值被声明为 const,这保证了调用 pselect 不会改变此值。

  • pselect 可使用可选信号屏蔽字。若 sigmaskNULL,那么在与信号有关的方面, pselect 的运行状况和 select 相同。否则,sigmask 指向一信号屏蔽字,在调用 pselect 时,以原子操作的方式安装该信号屏蔽字。在返回时,恢复以前的信号屏蔽字。在 pselect 调用期间被屏蔽的信号不会被处理,但在 pselect 返回后,这些信号会恢复为可处理状态。

函数poll

1
2
3
#include <poll.h>
int poll(struct pollfd fdarray[], nfds_t nfds, int timeout);
// 返回值:准备就绪的描述符数目;若超时,返回0;若出错,返回-1

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

1
2
3
4
5
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 指定。

当一个描述符被挂断(POLLHUP)后,就不能再写该描述符,但是有可能仍然可以从该描述符读取到数据。

poll 的最后一个参数指定的是我们愿意等待多长时间。如同 select 一样,有3种不同的情形。

  1. timeout == -1永远等待。当所指定的描述符中的一个已准备好,或捕捉到一个信号时返回。如果捕捉到一个信号,则poll返回-1,errno设置为 EINTR

  2. timeout == 0 不等待。测试所有描述符并立即返回。这是一种轮询系统的方法,可以找到多个描述符的状态而不阻塞 poll 函数。

  3. timeout > 0 等待 timeout 毫秒。当指定的描述符之一已准备好,或 timeout 到期时立即返回。若 timeout 到期时还没有一个描述符准备好,返回值是0。

理解文件尾端与挂断之间的区别是很重要的。如果我们正从终端输入数据,并键入文件结束符,那么就会打开 POLLIN,于是我们就可以读文件结束指示(read返回0)。revents 中的 POLLHUP 没有打开。如果正在读调制解调器,并且电话线已挂断,我们将接到 POLLHUP 通知。


异步I/O

在探讨使用异步输入/输出的不同方式之前,我们需要先讨论其成本。当我们决定使用异步输入/输出时,由于要处理多个并发操作,会使应用程序的设计变得复杂。一种更简单的方法可能是使用多线程,这能让我们采用同步模型来编写程序,并让各个线程彼此异步运行。


函数readv和writev

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

1
2
3
4
#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 结构数组的一个指针:

1
2
3
4
struct iovec {
void *iov_base; /* starting address of buffer */
size_t iov_len; /* size of buffer */
};

iov 数组中的元素数由 iovcnt 指定,其最大值受限于 IOV_MAX

writev 函数从缓冲区中聚集输出数据的顺序是:iov[0]iov[1] 直至 iov[iovcnt-1]writev 返回输出的字节总数,通常应等于所有缓冲区长度之和。

readv 函数则将读入的数据按上述同样顺序散布到缓冲区中。readv 总是先填满一个缓冲区,然后再填写下一个。readv 返回读到的字节总数。如果遇到文件尾端,已无数据可读,则返回0。


函数readn和writen

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

(1)即使我们尚未遇到文件末尾,read 操作返回的数据也可能少于请求的数据量。这并非错误,只需继续从设备读取即可。

(2)write 操作返回的字节数可能少于我们指定的数量。例如,这可能是由内核输出缓冲区已满导致的。不过,这并非错误,我们应该继续写入剩余的数据。(通常,只有在使用非阻塞描述符或者捕获到信号时,才会出现写操作返回的字节数少于预期这种情况。)

在读写磁盘文件时,永远不会遇到这种情况,除非文件系统空间耗尽,或者达到了配额限制,导致无法写入所有请求的内容。

通常,在读、写一个管道、网络设备或终端时,需要考虑这些特性。可以分别使用 readnwriten 函数来读取和写入N字节的数据,让这些函数处理可能小于请求值的返回值。这两个函数会根据需要多次调用read或write,以读取或写入全部的N字节数据。

1
2
3
4
// 自定义函数
ssize_t readn(int fd, void *buf, size_t nbytes);
ssize_t writen(int fd, void *buf, size_t nbytes);
// 两个函数的返回值:读、写的字节数;若出错,返回−1
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
#include <unistd.h>
#include <sys/types.h>
#include <stddef.h>

// 从指定描述符读n个字节
ssize_t readn(int fd, void *buf, size_t nbytes)
{
size_t nleft;
ssize_t nread;
nleft = nbytes;
while(nleft > 0)
{
if((nread = read(fd,buf,nleft)) < 0)
{
if(nleft == nbytes)
return -1;
else
break; // 返回已经读取的数量
}
else if(nread == 0)
break; // EOF
nleft -= nread;
buf += nread;
}
return (nbytes - nleft);
}

// 向指定描述符写n个字节
ssize_t writen(int fd, void *buf, size_t nbytes)
{
size_t nleft;
ssize_t nwrite;
nleft = nbytes;
while(nleft > 0)
{
if((nwrite = write(fd,buf,nleft)) < 0)
{
if(nleft == nbytes)
return -1;
else
break;
}
else if(nwrite == 0)
break;
nleft -= nwrite;
buf += nwrite;
}
return (nbytes - nleft);
}

Memory-Mapped I/O

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

要使用此功能,我们必须告知内核将给定的文件映射到内存中的一个区域。这项任务由 mmap 函数处理。

1
2
3
#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 参数指定了映射存储区的保护要求,如表所示。

prot Description
PROT_READ Region can be read.
PROT_WRITE Region can be written.
PROT_EXEC Region can be executed.
PROT_NONE Region cannot be accessed.

可将 prot 参数指定为 PROT_NONE ,也可指定为 PROT_READPROT_WRITEPROT_EXEC 的任意组合的按位或。对指定映射存储区的保护要求不能超过文件open模式访问权限。例如,若该文件是只读打开的,那么对映射存储区就不能指定 PROT_WRITE

在说明 flag 参数之前,先看一下存储映射文件的基本情况。下图显示了一个存储映射文件。在此图中,“起始地址”是 mmap 的返回值。内存映射区位于堆和栈之间。

Example of a memory-mapped file

下面是 flag 参数影响映射存储区的多种属性。

  • MAP_FIXED 返回值必须等于 addr。因为这不利于可移植性,所以不鼓励使用此标志。如果未指定此标志,而且 addr 非0,则内核只把 addr 视为在何处设置映射区的一种建议,但是不保证会使用所要求的地址。将 addr 指定为0可获得最大可移植性。

  • MAP_SHARED 此标志描述了该进程将存储操作部署到映射区域的方式。该标志规定,存储操作会修改映射文件,也就是说,一次存储操作等同于对该文件的一次写入。必须指定此标志或下一个标志(MAP_PRIVATE),但不能同时指定两者。

  • MAP_PRIVATE 该标志表明,对映射区域执行存储操作会导致创建映射文件的私有副本。所有后来对映射区域的引用会转而引用该副本。(此标志的一种用途是供调试器使用,调试器会映射程序文件的文本部分,但允许用户修改指令。任何修改都会影响副本,而不是原始程序文件。)

off 的值和 addr 的值(如果指定了 MAP_FIXED)通常被要求是系统虚拟存储页长度的倍数。虚拟存储页长可用带参数_SC_PAGESIZE_SC_PAGE_SIZEsysconf 函数得到。因为off和addr常常指定为0,所以这种要求一般并不重要。

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

子进程能通过fork继承内存映射区(因为子进程复制父进程地址空间,而内存映射区是该地址空间中的一部分),但是由于同样的原因,新程序则不能通过 exec 继承存储映射区。

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

1
2
3
#include <sys/mman.h>
int mprotect(void *addr, size_t len, int prot);
// 返回值:若成功,返回0;若出错,返回-1

prot 的合法值与 mmapprot 参数的一样。请注意,实现可能要求 addr 的值必须是系统页长的整数倍。

如果修改的页是通过 MAP_SHARED 标志映射到地址空间的,那么修改并不会立即写回到文件中。相反,何时写回脏页由内核的守护进程决定,决定的依据是系统负载和用来限制在系统失败事件中的数据损失的配置参数。因此,如果只修改了一页中的一个字节,当修改被写回到文件中时,整个页都会被写回。

如果共享映射中的页已修改,那么可以调用 msync 将该页冲洗到被映射的文件中。msync 函数类似于 fsync,但作用于内存映射区。

1
2
3
#include <sys/mman.h>
int msync(void *addr, size_t len, int flags);
// 返回值:若成功,返回0;若出错,返回-1

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

flags 参数使我们对如何冲洗存储区有某种程度的控制。可以指定 MS_ASYNC 标志来简单地安排页面的写入。如果希望在返回之前等待写操作完成,则可指定 MS_SYNC 标志。一定要指定 MS_ASYNCMS_SYNC 中的一个。

MS_INVALIDATE是一个可选标志,允许我们通知操作系统丢弃那些与底层存储器没有同步的页。若使用了此标志,某些实现将丢弃指定范围中的所有页,但这种行为并不是必需的。

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

1
2
3
#include <sys/mman.h>
int munmap(void *addr, size_t len);
// 返回值:若成功,返回0;若出错,返回−1

munmap 并不影响被映射的对象,也就是说,调用 munmap 并不会使映射区的内容写到磁盘文件上。对于 MAP_SHARED 区域,内核的虚拟内存算法会在我们向内存映射区域写入数据后的某个时刻自动更新磁盘文件。而对于 MAP_PRIVATE 区域,当该区域被解除映射时,对其内存所做的修改会被丢弃。