《UNIX环境高级编程》笔记 第十四章-高级IO

1. 非阻塞I/O

系统调用分为两类:低速系统调用和其他系统调用

低速系统调用是可能会使进程永远阻塞的一类系统调用,包括:

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

1.1 非阻塞I/O

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

对于一个非阻塞的描述符如果无数据可读,则read返回-1,errno为EAGAIN。

非阻塞I/O指的是文件状态标志,即与文件表项有关。会影响使用同一文件表项的所有文件描述符(即使属于不同的进程)。

注意,read函数阻塞的情况

read函数只是一个通用的读文件设备的接口。是否阻塞需要由设备的属性和设定所决定。一般来说,读字符终端、网络的socket描述字,管道文件等,这些文件的缺省read都是阻塞的方式。如果是读磁盘上的文件,一般不会是阻塞方式的。但使用锁和fcntl设置取消文件O_NOBLOCK状态,也会产生阻塞的read效果。

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

  • 如果用open获得描述符,指定O_NONBLOCK标志

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

    int flag = fcntl(fd, F_GETFL);  //获取文件状态标志
    flag |= O_NONBLOCK;
    int ret = fcntl(fd, F_SETFL, flag);  //设置文件状态标志
    

2. 记录锁 record locking

在大多数UNIX系统中,当两个人同时编辑一个文件时,该文件的最后状态取决于写该文件的最后一个进程。但是对于有些应用程序如数据库,进程有时需要确保它正在单独写一个文件。因此可以使用记录锁机制

记录锁的功能当第一个进程正在读或者修改文件的某个部分时,使用记录锁可以阻止其他进程修改同一文件区

其实应该称记录锁为**“字节范围锁”**,因为它锁定的只是文件中的一个区域(也可能是整个文件)

2.1 fcntl记录锁

int fcntl(int fd, int cmd, ... /* struct flock * flockptr */ );

与记录锁相关的cmd是F_GETLK、F_SETLK、F_SETLKW

  • F_GETLK

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

    注意由于调用进程自己的锁并不会阻塞自己的下一次尝试加锁(因为新锁将替换旧锁),因此F_GETLK不会报告调用进程自己持有的锁信息。因此不能用它来测试自己是否在某一文件区域持有一把锁。

  • F_SETLK

    设置由flockptr所描述的锁(共享读锁或独占写锁)。如果失败fcntl函数立即出错返回,errno设置为EACCES或EAGAGIN

  • F_SETLKW

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

第三个参数是一个指向flock结构的指针

struct flock
  {
    short int l_type;	/* 记录锁类型: F_RDLCK, F_WRLCK, or F_UNLCK.	*/
    short int l_whence;	/* SEEK_SET、SEEK_CUR、SEEK_END */
    __off_t l_start;	/* Offset where the lock begins.  */
    __off_t l_len;	/* Size of the locked area; zero means until EOF.  */
    __pid_t l_pid;	/* Process holding the lock.  */
  };
  • l_type:所希望的锁类型。F_RDLCK(共享读锁)、F_WRLCK(独占性写锁)、F_UNLCK(解锁一个区域)
  • l_whence:指示l_start从哪里开始。SEEK_SET(开头)、SEEK_CUR(当前位置)、SEEK_END(结尾)
  • l_start:要加锁或解锁区域的起始字节偏移量
  • l_len:要加锁或解锁区域字节长度
  • l_pid:仅由F_GETLK返回,表示该pid进程持有的锁能阻塞当前进程

注意:

  • 锁可以在当前文件尾端处开始或者越过尾端处开始,但是不能在文件起始位置之前开始
  • 如果l_len为0,则表示锁的范围可以扩展到最大可能偏移量。这意味着不管向该文件中追加写了多少数据,它们都可以处于锁的范围内(不必猜测会有多少字节被追加写到了文件之后)
  • 为了对整个文件加锁,设置l_start和l_whence指向文件起始位置,并且指定长度l_len为0

fcntl可以操作两种锁:共享读锁F_RDLCK和独占性写锁F_WRLCK

  • 任意多个进程在一个给定的字节上可以有一把共享的读锁
  • 但是在一个给定字节上只能有一个进程有一把独占写锁。
  • 如果在一个给定字节上已经有一把或多把读锁,则不能在该字节上再加写锁
  • 如果在一个字节上已经有一把独占性写锁,则不能再对它加任何读锁。
  • 如果一个进程对一个文件区间已经有了一把锁,后来该进程又企图在同一文件区间再加一把锁,那么新锁将替换已有锁。比如一个进程在某文件的16-32字节区间有一把写锁,然后又试图在16-32字节区间加一把读锁,那么该请求成功执行,原来的写锁替换为读锁。
  • 加读锁时,描述符必须是读打开;加写锁时,描述符必须是写打开。

