第十四章:高级I/O

14.1:引言

本章内容包括非阻塞I/O、记录锁、系统V流机制、I/O多路转接(select和poll函数)、readv和writev函数以及存储映射I/O(mmap),这些都称为高级I/O。

14.2:非阻塞I/O

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

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

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

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

 实例 14-1:长的非阻塞write

14.3:记录锁

记录锁(record lock)的功能是:当一个进程正在修改或读文件的某以部分时,它可以阻止其他进程修改同以文件区。

1. fcntl记录锁

#include <fcntl.h>
int fcntl(int filedes, int cmd, .../* struct flock *flockpty */);
// 返回值:若成功则依赖于cmd,若失败则返回-1

对于记录锁,cmd是F_GETLK、F_SETLK或F_SETLKW。第三个参数是一个指向flock结构的指针:

struct flock
{
    short l_type; // F_RDLCK、F_WRLCK、or F_UNLCK
    off_t l_start; // offset in bytes, relative to l_whence
    short l_whence; // SEEK_SET、SEEK_CUR、or SEEK_END
    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_start和l_whence决定

区域的字节长度,由l_len表示。

具有能阻塞当前进程的锁,其持有进程的进程ID放在l_pid中(仅由F_GETLK返回)

关于加锁和解锁区域的说明还要注意下列几点:

l_start是相对偏移量,l_whence则决定了相对偏移量的起点。l_whence的可选值是SEEK_SET、SEEK_CUR、SEEK_END。

如若l_len是0,则表示锁的区域从其起点(由l_start和l_whence决定)直至最大可能偏移量为止,也就是不管文件添加多少数据,它们都在锁的范围之内。

为了锁整个文件,我们设置l_start和l_whence,使锁的起点位于文件开始处,并且说明长度(l_len)为0。(有多种方法可以指定文件开始处,但最常见的方法是设置l_start为0,l_whence为SEEK_SET。)

锁的兼容性

共享锁和独占锁的基本规则是:多个进程在一个给定的字节可以有一把共享的读锁,但是在给定的字节上,只能有一个进程有一把独占的写锁。进一步而言,如果在给定的字节上有一把或多把读锁,则不能在该字节上加写锁;同样,如果再给定的字节上有一个写锁,则不能在字节上加读锁。

上述规则适用于不同进程提出的锁请求,并不适用于单个进程提出的多个锁请求。如果一个进程对一个文件区间有一把锁,后来该进程又企图在该文件区间上加另外一把锁,则新锁将替换老锁。

加读锁时,该文件必须是读打开;加写锁时,该文件必须是写打开。

以下说明fcntl函数的三种命令:

F_GETLK:判断由flockptr所描述的锁是否被另一把锁所排斥。如果一把排斥flockptr所描述的锁,则把该现存锁的信息写到flockptr指向的结构中。如果不存在一把排斥的锁,则只将l_type设置为F_UNLCK,flockptr指向的结构的其他信息不变。该函数的作用就是测试由flockptr所指向的锁能不能加到执行文件区间。

F_SETLK:设置由flockptr描述的锁。如果要建立一把读锁或写锁,按上述兼容性规则,如果不能创建该锁,则fcntl出错返回,此时errno设置为EAGAIN。此命令也用于清除锁,把l_type设置为F_UNLCK。

F_SETLKW:这是F_SETLK的阻塞版本(W表示wait)。如果当前所请求的区间的某一部分,另一个进程已有一把锁,而按兼容性规则由flockptr描述的锁无法创建,则进程休眠。如果请求的锁已可用,或者休眠由信号中断,则该进程被唤醒。

注意:

用F_GETLK测试一把锁,然后用F_SETLK或者F_SETLKW来设置锁,这两步不是一个原子操作。因此不能保证在使用F_SETLK时,测试的结果依然不变。

2. 锁的隐含继承和释放

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

(1)锁与进程和文件两方面有关。这有两重含义:第一重很明显,当一个进程终止时,它所建立的锁全部释放;第二重意思就不很明显了,任何时候关闭一个描述符时,则进程通过这一描述符可以引用的文件上的任何一把锁都被释放(这些锁都是该进程设置的)。

(2)由fork产生的子进程不继承父进程所设置的锁。

(3)在执行exec后,新程序可以继承原执行程序的锁。但是请注意,如果对一个文件描述符设置了close-on-exec标志,那么当作为exec的一部分关闭该文件描述符时,对相应文件的所有锁都被释放了。

3.实例 在文件整体上加锁

我们了解到,守护进程可以利用一把锁来保证只有守护进程的唯一副本在运行。下面函数实现了这种机制。

#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来实现:

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

4.在文件尾端加锁

在接近文件尾端加锁或解锁一定要小心。

如下代码:

write_lock(fd, 0, SEEK_END, 0);
write(fd, buf, 1);
un_lock(fd, 0, SEEK_END);
write(fd, buf, 1);

该代码所做的可能不是你所期望的。首先,它得到一把写锁,它从当前文件尾端起,包括以后可能添加到文件中的所有字节。然后,它在文件尾端添加了一个字节,该字节将被加锁。随后,解锁,但是刚才增加的那个字节将仍旧被锁着。最后,第二个写,这次写入的一个字节是不被加锁的。

所以,这段代码执行完之后,该文件中倒数第二个字节是被锁着的。跟我们的预期差别很远吧。

5.建议性锁和强制性锁

14.4:STREAMS

不太理解STREAMS机制、STREAMS设备是什么意思。

14.5:I/O多路转接