在这里插入图片描述

注意以下两点:

  • 用F_GETLK测试能否建立一把锁,然后用F_SETLK或F_SETLKW企图建立那把锁,这两者不是一个原子操作。不能保证两次fcntl调用之间不会有另一个进程插入并建立一把锁

  • POSIX没有说明下列情况会发生什么:

    第一个进程在某文件区间设置一把读锁,第二个进程试图在同一文件区间加一把写锁时阻塞,然后第三个进程则试图在同一文件区间设置另一把读锁。如果允许第三个进程获得读锁,那么这种实现容易导致希望加写锁的进程饿死

文件记录锁的组合和分裂:

在设置或释放文件上的一把锁时,系统按照要求组合或分裂相邻区。

在这里插入图片描述

例如在100-199字节是加锁区域,当需要解锁第150字节时,则内核将维持两把锁:一把用于100-149字节;另一把用于151-199字节。

如果我们又对第150字节加锁,那么系统会把相邻的加锁区合并成一个区(100-199字节),和开始时又一样了。

2.2 锁的隐含继承和释放

  • 当一个进程终止时,它所建立的锁全部释放;无论一个描述符何时关闭,该进程通过这一描述符引用的文件上的任何一把锁都会释放(这些锁都是该进程设置的)。

    比如fd1代表一个打开文件,调用dup后fd2也指向该文件。那么当close(fd2)后,fd1和fd2指向的文件上设置的锁都被释放。

  • 由fork产生的子进程不继承父进程所设置的锁。因为对于父进程获得的锁而言,子进程被视为另一个进程。

  • 执行exec后,新程序可以继承原执行程序的锁。但是如果该文件描述符设置了close-on-exec标志,则exec之后释放相应文件的锁

示例

fd1 = open(pathname,...);
write_lock(fd1,0,SEEK_SET,1); // 该函数是自定义的,父进程在字节0上设置写锁
if((pid = fork()) > 0) { // 父进程
    fd2 = dup(f1);
    fd3 = open(pathname,...);
} else if(pid == 0) { // 子进程
    read_lock(fd1,1,SEEK_SET,1); //该函数是自定义的,子进程在字节1上设置读锁
}
pause();

其结果如下:

在这里插入图片描述

可以看出来,文件记录锁信息是保存在文件v节点/inode节点上的(而不是在文件表项中的),其实现是通过一个链表记录该文件上的各个锁,因此能保证多个进程正确操作文件记录锁。

在父进程中,关闭fd1、fd2、fd3中的任意一个都将释放由父进程设置的写锁。内核会从该描述符锁关联的inode节点开始,逐个检查lockf链表中的各项,并释放由调用进程持有的各把锁。

在十三章中,我们知道守护进程可用一把文件锁来保证只有该守护进程的唯一副本在运行,其lockfile函数实现如下:守护进程可用该函数在文件上加独占写锁

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);
}

2.3 在文件尾端加锁

在对相对于文件尾端的字节范围加锁解锁必须特别小心。如下面代码:

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

刚开始获得一把写锁,该锁从当前文件尾开始,包括以后可能追加写到该文件的任何数据。当文件偏移量处于文件尾时,write一个字节将文件延伸了一个字节,因此该字节被加写锁。

但是其后的解锁是对当前文件尾开始包括以后可能追加写到该文件的任何数据进行解锁,因此刚才追加写入的一个字节保留加锁状态。之后又写入了一个字节,由此代码造成的文件锁状态如图

在这里插入图片描述

2.4 建议性锁和强制性锁

2.4.1 建议性锁

建议性锁机制是这样规定的:

每个使用文件的进程都要主动检查该文件是否有锁存在,当然都是通过具体锁的API,比如fcntl记录锁F_GETTLK来主动检查是否有锁存在。如果有锁存在并被排斥,那么就主动保证不再进行接下来的IO操作。如果每一个进程都主动进行检查,并主动保证,那么就说这些进程都以一致性的方法处理锁

但是这种一致性方法依赖于编写进程程序员的素质,也许有的程序员编写的进程程序遵守这个一致性方法,有的不遵守。不遵守的程序员编写的进程程序会怎么做呢?也许会不主动判断这个文件有没有加上文件记录锁,就直接对这个文件进行IO操作(例如某个拥有写权限的进程在不判断是否有锁的情况下直接写入该文件)。此时这种有破坏性的IO操作会不会成功呢?如果是在建议性锁的机制下,这种破坏性的IO就会成功。因为锁只是建议性存在的,并不强制执行。内核和系统总体上都坚持不使用建议性锁机制,它们依靠程序员遵守这个规定。(Linux默认是采用建议性锁

2.4.2 强制性锁

对一个特定文件打开其设置组ID位、关闭其组执行位便开启了对该文件的强制性锁机制。因为当组执行位关闭时,设置组ID位不再有意义,因此定义以这两者组合来指定该文件的锁是强制性的而不是建议性的

强制性锁机制是这样规定的:

上述提到的破坏性IO操作可能会被内核禁止(即在该文件范围存在锁时强行进行I/O)。强制性锁会让内核检查每一个open、read、write操作,如果这些操作违背了该文件上的某一把锁,则I/O操作会被禁止(阻塞或直接出错返回)。也就是强制性锁机制,让锁变得名副其实,真正达到了锁的效果,而不是像建议性锁机制那样只是个纸老虎。

如下图所示,当一个进程试图read、write一个强制性锁起作用的文件,而欲读、写的部分又由其他进程加上了锁,那么结果取决于三个方面:

  • 操作类型:read或write
  • 其他进程持有的锁类型:共享读锁或独占写锁
  • read或write该描述符是阻塞还是非阻塞的。

在这里插入图片描述

每个对文件的操作,例如执行open、read、write等操作时,内核检测该文件是否被加了强制锁,如果加锁导致则这些文件操作失败。内核强制应用程序来遵守游戏规则。也就是强制性锁机制,让锁变得名副其实,真正达到了锁的效果,而不是像建议性锁机制那样只是个纸老虎。

注意,对于open函数,即使正在打开的文件具有强制性记录锁,该open也会成功。随后的read或write依从与上图所示规则。除非open以O_TRUNC打开该文件,那么open出错返回,因为如果另一个进程拥有它的读锁或写锁,那么该文件就不能被截断为0。

回到最初的问题,如果两个人同时编辑同一个文件时将会怎样?一般的UNIX文本编辑器不使用记录锁,因此该文件的最后结果取决于写该文件的最后一个进程。但是如果该UNIX文本编辑器支持记录锁则不会是这样,具体结果会根据上面具体介绍的内容。

3. I/O多路转接 select poll epoll

while((n = read(STDIN_FilENO,buf,BUFSIZ) > 0) {
    write(STDOUT_FILENO,buf,n);
}

考虑这种情况:要从两个描述符读,如果每个文件描述符都采用以上方式进行阻塞读,那么可能会因为被阻塞在一个描述符的读操作上而导致另一个描述符也无法处理。

处理这种问题可以考虑用多进程、多线程、非阻塞I/O、异步I/O进行解决,但是都有各种限制

因此可以使用I/O多路转接技术

先构造一张我们感兴趣的描述符列表,然后调用一个函数,直到这些描述符中的一个已准备好进行I/O时,该函数才返回。poll、pselect、select、epoll函数可以使我们能够进行I/O多路转接。这些函数返回时,进程告诉我们哪些文件描述符已经准备好可以进行I/O

3.1 select和pselect

select可以进行I/O多路转接。传给select的参数告诉内核

  • 我们所关心的描述符
  • 对于每个描述符我们关心的条件(读、写、异常条件)
  • 愿意等待多久

而select返回时告诉我们

  • 已准备好的描述符总数量
  • 对于读、写、异常这三个条件,哪些描述符已经准备好

根据这些返回信息,我们可以对满足相应条件的描述符调用I/O函数(read或write等),并且这些函数不会被阻塞。

int select(int nfds, fd_set *readfds, fd_set *writefds,fd_set *exceptfds, struct timeval *timeout);
//返回准备就绪的描述符数目
  • 参数nfds

    最大文件描述符值+1。即为三个描述符集合中最大描述符编号+1。通过该参数,内核就在此范围内寻找三个描述符集中打开的位,而不用在描述符集中的数百个没有使用的位内搜索。

  • 参数readfds/writefds/exceptfds

    指向描述符集fd_set。这三个集合说明了我们关心的可读、可写或处于异常条件的描述符集合。对于这个fd_set描述符集合类型,可以使用以下函数进行操作:

    void	FD_SET(int fd,fd_set* fdsetp); // 开启描述符集中的一位
    void	FD_CLR(int fd,fd_set* fdsetp); // 清除描述符集中的一位
    int 	FD_ISSET(int fd,fd_set* fdsetp); // 测试描述符集中一位是否打开
    void	FD_ZERO(fd_set* fdsetp); // 将描述符集中所有位置0
    

    这些接口可以被实现为宏或函数。在创建一个fd_set对象后,一定要使用FD_ZERO将这个描述符集置0。

    当select函数返回时,这三个描述符集将包含了所有满足条件的描述符,此时可以用FD_ISSET进行测试

  • 参数timeout

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

    • timeout->tv_sec0 && timeout->tv_usec0:不等待,测试指定的描述符并立即返回

    • timeout->tv_sec!=0 || timeout->tv_usec!=0:等待指定时间。当指定的描述符之一准备好,或指定时间超时时返回。如果超时时没一个描述符准备好则返回0。如果捕捉到一个信号则被中断返回-1。

      若在等待时间尚未到期时select返回,那么将用剩余时间值更新该结构。

  • 返回值

    • -1:出错(如捕捉到信号中断)。此时三个描述符集都不修改
    • 0:超时后没有描述符准备好。此时三个描述符集都置0
    • >0:已经准备好的描述符数。此数字是三个描述符集中已准备好的描述符数目之和,所以如果同一描述符已准备好读和写,则返回值中对其计两次数。三个描述符集中仍然打开的位对应于已准备好的描述符。

对于文件描述符“准备好”的含义

  • 若对读集中的一个描述符进行read操作不会阻塞,则认为此描述符是读准备好的。
  • 若对写集中的一个描述符进行write操作不会阻塞,则认为此描述符是写准备好的
  • 若对异常条件集中的一个描述符有一个未决异常条件,则认为此描述符是准备好的。这些异常条件包括:网络连接上到达的带外数据、处于数据包模式的伪终端上发生了某些条件
  • 对于读、写、异常条件,普通文件的文件描述符总是返回准备好。注意,在一个描述符上碰到了文件尾端,则select认为该描述符可读,然后调用read返回0,。

pselect是select函数的变体

int pselect(int nfds, fd_set *readfds, fd_set *writefds,fd_set *exceptfds, const struct timespec *timeout,const sigset_t *sigmask);

除了以下几点,pselect和select相同

  • 超时使用timespec结构,提供纳秒级别精度
  • 超时参数为const类型,若未超时就返回,pselect并不会改变此值。
  • 可使用可选信号屏蔽字。若sigmask参数为NULL则和select相同;否则sigmask指向一信号屏蔽字,在调用pselect时,安装该信号屏蔽字,函数返回前恢复以前的信号屏蔽字。

3.2 poll

poll函数类似于select,但是程序接口不同。poll也可以用于任何类型文件描述符

int poll(struct pollfd *fds, nfds_t nfds, int timeout);//返回就绪的描述符个数
  • fds参数:

    与select不同,poll不为每个条件(读、写、异常)分别构造描述符集,而是构造一个pollfd结构体数组,每个元素指定一个描述符以及对该描述符感兴趣的条件

    struct pollfd {
        int   fd;         /* 文件描述符 */
        short events;     /* 感兴趣的事件 */
        short revents;    /* 返回时指示真实发生的事件 */
    };
    

    其中events成员是下表中的一个或几个,通过这些值告诉内核我们关心每个描述符的哪些事件。返回时,revents成员由内核设置,用于说明每个描述符发生了哪些事件

在这里插入图片描述

注意上图中前四行是测试可读性、接下来三行是测试可写性、最后三行是测试异常条件。最后三行是内核返回时设置的,即使events成员没有指定这三个值,如果相应事件发生,revents中也返回它们。

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

  • 参数nfds:指定fds数组元素个数。

  • 参数timeout:指定超时时间。

    • -1:永远等待。当有一个描述符准备好或捕捉到信号则返回。如果捕捉到信号返回-1,errno置EINTR
    • 0:不等待,测试所有描述符并立即返回
    • >0:等待指定时间。当有一个描述符准备好、timeout到期或收到信号中断则返回。

注意,需要区别文件尾端和挂断之间的区别

如果处于文件尾端,poll认为满足POLLIN,于是poll函数返回,我们通过read读该文件返回0。如果正在读调制解调器而电话线已挂断,则poll认为满足POLLHUP并返回。

select和poll的可中断性:

在接到一信号时都不自动重启动poll和select,即使在sigaction中使用了SA_RESTART标志也是如此

4. 异步I/O

4.1 aiocb结构体(AIO控制块)

POSIX异步I/O为不同类型的文件进行异步I/O提供了一套一致(接口函数)的方法

这些异步I/O接口函数使用AIO控制块来描述I/O操作。aiocb结构体定义了AIO控制块,它至少包括以下字段

/* Asynchronous I/O control block.  */
struct aiocb
{
  int 				aio_fildes;		/* 被打开用来读写的文件描述符  */
  off_t 			aio_offset;		/* 读写操作从该偏移量开始 */
  int 				aio_lio_opcode;		/* 仅被 lio_listio() 函数使用  */
  int 				aio_reqprio;		/* 请求优先级  */
  volatile void *	aio_buf;	/* 对于读操作,数据复制到该缓冲区;对于写操作,数据从该缓冲区中复制出来  */
  size_t 			aio_nbytes;		/* 要读写的字节数  */
  struct sigevent 	aio_sigevent;	/* I/O事件完成后如何通知程序  */
};
  • 注意,异步I/O操作必须显式的指定偏移量aio_offset,因为异步I/O接口并不影响(或使用)由操作系统维护的文件表项中记录的偏移量。

  • 如果使用异步I/O向一个以追加模式(O_APPEND)open的文件中写入数据,则aio_offset字段被忽略。

  • aio_reqprio字段是异步I/O请求提示顺序(请求优先级。但是系统对于该顺序只有有限的控制力,即不一定遵循。

  • aio_lio_opcode字段只能用于基于列表的异步I/O仅被 lio_listio() 函数使用

  • aio_sigevent字段控制I/O事件完成后,如何通知应用程序,通过sigevent结构体描述

    struct sigevent
      {
        int 			sigev_signo;				/* 通知的信号编号 */
        int 			sigev_notify;				/* 通知类型 */
    	union sigval	sigev_value;				/* 通知参数 */
        void(*sigev_notify_function)(union sigval); /* 通知函数 */
        pthread_attr_t * sigev_notify_attributes;   /* 线程属性 */
      };
    
    • sigev_notify字段

      控制通知的类型,取值为以下三个中的一个

      • SIGEV_NONE:异步I/O请求完成后,不通知进程
      • SIGEV_SIGNAL:异步I/O请求完成后,产生由sigev_signo字段指定的信号。如果应用程序要捕获该信号,且在sigaction时指定了SA_SIGINFO标志,那么该信号将被入队(如果支持排队信号)。信号处理程序sa_sigaction中第二个参数siginfo_t中的si_value字段值被置为 sigev_value。
      • SIGEV_THREAD:当异步I/O请求完成时,调用sigev_notify_function函数。该函数的sigval参数值为sigev_value。系统会自动将该函数在分离状态下的一个单独的线程中执行(该线程的属性是sigev_notify_attributes)

4.2 aio_read和aio_write函数

在初始化了aiocb结构体后,就可以调用aio_read函数来进行异步读操作,或调用aio_write函数来进行异步写操作

aio_read() 函数对 aiocbp 描述的异步I/O 请求进行排队。这个函数是 read(2) 的异步模拟。

aio_write() 函数对由 aiocbp 描述的异步 I/O 请求进行排队。此函数是 write(2) 的异步模拟。

int aio_read(struct aiocb *aiocbp);
int aio_write(struct aiocb *aiocbp);
  • 当函数返回成功时,异步I/O请求便已经被操作系统放入等待处理的队列中了

  • 这些返回值与实际I/O操作的结果没有任何关系

  • I/O操作在等待时,必须保证aiocb对象中的缓冲区等资源始终是可用的。

4.3 aio_fsync函数

如果想强制(排队中)等待的异步操作不等待,而直接写入持久化的存储中,可以建立一个 AIO 控制块并调用 aio_fsync 函数

注意,同aio_read和aio_write一样,该函数只是一个请求(即一个同步请求),它并不等待I/O结束(只是将同步请求入队),而是立即返回

int aio_fsync(int op, struct aiocb *aiocbp);
  • aiocbp参数:

    其中的aio_fildes字段指定了要同步的异步写操作的文件

    除去由 aiocbp 指向的结构体成员 aio_fildes 之外唯一被本函数使用成员是 aio_sigevent ,这个成员指出在操作完成时希望收到哪种异步通知。所有其它字段都被忽略。

  • op参数

    • O_DSYNC所有当前在队的异步 I/O 操作好比调用了 fdatasync(2) 一样将会完成
  • O_SYNC所有当前在队的 异步I/O 操作好比调用了 fsync(2) 一样将会完成

同aio_read和aio_write一样,在安排了同步后(即同步请求成功排队),aio_fsync函数立即返回。如果返回0说明成功,失败返回-1并置位errno。

4.4 aio_error函数

获知一个异步读、写或者同步操作的完成状态,可以通过aio_error函数

int aio_error(const struct aiocb *aiocbp);

返回值:

  • 0:异步操作成功完成,需要调用aio_return函数获取操作返回值
  • -1:失败,并置位errno
  • EINPROGRESS:异步读、写或同步操作仍在等待
  • 其他情况:相关异步操作失败返回的错误码。如ECANCELED代表该异步I/O被取消了

4.5 aio_return函数

如果异步操作成功,可以调用aio_return函数来获取异步操作的返回值

ssize_t aio_return(struct aiocb *aiocbp);
  • 异步操作完成之前,都不要调用该函数
  • 异步操作完成之后,对每个异步操作只能调用一次该函数。因为一旦调用了该函数,操作系统就释放掉了包含I/O操作返回值的记录。

返回值

  • -1:调用失败,置位errno
  • 其他情况:返回异步操作结果,即会返回read、write或者fsync在被成功调用时可能返回的结果

4.6 aio_suspend函数

可以通过aio_suspend函数阻塞进程,直到异步I/O操作结束

int aio_suspend(const struct aiocb * const aiocb_list[],int nitems, const struct timespec *timeout);
  • 参数aiocb_list:阻塞的异步I/O操作数组,数组元素必须指向已用于初始化异步I/O操作的aiocb控制块
  • nitems参数:阻塞的异步I/O操作数组元素个数
  • timeout参数:等待的时间,NULL代表永远等待

返回值:

  • 被信号中断返回-1,errno置位EINTR
  • 超过timeout时间限制,返回-1,errno置位EAGAIN
  • 如果有任何异步I/O操作完成,该函数返回0。
  • 如果调用该函数时所有异步I/O操作已经完成,则不阻塞直接返回

4.7 aio_cancel函数

当我们不想再完成等待中的异步I/O操作时,可以尝试用aio_cancel函数取消它们

注意,系统无法保证能够取消该异步I/O操作,所以是尝试。

int aio_cancel(int fd, struct aiocb *aiocbp);
  • **参数fd:**未完成的异步I/O操作的文件描述符
  • **aiocbp参数:**如果为NULL,系统会尝试取消该文件的所有未完成异步I/O操作;否则,系统会尝试取消由aiocbp控制块描述的单个异步I/O操作。
  • 返回值:
    • AIO_ALLDONE:所有操作在尝试取消它们之前已经完成
    • AIO_CANCELED:所有要求的操作已被取消
    • AIO_NOTCANCELED:至少一个要求的操作没有被取消
    • -1:调用失败,置位errno

如果异步I/O成功被取消,对相应的AIO控制块调用aio_error会返回错误ECANCELED

4.8 lio_listio函数

一次提交一系列由一个aiocb控制块列表描述的I/O请求

注意该函数可以指定是异步还是同步

int lio_listio(int mode, struct aiocb *const aiocb_list[],int nitems, struct sigevent *sevp);
  • 参数mode:决定I/O是否真的是异步的

    • LIO_WAIT:同步I/O,即该函数在所有由列表指定的I/O操作完成后返回。此时sevp参数被忽略
    • LIO_NOWAIT:异步I/O。该函数在I/O请求入队后立即返回。进程将在所有I/O操作完成后,按照sevp参数指定的被异步的通知。
  • sevp参数:

    所有异步I/O完成后,按照该参数进行通知。需要与aiocb_list数组中每个aiocb的aio_sigevent字段区分开来,每个元素的aio_sigevent字段是在该异步I/O完成后进行通知的,而sevp参数针对的是所有异步I/O完成之后。

  • aiocb_list参数和nitems参数

    指定要运行的I/O操作。(aiocb_list是aiocb数组,nitems是数组元素个数)

    每个aiocb中的aio_lio_opcode只在该函数中使用,aio_lio_opcode字段值

    • LIO_READ:读操作,按照对应的aiocb控制块传给aio_read处理
    • LIO_WRITE:写操作,按照对应的aiocb控制块传给aio_write处理
    • LIO_NOP:将被忽略的空操作

**异步I/O流程图:**以从一个文件读取数据, 然后写到另外一个文件为例。

https://www.cnblogs.com/fortunely/p/14806255.html#33-aio_fsync

img

5. readv分散读 writev分散写

如果要从文件中读一片连续的数据至进程的不同区域,有两种方案:①使用read()一次将它们读至一个较大的缓冲区中,然后将它们分成若干部分复制到不同的区域; ②调用read()若干次分批将它们读至不同区域。
同样,如果想将程序中不同区域的数据块连续地写至文件,也必须进行类似的处理。

UNIX提供了另外两个函数—readv()和writev(),它们只需一次系统调用就可以实现在文件和进程的多个缓冲区之间传送数据,免除了多次系统调用或复制数据的开销。

5.1 readv分散读

可以将文件描述符的数据读入多个缓冲区中。readv总是先填满一个缓冲区,然后再填写下一个。

ssize_t readv(int fd, const struct iovec *iov, int iovcnt);
struct iovec {
	void  *iov_base;    /* 缓冲区地址 */
	size_t iov_len;     /* 缓冲区大小 */
};

参数:

  • fd:要读的文件描述符

  • iov:iovec结构体数组,每个结构体代表一个缓冲区

在这里插入图片描述

  • iovcnt:第二个参数数组元素个数

返回值:

  • readv返回读到的总字节数。如果遇到文件结尾,已无数据可读,则返回0。出错时返回-1并设置相应的errno。

使用示例:

int main()
{
    char buf1[8] = { 0 };
    char buf2[8] = { 0 };
    struct iovec iov[2];
    ssize_t nread;

    iov[0].iov_base = buf1;
    iov[0].iov_len = sizeof(buf1);
    iov[1].iov_base = buf2;
    iov[1].iov_len = sizeof(buf2);

    nread = readv(STDIN_FILENO, iov, 2);
    printf("%ld bytes read.\n", nread);
    printf("buf1: %s\n", buf1);
    printf("buf2: %s\n", buf2);

    exit(EXIT_SUCCESS);
}

5.2 writev分散写

可以将数据从多个缓冲区写入文件描述符。writev将多个数据存储在一起,将驻留在两个或更多的不连接的缓冲区中的数据一次写出去。writev以顺序iov[0]、iov[1]至iov[iovcnt-1]从各缓冲区中聚集输出数据到fd

ssize_t writev(int fd, const struct iovec *iov, int iovcnt);
struct iovec {
	void  *iov_base;    /* 缓冲区地址 */
	size_t iov_len;     /* 缓冲区大小 */
};

参数:

  • fd:要写入的文件描述符
  • iov:iovec结构体数组,每个结构体代表一个缓冲区
  • iovcnt:第二个参数数组元素个数

返回值:

  • 返回写入的总字节数,出错时返回-1并设置相应的errno。

示例

int main()
{
    char *str0 = "hello ";
    char *str1 = "world\n";
    struct iovec iov[2];
    ssize_t nwritten;

    iov[0].iov_base = str0;
    iov[0].iov_len = strlen(str0) + 1;
    iov[1].iov_base = str1;
    iov[1].iov_len = strlen(str1) + 1;

    nwritten = writev(STDOUT_FILENO, iov, 2);
    printf("%ld bytes written.\n", nwritten);

    exit(EXIT_SUCCESS);
}

6. 知识点:read和write返回值情况

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

  • 一次read操作所返回的数据可能少于所要求的的数据,即使还没到达文件尾端也可能是这样。这不是一个错误,而应该继续读该设备
  • 一次write操作返回值也可能少于指定输出的字节数。这可能是由某个因素造成的,例如,内核输出缓冲区变满。这不是错误,应该继续写剩下的数据

在读、写磁盘文件时不会出现以上的情况,除非文件系统用完了空间,或者接近了配额限制。

7. 存储映射I/O (内存映射I/O)

https://blog.csdn.net/qq_36359022/article/details/79992287

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

7.1 mmap函数

mmap函数告诉内核将一个给定的文件映射到一个存储区域中

mmap是一种内存映射文件的方法,即将一个文件或者其它对象映射到进程的地址空间,实现文件磁盘地址和进程虚拟地址空间中一段虚拟地址的一一对映关系。实现这样的映射关系后,**进程就可以采用指针的方式读写操作这一段内存,而系统会自动回写脏页面到对应的文件磁盘上,即完成了对文件的操作而不必再调用read,write等系统调用函数。相反,内核空间对这段区域的修改也直接反映用户空间,从而可以实现不同进程间的文件共享。**mmap()系统调用使得进程之间通过映射同一个普通文件实现共享内存。

**存储映射文件示意图:**可见映射存储区位于堆和栈之间(各种实现可能不同)

在这里插入图片描述

void *mmap(void *addr, size_t length, int prot, int flags,int fd, off_t offset);
//成功返回映射区起始地址;失败返回MAP_FAILED
  • 参数addr

    指定映射存储区起始地址(即进程中的一块内存区域)。如果为NULL,则由系统选择该映射区起始地址,且函数返回值是该地址。

  • 参数length

    映射区的长度(字节)。可以通过stat系统调用获得打开文件的大小信息,然后设置为这个参数

  • 参数prot:

    期望的内存保护标志,不能与文件的打开模式冲突。是以下的某个值,可以是PROT_NONE ,也可以是其他三个值得按位或。

    • PROT_EXEC 映射区可以被执行

    • PROT_READ 映射区可以被读取

    • PROT_WRITE 映射区可以被写入

    • PROT_NONE 映射区不可访问

  • 参数flag

    指定映射对象的类型,映射选项和映射页是否可以共享。

    • MAP_FIXED:返回值必须等于addr参数。该标志不建议使用。因为如果addr非0,则内核只把该参数当做一种建议,但是不保证会使用所要求的地址。因此建议将addr设置为NULL

    • MAP_PRIVATE:多进程间数据共享,修改不反应到磁盘实际文件,是一个copy-on-write(写时复制)的映射方式。即内存区域的写入不会影响到原文件

    • MAP_SHARED:多进程间数据共享,修改反应到磁盘实际文件中,相当于输出到文件

    • 等等

  • 参数fd

    要被映射的文件描述符。必须先打开该文件。

  • 参数offset

    被映射内容的起点(即fd文件内容偏移量)

使用mmap函数需要注意以下几点

  • 注意,addr和offset的值通常被要求是系统虚拟存储页长度的倍数(可以通过sysconf(_SC_PAGESIZE)得到)。但是addr和offset常被设置为0,因此该要求不重要。
  • 注意,映射区长度通常是页长的整数倍。假定文件长为12字节,系统页长为512字节,则系统通常提供512字节的映射区,只不过后500字节都被置0。可以修改后面的这500字节,但是任何变动不会在文件中反映出来。因此不能用mmap将数据添加到文件从而改变文件长度,而是必须先加长该文件再修改文件内容。
  • 与映射区相关信号有SIGSEGV和SIGBUS。
  • fork后子进程继承存储映射区
  • exec之后的新程序不继承存储映射区

7.2 mprotect函数

修改一个现有映射的权限

int mprotect(void *addr, size_t len, int prot);
  • prot参数值与mmap的prot参数一样

    • PROT_EXEC 映射区可以被执行

    • PROT_READ 映射区可以被读取

    • PROT_WRITE 映射区可以被写入

    • PROT_NONE 映射区不可访问

  • 地址参数addr必须是系统页长整数倍

如果要修改的页是通过MAP_SHARED标志映射到地址空间的,那么修改并不会立即写回到文件中。何时写回脏页面由内核的守护进程决定

如果只修改了一页中的一个字节,当修改被写回到文件中时,整个页都被写回

7.3 msync函数

进程在映射空间的对共享内容的改变并不直接写回到磁盘文件中,如果共享映射中的页已修改(MAP_SHARED),那么可以调用msync将该页冲洗到被映射的文件中。msync类似于fsync,但作用于存储映射区

int msync(void *addr, size_t length, int flags);

如果映射是私有的(MAP_PRIVATE),那么不修改被映射的文件

flags参数

  • MS_ASYNC:异步。调用会立即返回,不等到更新的完成
  • MS_SYNC:同步。在该函数返回之前等待写操作完成。如果要确保数据安全的写到了文件中,则需要在进程终止前以MS_SYNC标志调用msync函数

7.4 munmap函数

进程终止时,会自动解除存储映射区的映射,或者直接调用munmap函数解除映射区

注意,close映射存储区时使用的文件描述符并不解除映射区

int munmap(void *addr, size_t length);

munmap函数并不会使映射区的内容写到磁盘文件上

对于MAP_SHARED映射的磁盘文件的更新,会在我们将数据写到存储映射区后的某个时刻,按照内存虚拟存储算法自动进行

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

7.5 对于mmap映射存储区是页的整数倍大小的思考

转自https://blog.csdn.net/qq_36359022/article/details/79992287

文件大小等于映射区大小的情况

当我们用普通文件作映射区时,如果文件大小是5000,并且我们也用5000的映射区时(不是页面的整数倍), 虽然映射区大小为5000,但仍能够在一定程度上越界访问。 这其实是因为内核的内存保护是以页面为单位的,5000大小分得的物理页面支撑实际上是2个页面(8192大小)
在0-4999可以使用ptr进行正常的读写访问,而5000-8191这一段里,内核是允许我们读写的,但是不会写入文件。注意,是允许读写,但写不进去文件。就是说内核允许写操作,但内核又不执行这个写操作。
当超过了物理页面支撑后的任何操作都是不合规矩的,引发SIGSEGV信号。

在这里插入图片描述

文件大小远小于映射区大小的情况
这次文件大小仍然是5000,而映射区大小我们改为15000。物理页面支撑2个页面大小(8192大小)。
在访问0-4999是没有问题的,5000-8191这段允许读写但不执行写入操作。当超过物理页面支撑以后的空间分为两种情况

  • 超过物理页面但是没有超过映射区大小 –> 引发SIGBUS信号

  • 超过物理页面且超过映射区大小 —> 引发SIGSEGV信号

由此我们可以看出,mmap映射时物理页面上面并不是单纯的以我们填入的数据分配,内核仍然会对文件本身的大小进行检查

在这里插入图片描述


版权声明:本文为Nick_Di原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接和本声明。