 当从一个描述符读,然后又写入到另外一个描述符时,可以在下列形式的循环中使用阻塞I/O:

while ((n = read(STDIN_FILENO, buf, BUFSIZE)) > 0)
{
    if (write(STDOUT_FILENO, buf, n) != n)
    {
        perror("write error!");
    }
}

这种形式的阻塞I/O到处可见。但是如果必须从两个描述符读,又将如何呢?如果仍旧使用阻塞I/O,那么就可能长时间阻塞在一个描述符上,而另一个描述符虽有很多数据却不能得到及时处理。所以为了处理这种情况显然需要另一种不同的技术。

 处理这种问题的一个方法是PPC或者TPC。即一个连接一个进程、一个连接一个线程,但是这样做也会增加进程间通信和线程间同步的复杂度。

还有一种方法是使用非阻塞I/O(nonblocking I/O)

基本方法是将两个输入描述符设置为非阻塞的,对第一个描述符发出read操作,如果有数据则处理,如果没有数据,则read立即返回。然后对第二个描述符做同样的操作。在此之后等待若干秒,循环上述操作。这种方式成为轮询。

这种方式的不足之处是浪费CPU。

还有一种技术称之为异步I/O(asynchronous I/O)

其基本思想是进程告诉内核,当一个描述符已准备好可以进行I/O时,用一个信号通知它。这种技术有两个问题。第一,并非所有系统都支持。其次,这种信号对每个进程而言只有一个,在接到该信号时进程无法判断是哪一个描述符已准备好可以进行I/O。为了确定是哪一个,仍需将这两个描述符都设置为非阻塞的,并顺序试执行I/O。

一种比较好的技术是使用I/O多路转接(I/O multiplexing)

先构造一张有关描述符的列表,然后调用一个函数,直到该描述符列表中的一个已准备好I/O时,该函数才返回。在返回时,它告诉进程哪些描述符已准备好可以进行I/O。

poll、select、pselect这三个函数可以让我们实现I/O多路转接。

14.5.1:select、pselect函数

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

我们所关心的描述符。

对每个描述符,我们所关心的状态。(是否读一个给定描述符,是否写一个给定描述符,是否关心一个描述符的异常状态)

愿意等待多久。

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

已准备好I/O的描述符个数。

对于读、写、异常这三个状态中的每一个,哪些描述符已准备好。

使用这些信息就可以调用相应的I/O函数,并且确定这些I/O函数不会阻塞。

#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

 先说明最后一个参数,它说明愿意等待的时间:

struct timeval
{
    long tv_sec; // seconds
    long tv_usec; // microseconds
};

有三种情况:

tvptr=NULL 永远等待。

tvptr->tv_sec==0 && tvptr->tv_usec=0 完全不等待。

tvptr->tv_sec!=0 || tvptr->tv_usec != 0 等待指定时间。若超时,则返回0。

POSIX允许实现中修改timeval的值,所以在select返回后,你不能指望该结构保持之前的值。在Linux 2.4.22中,若在该指定时间尚未超时就返回,那么就将用余下的时间值更新该结构。注意与poll函数中对应参数做对比。

中间三个参数readfds、writefds和exceptfds是指向描述符集的指针。这三个描述符集说明了我们关心的可读、可写或处于异常条件的各个描述符。

对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_sete *fdset);

调用FD_ZERO将一个指定fd_set变量的所有位置为0;调用FD_SET设置一个fd_set变量的指定位;调用FD_CLR清除一个fd_set变量的指定位;然后调用FD_ISSET测试fd_set变量的指定位是否设置。

select函数的中间三个参数的任意一个或全部都可以为NULL。如果三个都是NULL,则select提供一个高精度的计时器。

select函数的第一位参数maxfdp1的意思是“最大描述符加1”。

select有三个可能的返回值:

返回-1表示出错。

返回0表示没有描述符准备好。

返回正值表示已经准备好的描述符数。该值是三个描述符集中已准备好的描述符的和。

14.5.2:poll函数

poll函数类似于select,但其程序员接口不同。

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

 与select不同,poll不是为每个状态构造一个描述符集,而是构造一个poll结构数组,每个数组元素指定一个描述符编号及其所关心的状态。

struct pollfd
{
    int fd;            // file descriptor to check
    short events;     // events of interest on fd
    short revents;     // events that occurred on fd
};

fdarray的个数由nfds参数指定。

应将events成员设置成以下值。通过这些值,告诉内核对该描述符我们关心哪些状态。返回时,内核设置revents成员,以说明对于该描述符已发生了什么事件。(注意,poll没有更改events成员,这与select不同,select修改其参数以指示哪一个描述符已准备好了。)

----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------

标志名    输入致events    从reevents得到结果    说明

----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------

POLLIN    *          *            不阻塞的可读高优先级外的数据

POLLRDNORM  *          *            不阻塞的可读普通数据

POLLRDBAND *          *            不阻塞的可读非0优先级波段数据

POLLPRI     *          *            不阻塞的可读高优先级数据

----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------

POLLOUT   *          *            不阻塞的可写普通数据

POLLWRNORM *            *            与POLLOUT相同

POLLWRBAND *           *            不阻塞的可写非0优先级波段数据

----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------

POLLERR               *            已出错

POLLHUP              *            已挂断

POLLNVAL              *            描述符不引用一个打开文件

---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- 

前四行测试可读性,中间三行测试可写性,最后三行测试异常状态。最后三行是由内核在返回时设置的,即使在events中没有设置这三个值,如果有异常情况发生,也会在reevents中返回它们。

poll的最后一个参数表示我们愿意等待多长时间。与select类似,有三种情况:

timeout == -1 永远等待。捕捉信号返回,则poll返回-1,errno设置为EINTR。

timeout == 0 不等待。

timeout > 0 等待timeout毫秒。超时返回0

 应当理解文件结束和挂断的区别。如果正从终端输入数据,并键入文件结束符,POLLIN被打开,于是就可读文件结束指示(read 0)。POLLHUP在revents中没有打开。如果正读调制解调器,电话线已挂断,则在revents中将接到POLLHUP通知。

与select一样,不论描述符是否阻塞,都不影响poll是否阻塞。

select与poll的可中断性

在接到信号后,select和poll都不自动重启。

14.6:异步I/O

 使用上一节的select、poll可以实现异步形式的通知。关于描述符的状态,系统并不主动告诉我们,需要我们主动查询(调用select或poll)。信号机构提供一种以异步形式通知某种事件已发生的方法。

但是异步I/O的一个限制是每个进程只有一个信号。如果要对几个描述符进行异步I/O,那么在进程收到该信号时并不知道信号对应于哪一个描述符。

14.7:readv和writev函数

readv和writev函数用于在一次函数调用中读取、写入多个缓冲区。也称为散布读、聚集写。

#include <sys/uio.h>
ssize_t readv(int filedes, const struct iovec *iov, int iovcnt);
ssize_t writev(int filedes, const struct iovec *iov, int iovcnt);
// 两个函数返回值,若成功,返回已读、写的字节数,若失败返回-1

这两个函数的第二个参数是指向iovec结构数组的指针:

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

14.8:readn和writen函数

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

(1)一次read返回的数据可能少于要求的数据。

(2)一次write返回的个数也可能小于要写入的数据长度。

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

#include "apue.h"
ssize_t readn(int filedes, void *buf, size_t nbytes);
ssize_t writen(int filedes, void *buf, size_t nbytes);
// 两个函数返回值:已读写字节数,若出错返回-1

注意:这两个函数并非任何标准,而是apue这本书中作者写出来的,方便以后使用。

14.9:存储映射I/O

存储映射I/O使一个磁盘文件和存储空间中的一个缓冲区相映射,于是当从缓冲区中读取数据,就相当于读文件中的相应字节。与此类似,将数据存入缓冲区,则相应字节就自动写入文件。

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

#include <sys/mmap.h>
void *mmap(void *addr, size_t len, int prot, int flag, int filedes, off_t off);
// 返回值:若成功则返回映射区的起始地址,若出错则返回MAP_FAILED

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

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

prot参数说明对映射存储区的保护要求。

PROT_READ 映射区可读
PROT_WRITE 映射区可写
PROT_EXEC 映射区可执行
PROT_NONE 映射区不可访问

flag参数影响映射存储区的多种属性

MAP_FIXED 返回值必须等于addr。因为这不利于可移植性,所以不建议使用此标志。如果未指定此标志,而addr非0,则内核只把addr视为一种建议,但是不保证会使用该起始地址。
MAP_SHARED 这一标志说明了本进程对映射存储区所进行的存储操作配置。此标志指定存储操作修改映射文件,也就是说,存储操作相当于对该文件的write操作。必须指定本标志或下一个标志,但不能同时指定。
MAP_PRIVATE 本标志说明,对映射区的存储操作导致创建该映射文件一个私有副本。所有后来对该映射区的引用都是引用该副本,而不是原始文件。

调用mprotect可以更改一个现存的映射存储区的权限:

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

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

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

如果映射是私有的,那么不修改被映射的文件。flags参数使我们对如何冲洗存储区有某种程度的控制。我们可以指定MS_ASYNC标志以简化被写页的调度。如果我们希望在返回之前等待写操作完成,则可以指定MS_SYNC标志。一定要指定MS_ASYNC和MS_SYNC中的一个。

进程终止时,或调用了munmap函数之后,存储映射区就被自动解除映射。关闭文件描述符filedes并不解除映射区。

#include <sys/mman.h>
int munmap(caddr_t addr, size_t len); // 返回值:若成功则返回0,若出错则返回-1

munmap不会影响被映射对象,调用munmap不会将映射存储区的内容写到磁盘文件上。对于MAP_SHARED区磁盘文件的更新,在写到存储映射区时按内核虚存算法自动进行。

在解除了映射后,对于MAP_PRIVATE存储区的修改被丢弃。

实例 14-12:用存储映射I/O复制一个文件。

14.10:小结

本章说明了很多高级I/O功能。

非阻塞I/O--发一个I/O操作,不使其阻塞。

记录锁

系统V流机制

I/O多路转接--select、poll函数

readv和writev函数

存储映射I/O(mmap)

原文地址:https://www.cnblogs.com/lit10050528/p/4464863.html