1)实验平台:正点原子阿尔法Linux开发板
2)平台购买地址:https://item.taobao.com/item.htm?id=603672744434
2)全套实验源码+手册+视频下载地址:http://www.openedv.com/thread-300792-1-1.html
3)对正点原子Linux感兴趣的同学可以加群讨论:935446741
4)关注正点原子公众号,获取最新资料更新
第十三章 高级I/O
本章再次回到文件I/O相关话题的讨论,将会介绍文件I/O当中的一些高级用法,以应对不同应用场合的需求,主要包括:非阻塞I/O、I/O多路复用、异步I/O、存储映射I/O以及文件锁,我们统统把它们放到本章《高级I/O》中进行讨论、学习。
本章将会讨论如下主题内容。
阻塞I/O与非阻塞I/O;
阻塞I/O所带来的困境;
非阻塞I/O以轮训方式访问多个设备;
何为I/O多路复用以及原理;
何为异步I/O以及原理;
存储映射I/O;
文件加锁。
14.1非阻塞I/O
关于“阻塞”一词前面已经给大家多次提到,阻塞其实就是进入了休眠状态,交出了CPU控制权。前面所学习过的函数,譬如wait()、pause()、sleep()等函数都会进入阻塞,本小节来聊一聊关于阻塞式I/O与非阻塞式I/O。
阻塞式I/O顾名思义就是对文件的I/O操作(读写操作)是阻塞式的,非阻塞式I/O同理就是对文件的I/O操作是非阻塞的。这样说大家可能不太明白,这里举个例子,譬如对于某些文件类型(读管道文件、网络设备文件和字符设备文件),当对文件进行读操作时,如果数据未准备好、文件当前无数据可读,那么读操作可能会使调用者阻塞,直到有数据可读时才会被唤醒,这就是阻塞式I/O常见的一种表现;如果是非阻塞式I/O,即使没有数据可读,也不会被阻塞、而是会立马返回错误!
普通文件的读写操作是不会阻塞的,不管读写多少个字节数据,read()或write()一定会在有限的时间内返回,所以普通文件一定是以非阻塞的方式进行I/O操作,这是普通文件本质上决定的;但是对于某些文件类型,譬如上面所介绍的管道文件、设备文件等,它们既可以使用阻塞式I/O操作,也可以使用非阻塞式I/O进行操作。
14.1.1阻塞I/O与非阻塞I/O读文件
本小节我们将分别演示使用阻塞式I/O和非阻塞式I/O对文件进行读操作,在调用open()函数打开文件时,为参数flags指定O_NONBLOCK标志,open()调用成功后,后续的I/O操作将以非阻塞式方式进行;这就是非阻塞I/O的打开方式,如果未指定O_NONBLOCK标志,则默认使用阻塞式I/O进行操作。
对于普通文件来说,指定与未指定O_NONBLOCK标志对其是没有影响,普通文件的读写操作是不会阻塞的,它总是以非阻塞的方式进行I/O操作,这是普通文件本质上决定的,前面已经给大家进行了说明。
本小节我们将以读取鼠标为例,使用两种I/O方式进行读取,来进行对比,鼠标是一种输入设备,其对应的设备文件在/dev/input/目录下,如下所示:
图 14.1.1 输入设备对应的设备文件
通常情况下是mouseX(X表示序号0、1、2),但也不一定,也有可能是eventX,如何确定到底是哪个设备文件,可以通过对设备文件进行读取来判断,譬如使用od命令:
sudo od -x /dev/input/event3
Tips:需要添加sudo,在Ubuntu系统下,普通用户是无法对设备文件进行读取或写入操作。
当执行命令之后,移动鼠标或按下鼠标、松开鼠标都会在终端打印出相应的数据,如下所示:
图 14.1.2 读取鼠标打印信息
如果没有打印信息,那么这个设备文件就不是鼠标对应的设备文件,那么就换一个设备文件再次测试,这样就会帮助你找到鼠标设备文件。笔者使用的Ubuntu系统,对应的鼠标设备文件是/dev/input/event3。接下来我们编写一个测试程序,使用阻塞式I/O读取鼠标。
示例代码 13.1.1演示了以阻塞方式读取鼠标,调用open()函数打开鼠标设备文件"/dev/input/event3",以只读方式打开,没有指定O_NONBLOCK标志,说明使用的是阻塞式I/O;程序中只调用了一次read()读取鼠标。
示例代码 14.1.1 阻塞式I/O读取鼠标数据
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <string.h>
int main(void)
{
char buf[100];
int fd, ret;
/* 打开文件 */
fd = open("/dev/input/event3", O_RDONLY);
if (-1 == fd) {
perror("open error");
exit(-1);
}
/* 读文件 */
memset(buf, 0, sizeof(buf));
ret = read(fd, buf, sizeof(buf));
if (0 > ret) {
perror("read error");
close(fd);
exit(-1);
}
printf("成功读取<%d>个字节数据\n", ret);
/* 关闭文件 */
close(fd);
exit(0);
}
编译上述示例代码进行测试:
图 14.1.3 阻塞
执行程序之后,发现程序没有立即结束,而是一直占用了终端,没有输出信息,原因在于调用read()之后进入了阻塞状态,因为当前鼠标没有数据可读;如果此时我们移动鼠标、或者按下鼠标上的任何一个按键,阻塞会结束,read()会成功读取到数据并返回,如下所示:
图 14.1.4 移动鼠标
打印信息提示,此次read成功读取了48个字节,程序当中我们明明要求读取的是100个字节,为什么这里只读取到了48个字节?关于这个问题将会在第二篇内容当中进行介绍,这里暂时先不去理会这个问题。
接下来,我们将示例代码 13.1.1修改成非阻塞式I/O,如下所示:
示例代码 14.1.2 非阻塞式I/O读取鼠标数据
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <string.h>
int main(void)
{
char buf[100];
int fd, ret;
/* 打开文件 */
fd = open("/dev/input/event3", O_RDONLY | O_NONBLOCK);
if (-1 == fd) {
perror("open error");
exit(-1);
}
/* 读文件 */
memset(buf, 0, sizeof(buf));
ret = read(fd, buf, sizeof(buf));
if (0 > ret) {
perror("read error");
close(fd);
exit(-1);
}
printf("成功读取<%d>个字节数据\n", ret);
/* 关闭文件 */
close(fd);
exit(0);
}
修改方法很简单,只需在调用open()函数时指定O_NONBLOCK标志即可,对上述示例代码进行编译测试:
图 14.1.5 非阻塞
执行程序之后,程序立马就结束了,并且调用read()返回错误,提示信息为"Resource temporarily unavailable",意思就是说资源暂时不可用;原因在于调用read()时,如果鼠标并没有移动或者被按下(没有发生输入事件),是没有数据可读,故而导致失败返回,这就是非阻塞I/O。
可以对示例代码 13.1.2进行修改,使用轮训方式不断地去读取,直到鼠标有数据可读,read()将会成功返回:
示例代码 14.1.3 轮训+非阻塞方式读取鼠标
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <string.h>
int main(void)
{
char buf[100];
int fd, ret;
/* 打开文件 */
fd = open("/dev/input/event3", O_RDONLY | O_NONBLOCK);
if (-1 == fd) {
perror("open error");
exit(-1);
}
/* 读文件 */
memset(buf, 0, sizeof(buf));
for ( ; ; ) {
ret = read(fd, buf, sizeof(buf));
if (0 < ret) {
printf("成功读取<%d>个字节数据\n", ret);
close(fd);
exit(0);
}
}
}
具体的执行的效果便不再演示了,各位读者自己动手试试。
14.1.2阻塞I/O的优点与缺点
当对文件进行读取操作时,如果文件当前无数据可读,那么阻塞式I/O会将调用者应用程序挂起、进入休眠阻塞状态,直到有数据可读时才会解除阻塞;而对于非阻塞I/O,应用程序不会被挂起,而是会立即返回,它要么一直轮训等待,直到数据可读,要么直接放弃!
所以阻塞式I/O的优点在于能够提升CPU的处理效率,当自身条件不满足时,进入阻塞状态,交出CPU资源,将CPU资源让给别人使用;而非阻塞式则是抓紧利用CPU资源,譬如不断地去轮训,这样就会导致该程序占用了非常高的CPU使用率!
执行示例代码 13.1.3对应的程序时,通过top命令可以发现该程序的占用了非常高的CPU使用率,如下所示:
图 14.1.6 CPU占用率
其CPU占用率几乎达到了100%,在一个系统当中,一个进程的CPU占用率这么高是一件非常危险的事情。而示例代码 13.1.1这种阻塞式方式,其CPU占用率几乎为0,所以就本章的所举例子来说,阻塞式I/O绝地要优于非阻塞式I/O,那既然如此,我们为何还要介绍非阻塞式I/O呢?下一小节我们将通过一个例子给大家介绍,阻塞式I/O的困境!
14.1.3使用非阻塞I/O实现并发读取
上一小节给大家所举的例子当中,只读取了鼠标的数据,如果要在程序当中同时读取鼠标和键盘,那又该如何呢?本小节我们将分别演示使用阻塞式I/O和非阻塞式I/O同时读取鼠标和键盘;同理键盘也是一种输入类设备,但是键盘是标准输入设备stdin,进程会自动从父进程中继承标准输入、标准输出以及标准错误,标准输入设备对应的文件描述符为0,所以在程序当中直接使用即可,不需要再调用open打开。
首先我们使用阻塞式方式同时读取鼠标和键盘,示例代码如下所示:
示例代码 14.1.4 阻塞式同时读取鼠标和键盘
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <string.h>
#define MOUSE "/dev/input/event3"
int main(void)
{
char buf[100];
int fd, ret;
/* 打开鼠标设备文件 */
fd = open(MOUSE, O_RDONLY);
if (-1 == fd) {
perror("open error");
exit(-1);
}
/* 读鼠标 */
memset(buf, 0, sizeof(buf));
ret = read(fd, buf, sizeof(buf));
printf("鼠标: 成功读取<%d>个字节数据\n", ret);
/* 读键盘 */
memset(buf, 0, sizeof(buf));
ret = read(0, buf, sizeof(buf));
printf("键盘: 成功读取<%d>个字节数据\n", ret);
/* 关闭文件 */
close(fd);
exit(0);
}
上述程序中先读了鼠标,在接着读键盘,所以由此可知,在实际测试当中,需要先动鼠标在按键盘(按下键盘上的按键、按完之后按下回车),这样才能既成功读取鼠标、又成功读取键盘,程序才能够顺利运行结束。因为read此时是阻塞式读取,先读取了鼠标,没有数据可读将会一直被阻塞,后面的读取键盘将得不到执行。
这就是阻塞式I/O的一个困境,无法实现并发读取(同时读取),主要原因在于阻塞,那如何解决这个问题呢?当然大家可能会想到使用多线程,一个线程读取鼠标、另一个线程读取键盘,亦或者创建一个子进程,父进程读取鼠标、子进程读取键盘等方法,当然这些方法自然可以解决,但不是我们要学习的重点。
既然阻塞I/O存在这样一个困境,那我们可以使用非阻塞式I/O解决它,将示例代码 13.1.4修改为非阻塞式方式同时读取鼠标和键盘。使用open()打开得到的文件描述符,调用open()时指定O_NONBLOCK标志将其设置为非阻塞式I/O;因为标准输入文件描述符(键盘)是从其父进程进程而来,并不是在我们的程序中调用open()打开得到的,那如何将标准输入设置为非阻塞I/O,可以使用3.10.1小节中给大家介绍的fcntl()函数,具体使用方法在该小节中已有详细介绍,这里不再重述!可通过如下代码将标准输入(键盘)设置为非阻塞方式:
int flag;
flag = fcntl(0, F_GETFL); //先获取原来的flag
flag |= O_NONBLOCK; //将O_NONBLOCK标志添加到flag
fcntl(0, F_SETFL, flag); //重新设置flag
示例代码 13.1.5演示了以非阻塞方式同时读取鼠标和键盘。
示例代码 14.1.5 非阻塞式方式同时读取鼠标和键盘
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#define MOUSE "/dev/input/event3"
int main(void)
{
char buf[100];
int fd, ret, flag;
/* 打开鼠标设备文件 */
fd = open(MOUSE, O_RDONLY | O_NONBLOCK);
if (-1 == fd) {
perror("open error");
exit(-1);
}
/* 将键盘设置为非阻塞方式 */
flag = fcntl(0, F_GETFL); //先获取原来的flag
flag |= O_NONBLOCK; //将O_NONBLOCK标准添加到flag
fcntl(0, F_SETFL, flag); //重新设置flag
for ( ; ; ) {
/* 读鼠标 */
ret = read(fd, buf, sizeof(buf));
if (0 < ret)
printf("鼠标: 成功读取<%d>个字节数据\n", ret);
/* 读键盘 */
ret = read(0, buf, sizeof(buf));
if (0 < ret)
printf("键盘: 成功读取<%d>个字节数据\n", ret);
}
/* 关闭文件 */
close(fd);
exit(0);
}
将读取鼠标和读取键盘操作放入到一个循环中,通过轮训方式来实现并发读取鼠标和键盘,对上述代码进行编译,测试结果:
图 14.1.7 测试结果
这样就解决了示例代码 13.1.4所出现的问题,不管是先动鼠标还是先按键盘都可以成功读取到相应数据。
虽然使用非阻塞I/O方式解决了示例代码 13.1.4出现的问题,但由于程序当中使用轮训方式,故而会使得该程序的CPU占用率特别高,终归还是不太安全,会对整个系统产生很大的副作用,如何解决这样的问题呢?我们将在下一小节向大家介绍。
14.2I/O多路复用
上一小节虽然使用非阻塞式I/O解决了阻塞式I/O情况下并发读取文件所出现的问题,但依然不够完美,使得程序的CPU占用率特别高。解决这个问题,就要用到本小节将要介绍的I/O多路复用方法。
14.2.1何为I/O多路复用
I/O多路复用(IO multiplexing)它通过一种机制,可以监视多个文件描述符,一旦某个文件描述符(也就是某个文件)可以执行I/O操作时,能够通知应用程序进行相应的读写操作。I/O多路复用技术是为了解决:在并发式I/O场景中进程或线程阻塞到某个I/O系统调用而出现的技术,使进程不阻塞于某个特定的I/O系统调用。
由此可知,I/O多路复用一般用于并发式的非阻塞I/O,也就是多路非阻塞I/O,譬如程序中既要读取鼠标、又要读取键盘,多路读取。
我们可以采用两个功能几乎相同的系统调用来执行I/O多路复用操作,分别是系统调用select()和poll()。这两个函数基本是一样的,细节特征上存在些许差别!
I/O多路复用存在一个非常明显的特征:外部阻塞式,内部监视多路I/O。
14.2.2select()函数介绍
系统调用select()可用于执行I/O多路复用操作,调用select()会一直阻塞,直到某一个或多个文件描述符成为就绪态(可以读或写)。其函数原型如下所示:
#include <sys/select.h>
int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
使用该函数需要包含头文件<sys/select.h>。
可以看出select()函数的参数比较多,其中参数readfds、writefds以及exceptfds都是fd_set类型指针,指向一个fd_set类型对象,fd_set数据类型是一个文件描述符的集合体,所以参数readfds、writefds以及exceptfds都是指向文件描述符集合的指针,这些参数按照如下方式使用:
readfds是用来检测读是否就绪(是否可读)的文件描述符集合;
writefds是用来检测写是否就绪(是否可写)的文件描述符集合;
exceptfds是用来检测异常情况是否发生的文件描述符集合。
Tips:异常情况并不是在文件描述符上出现了一些错误。
fd_set数据类型是以位掩码的形式来实现的,但是,我们并不需要关心这些细节、无需关心该结构体成员信息,因为Linux提供了四个宏用于对fd_set类型对象进行操作,所有关于文件描述符集合的操作都是通过这四个宏来完成的:FD_CLR()、FD_ISSET()、FD_SET()、FD_ZERO(),稍后介绍!
如果对readfds、writefds以及exceptfds中的某些事件不感兴趣,可将其设置为NULL,这表示对相应条件不关心。如果这三个参数都设置为NULL,则可以将select()当做为一个类似于sleep()休眠的函数来使用,通过select()函数的最后一个参数timeout来设置休眠时间。
select()函数的第一个参数nfds通常表示最大文件描述符编号值加1,考虑readfds、writefds以及exceptfds这三个文件描述符集合,在3个描述符集中找出最大描述符编号值,然后加1,这就是参数nfds。
select()函数的最后一个参数timeout可用于设定select()阻塞的时间上限,控制select的阻塞行为,可将timeout参数设置为NULL,表示select()将会一直阻塞、直到某一个或多个文件描述符成为就绪态;也可将其指向一个struct timeval结构体对象,该结构体在示例代码 5.6.3有详细介绍,这里不再重述!
如果参数timeout指向的struct timeval结构体对象中的两个成员变量都为0,那么此时select()函数不会阻塞,它只是简单地轮训指定的文件描述符集合,看看其中是否有就绪的文件描述符并立刻返回。否则,参数timeout将为select()指定一个等待(阻塞)时间的上限值,如果在阻塞期间内,文件描述符集合中的某一个或多个文件描述符成为就绪态,将会结束阻塞并返回;如果超过了阻塞时间的上限值,select()函数将会返回!
select()函数将阻塞知道有以下事情发生:
readfds、writefds或exceptfds指定的文件描述符中至少有一个称为就绪态;
该调用被信号处理函数中断;
参数timeout中指定的时间上限已经超时。
FD_CLR()、FD_ISSET()、FD_SET()、FD_ZERO()
文件描述符集合的所有操作都可以通过这四个宏来完成,这些宏定义如下所示:
#include <sys/select.h>
void FD_CLR(int fd, fd_set *set);
int FD_ISSET(int fd, fd_set *set);
void FD_SET(int fd, fd_set *set);
void FD_ZERO(fd_set *set);
这些宏按照如下方式工作:
FD_ZERO()将参数set所指向的集合初始化为空;
FD_SET()将文件描述符fd添加到参数set所指向的集合中;
FD_CLR()将文件描述符fd从参数set所指向的集合中移除;
如果文件描述符fd是参数set所指向的集合中的成员,则FD_ISSET()返回true,否则返回false。
文件描述符集合有一个最大容量限制,有常量FD_SETSIZE来决定,在Linux系统下,该常量的值为1024。在定义一个文件描述符集合之后,必须用FD_ZERO()宏将其进行初始化操作,然后再向集合中添加我们关心的各个文件描述符,例如:
fd_set fset; //定义文件描述符集合
FD_ZERO(&fset); //将集合初始化为空
FD_SET(3, &fset); //向集合中添加文件描述符3
FD_SET(4, &fset); //向集合中添加文件描述符4
FD_SET(5, &fset); //向集合中添加文件描述符5
在调用select()函数之后,select()函数内部会修改readfds、writefds、exceptfds这些集合,当select()函数返回时,它们包含的就是已处于就绪态的文件描述符集合了。譬如在调用select()函数之前,readfds所指向的集合中包含了3、4、5这三个文件描述符,当调用select()函数之后,假设select()返回时,只有文件描述符4已经处于就绪态了,那么此时readfds指向的集合中就只包含了文件描述符4。所以由此可知,如果要在循环中重复调用select(),我们必须保证每次都要重新初始化并设置readfds、writefds、exceptfds这些集合。
select()函数的返回值
select()函数有三种可能的返回值,会返回如下三种情况中的一种:
返回-1表示有错误发生,并且会设置errno。可能的错误码包括EBADF、EINTR、EINVAL、EINVAL以及ENOMEM,EBADF表示readfds、writefds或exceptfds中有一个文件描述符是非法的;EINTR表示该函数被信号处理函数中断了,其它错误大家可以自己去看,在man手册都有相信的记录。
返回0表示在任何文件描述符成为就绪态之前select()调用已经超时,在这种情况下,readfds,writefds以及exceptfds所指向的文件描述符集合都会被清空。
返回一个正整数表示有一个或多个文件描述符已达到就绪态。返回值表示处于就绪态的文件描述符的个数,在这种情况下,每个返回的文件描述符集合都需要检查,通过FD_ISSET()宏进行检查,以此找出发生的I/O事件是什么。如果同一个文件描述符在readfds,writefds以及exceptfds中同时被指定,且它多于多个I/O事件都处于就绪态的话,那么就会被统计多次,换句话说,select()返回三个集合中被标记为就绪态的文件描述符的总数。
使用示例
示例代码 13.2.1演示了使用select()函数来实现I/O多路复用操作,同时读取键盘和鼠标。程序中将鼠标和键盘配置为非阻塞I/O方式,本程序对数据进行了5次读取,通过while循环来实现。由于在while循环中会重复调用select()函数,所以每次调用之前需要对rdfds进行初始化以及添加鼠标和键盘对应的文件描述符。
该程序中,select()函数的参数timeout被设置为NULL,并且我们只关心鼠标或键盘是否有数据可读,所以将参数writefds和exceptfds也设置为NULL。执行select()函数时,如果鼠标和键盘均无数据可读,则select()调用会陷入阻塞,直到发生输入事件(鼠标移动、键盘上的按键按下或松开)才会返回。
示例代码 14.2.1 使用select实现同时读取键盘和鼠标
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <sys/select.h>
#define MOUSE "/dev/input/event3"
int main(void)
{
char buf[100];
int fd, ret = 0, flag;
fd_set rdfds;
int loops = 5;
/* 打开鼠标设备文件 */
fd = open(MOUSE, O_RDONLY | O_NONBLOCK);
if (-1 == fd) {
perror("open error");
exit(-1);
}
/* 将键盘设置为非阻塞方式 */
flag = fcntl(0, F_GETFL); //先获取原来的flag
flag |= O_NONBLOCK; //将O_NONBLOCK标准添加到flag
fcntl(0, F_SETFL, flag); //重新设置flag
/* 同时读取键盘和鼠标 */
while (loops--) {
FD_ZERO(&rdfds);
FD_SET(0, &rdfds); //添加键盘
FD_SET(fd, &rdfds); //添加鼠标
ret = select(fd + 1, &rdfds, NULL, NULL, NULL);
if (0 > ret) {
perror("select error");
goto out;
}
else if (0 == ret) {
fprintf(stderr, "select timeout.\n");
continue;
}
/* 检查键盘是否为就绪态 */
if(FD_ISSET(0, &rdfds)) {
ret = read(0, buf, sizeof(buf));
if (0 < ret)
printf("键盘: 成功读取<%d>个字节数据\n", ret);
}
/* 检查鼠标是否为就绪态 */
if(FD_ISSET(fd, &rdfds)) {
ret = read(fd, buf, sizeof(buf));
if (0 < ret)
printf("鼠标: 成功读取<%d>个字节数据\n", ret);
}
}
out:
/* 关闭文件 */
close(fd);
exit(ret);
}
程序中分析select()函数的返回值ret,只有当ret大于0时才表示有文件描述符处于就绪态,并将这些处于就绪态的文件描述符通过rdfds集合返回出来,程序中使用FD_ISSET()宏检查返回的rdfds集合中是否包含鼠标文件描述符以及键盘文件描述符,如果包含则表示可以读取数据了。
编译运行:
图 14.2.1 测试结果
示例代码 13.2.1将鼠标和键盘都设置为了非阻塞I/O方式,其实设置为阻塞I/O方式也是可以的,因为select()返回时意味着此时数据是可读取的,所以以非阻塞和阻塞两种方式读取数据均不会发生阻塞。
14.2.3poll()函数介绍
系统调用poll()与select()函数很相似,但函数接口有所不同。在select()函数中,我们提供三个fd_set集合,在每个集合中添加我们关心的文件描述符;而在poll()函数中,则需要构造一个struct pollfd类型的数组,每个数组元素指定一个文件描述符以及我们对该文件描述符所关心的条件(数据可读、可写或异常情况)。poll()函数原型如下所示:
#include <poll.h>
int poll(struct pollfd fds, nfds_t nfds, int timeout);
使用该函数需要包含头文件<poll.h>。
函数参数含义如下:
fds:指向一个struct pollfd类型的数组,数组中的每个元素都会指定一个文件描述符以及我们对该文件描述符所关心的条件,稍后介绍struct pollfd结构体类型。
nfds:参数nfds指定了fds数组中的元素个数,数据类型nfds_t实际为无符号整形。
timeout:该参数与select()函数的timeout参数相似,用于决定poll()函数的阻塞行为,具体用法如下:
如果timeout等于-1,则poll()会一直阻塞(与select()函数的timeout等于NULL相同),直到fds数组中列出的文件描述符有一个达到就绪态或者捕获到一个信号时返回。
如果timeout等于0,poll()不会阻塞,只是执行一次检查看看哪个文件描述符处于就绪态。
如果timeout大于0,则表示设置poll()函数阻塞时间的上限值,意味着poll()函数最多阻塞timeout毫秒,直到fds数组中列出的文件描述符有一个达到就绪态或者捕获到一个信号为止。
struct pollfd结构体
struct pollfd结构体如下所示:
示例代码 14.2.2 struct pollfd结构体
struct pollfd {
int fd; / file descriptor /
short events; / requested events /
short revents; / returned events */
};
fd是一个文件描述符,struct pollfd结构体中的events和revents都是位掩码,调用者初始化events来指定需要为文件描述符fd做检查的事件。当poll()函数返回时,revents变量由poll()函数内部进行设置,用于说明文件描述符fd发生了哪些事件(注意,poll()没有更改events变量),我们可以对revents进行检查,判断文件描述符fd发生了什么事件。
应将每个数组元素的events成员设置为表 13.2.1中所示的一个或几个标志,多个标志通过位或运算符( | )组合起来,通过这些值告诉内核我们关心的是该文件描述符的哪些事件。同样,返回时,revents变量由内核设置为表 13.2.1中所示的一个或几个标志。
表 13.2.1 poll的events和revents标志
标志名 输入至events 从revents得到结果 说明
POLLIN
POLLRDNORM
POLLRDBAND
POLLPRI
POLLRDHUP ●
● 有数据可以读取
相等于POLLIN
可以读取优先级数据(Linux上通常不使用)
可读取高优先级数据
对端套接字关闭
POLLOUT
POLLWRNORM
POLLWRBAND ●
● 可写入数据
相等于POLLOUT
优先级数据可写入
POLLERR
POLLHUP
POLLNVAL ●
●
● 有错误发生
出现挂断
文件描述符未打开
POLLMSG Linux中不使用
表 13.2.1中第一组标志(POLLIN、POLLRDNORM、POLLRDBAND、POLLPRI、POLLRDHUP)与数据可读相关;第二组标志(POLLOUT、POLLWRNORM、POLLWRBAND)与可写数据相关;而第三组标志(POLLERR、POLLHUP、POLLNVAL)是设定在revents变量中用来返回有关文件描述符的附加信息,如果在events变量中指定了这三个标志,则会被忽略。
如果我们对某个文件描述符上的事件不感兴趣,则可将events变量设置为0;另外,将fd变量设置为文件描述符的负值(取文件描述符fd的相反数-fd),将导致对应的events变量被poll()忽略,并且revents变量将总是返回0,这两种方法都可用来关闭对某个文件描述符的检查。
在实际应用编程中,一般用的最多的还是POLLIN和POLLOUT。
poll()函数返回值
poll()函数返回值含义与select()函数的返回值是一样的,有如下几种情况:
返回-1表示有错误发生,并且会设置errno。
返回0表示该调用在任意一个文件描述符成为就绪态之前就超时了。
返回一个正整数表示有一个或多个文件描述符处于就绪态了,返回值表示fds数组中返回的revents变量不为0的struct pollfd对象的数量。
使用示例
示例代码 13.2.3演示了使用poll()函数来实现I/O多路复用操作,同时读取键盘和鼠标。其实就是将示例代码 13.2.1进行了修改,使用poll替换select。
示例代码 14.2.3 使用poll实现同时读取鼠标和键盘
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <poll.h>
#define MOUSE "/dev/input/event3"
int main(void)
{
char buf[100];
int fd, ret = 0, flag;
int loops = 5;
struct pollfd fds[2];
/* 打开鼠标设备文件 */
fd = open(MOUSE, O_RDONLY | O_NONBLOCK);
if (-1 == fd) {
perror("open error");
exit(-1);
}
/* 将键盘设置为非阻塞方式 */
flag = fcntl(0, F_GETFL); //先获取原来的flag
flag |= O_NONBLOCK; //将O_NONBLOCK标准添加到flag
fcntl(0, F_SETFL, flag); //重新设置flag
/* 同时读取键盘和鼠标 */
fds[0].fd = 0;
fds[0].events = POLLIN; //只关心数据可读
fds[0].revents = 0;
fds[1].fd = fd;
fds[1].events = POLLIN; //只关心数据可读
fds[1].revents = 0;
while (loops--) {
ret = poll(fds, 2, -1);
if (0 > ret) {
perror("poll error");
goto out;
}
else if (0 == ret) {
fprintf(stderr, "poll timeout.\n");
continue;
}
/* 检查键盘是否为就绪态 */
if(fds[0].revents & POLLIN) {
ret = read(0, buf, sizeof(buf));
if (0 < ret)
printf("键盘: 成功读取<%d>个字节数据\n", ret);
}
/* 检查鼠标是否为就绪态 */
if(fds[1].revents & POLLIN) {
ret = read(fd, buf, sizeof(buf));
if (0 < ret)
printf("鼠标: 成功读取<%d>个字节数据\n", ret);
}
}
out:
/* 关闭文件 */
close(fd);
exit(ret);
}
struct pollfd结构体的events变量和revents变量都是位掩码,所以可以使用"revents & POLLIN"按位与的方式来检查是否发生了相应的POLLIN事件,判断鼠标或键盘数据是否可读。测试结果:
图 14.2.2 测试结果
14.3异步IO
在I/O多路复用中,进程通过系统调用select()或poll()来主动查询文件描述符上是否可以执行I/O操作。而在异步I/O中,当文件描述符上可以执行I/O操作时,进程可以请求内核为自己发送一个信号。之后进程就可以执行任何其它的任务直到文件描述符可以执行I/O操作为止,此时内核会发送信号给进程。所以要使用异步I/O,还得结合前面所学习的信号相关的内容,所以异步I/O通常也称为信号驱动I/O。
要使用异步I/O,程序需要按照如下步骤来执行:
通过指定O_NONBLOCK标志使能非阻塞I/O。
通过指定O_ASYNC标志使能异步I/O。
设置异步I/O事件的接收进程。也就是当文件描述符上可执行I/O操作时会发送信号通知该进程,通常将调用进程设置为异步I/O事件的接收进程。
为内核发送的通知信号注册一个信号处理函数。默认情况下,异步I/O的通知信号是SIGIO,所以内核会给进程发送信号SIGIO。在8.2小节中简单地提到过该信号。
以上步骤完成之后,进程就可以执行其它任务了,当I/O操作就绪时,内核会向进程发送一个SIGIO信号,当进程接收到信号时,会执行预先注册好的信号处理函数,我们就可以在信号处理函数中进行I/O操作。
O_ASYNC标志
O_ASYNC标志可用于使能文件描述符的异步I/O事件,当文件描述符可执行I/O操作时,内核会向异步I/O事件的接收进程发送SIGIO信号(默认情况下)。在2.3小节介绍open()函数时,给大家提到过该标志,但并未介绍该标志的作用,该标志主要用于异步I/O。
需要注意的是:在调用open()时无法通过指定O_ASYNC标志来使能异步I/O,但可以使用fcntl()函数添加O_ASYNC标志使能异步I/O,譬如:
int flag;
flag = fcntl(0, F_GETFL); //先获取原来的flag
flag |= O_ASYNC; //将O_ASYNC标志添加到flag
fcntl(fd, F_SETFL, flag); //重新设置flag
设置异步I/O事件的接收进程
为文件描述符设置异步I/O事件的接收进程,也就是设置异步I/O的所有者。同样也是通过fcntl()函数进行设置,操作命令cmd设置为F_SETOWN,第三个参数传入接收进程的进程ID(PID),通常将调用进程的PID传入,譬如:
fcntl(fd, F_SETOWN, getpid());
注册SIGIO信号的处理函数
通过signal()或sigaction()函数为SIGIO信号注册一个信号处理函数,当进程接收到内核发送过来的SIGIO信号时,会执行该处理函数,所以我们应该在处理函数当中执行相应的I/O操作。
使用示例
示例代码 13.3.1演示了以异步I/O方式读取鼠标,当进程接收到SIGIO信号时,执行信号处理函数sigio_handler(),在该函数中调用read()读取鼠标数据。
示例代码 14.3.1 以异步I/O方式读取鼠标
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <signal.h>
#define MOUSE "/dev/input/event3"
static int fd;
static void sigio_handler(int sig)
{
static int loops = 5;
char buf[100] = {0};
int ret;
if(SIGIO != sig)
return;
ret = read(fd, buf, sizeof(buf));
if (0 < ret)
printf("鼠标: 成功读取<%d>个字节数据\n", ret);
loops--;
if (0 >= loops) {
close(fd);
exit(0);
}
}
int main(void)
{
int flag;
/* 打开鼠标设备文件<使能非阻塞I/O> */
fd = open(MOUSE, O_RDONLY | O_NONBLOCK);
if (-1 == fd) {
perror("open error");
exit(-1);
}
/* 使能异步I/O */
flag = fcntl(fd, F_GETFL);
flag |= O_ASYNC;
fcntl(fd, F_SETFL, flag);
/* 设置异步I/O的所有者 */
fcntl(fd, F_SETOWN, getpid());
/* 为SIGIO信号注册信号处理函数 */
signal(SIGIO, sigio_handler);
for ( ; ; )
sleep(1);
}
代码比较简单,这里我们进行编译测试:
图 14.3.1 测试结果
14.4优化异步I/O
上一小节介绍了异步I/O的原理以及使用方法,在一个需要同时检查大量文件描述符(譬如数千个)的应用程序中,例如某种类型的网络服务端程序,与select()和poll()相比,异步I/O能够提供显著的性能优势。之所以如此,原因在于:对于异步I/O,内核可以“记住”要检查的文件描述符,且仅当这些文件描述符上可执行I/O操作时,内核才会向应用程序发送信号。
而对于select()或poll()函数来说,内部实现原理其实是通过轮训的方式来检查多个文件描述符是否可执行I/O操作,所以,当需要检查的文件描述符数量较多时,随之也将会消耗大量的CPU资源来实现轮训检查操作。当需要检查的文件描述符并不是很多时,使用select()或poll()是一种非常不错的方案!
Tips:当需要检查大量文件描述符时,可以使用epoll解决select()或poll()性能低的问题,本书并不会介绍epoll相关内容,如果读者有兴趣可以自行查阅书籍进行学习。在性能表现上,epoll与异步I/O方式相似,但是epoll有一些胜过异步I/O的优点。
不管是异步I/O、还是epoll,在需要检查大量文件描述符的应用程序当中,在这种情况下,它们的性能相比于select()或poll()有着显著的优势!
本小节将对上一小节所讲述的异步I/O进行优化,既然要对其进行优化,那必然存在着一些缺陷,如下所示:
默认的异步I/O通知信号SIGIO是非排队信号。SIGIO信号是标准信号(非实时信号、不可靠信号),所以它不支持信号排队机制,譬如当前正在执行SIGIO信号的处理函数,此时内核又发送多次SIGIO信号给进程,这些信号将会被阻塞,只有当信号处理函数执行完毕之后才会传递给进程,并且只能传递一次,而其它后续的信号都会丢失。
无法得知文件描述符发生了什么事件。在示例代码 13.3.1的信号处理函数sigio_handler()中,直接调用了read()函数读取鼠标,而并未判断文件描述符是否处于可读就绪态,事实上,示例代码 13.3.1这种异步I/O方式并未告知应用程序文件描述符上发生了什么事件,是可读取还是可写入亦或者发生异常等。
所以本小节我们将会针对以上列举出的两个缺陷进行优化。
14.4.1使用实时信号替换默认信号SIGIO
SIGIO作为异步I/O通知的默认信号,是一个非实时信号,我们可以设置不使用默认信号,指定一个实时信号作为异步I/O通知信号,如何指定呢?同样也是使用fcntl()函数进行设置,调用函数时将操作命令cmd参数设置为F_SETSIG,第三个参数arg指定一个实时信号编号即可,表示将该信号作为异步I/O通知信号,譬如:
fcntl(fd, F_SETSIG, SIGRTMIN);
上述代码指定了SIGRTMIN实时信号作为文件描述符fd的异步I/O通知信号,而不再使用默认的SIGIO信号。当文件描述符fd可执行I/O操作时,内核会发送实时信号SIGRTMIN给调用进程。
如果第三个参数arg设置为0,则表示指定SIGIO信号作为异步I/O通知信号,也就是回到了默认状态。
14.4.2使用sigaction()函数注册信号处理函数
在应用程序当中需要为实时信号注册信号处理函数,使用sigaction函数进行注册,并为sa_flags参数指定SA_SIGINFO,表示使用sa_sigaction指向的函数作为信号处理函数,而不使用sa_handler指向的函数。因为sa_sigaction指向的函数作为信号处理函数提供了更多的参数,可以获取到更多信息,函数定义参考示例代码 8.4.2中关于struct sigaction结构体的描述。
函数参数中包括一个siginfo_t指针,指向siginfo_t类型对象,当触发信号时该对象由内核构建。siginfo_t结构体中提供了很多信息,我们可以在信号处理函数中使用这些信息,具体定义请参考示例代码 8.4.3,就对于异步I/O事件而言,传递给信号处理函数的siginfo_t结构体中与之相关的字段如下:
si_signo:引发处理函数被调用的信号。这个值与信号处理函数的第一个参数一致。
si_fd:表示发生异步I/O事件的文件描述符;
si_code:表示文件描述符si_fd发生了什么事件,读就绪态、写就绪态或者是异常事件等。该字段中可能出现的值以及它们对应的描述信息参见表 13.4.1。
si_band:是一个位掩码,其中包含的值与系统调用poll()中返回的revents字段中的值相同。如表 13.4.1所示,si_code中可能出现的值与si_band中的位掩码有着一一对应关系。
表 13.4.1 siginfo_t结构体中的si_code和si_band的可能值
si_code si_band掩码值 描述/说明
POLL_IN POLLIN | POLLRDNORM 可读取数据
POLL_OUT POLLOUT | POLLWRNORM | POLLWRBAND 可写入数据
POLL_MSG POLLIN | POLLRDNORM | POLLMSG 不使用
POLL_ERR POLLERR I/O错误
POLL_PRI POLLPRI | POLLRDNORM 可读取高优先级数据
POLL_HUP POLLHUP | POLLERR 出现宕机
所以,由此可知,可以在信号处理函数中通过对比siginfo_t结构体的si_code变量来检查文件描述符发生了什么事件,以采取相应的I/O操作。
14.4.3使用示例
通过13.4.1小节和13.4.2小节的学习,我们已经知道了如何针对13.4小节开头提出的异步I/O存在的两个缺陷进行优化。示例代码 13.4.1是对示例代码 13.3.1进行了优化,使用实时信号+sigaction解决:默认异步I/O通知信号SIGIO可能存在丢失以及信号处理函数中无法判断文件描述符所发生的I/O事件这两个问题。
调用sigaction()注册信号处理函数时,sa_flags指定了SA_SIGINFO,所以将使用sa_sigaction指向的函数io_handler作为信号处理函数,io_handler共有3个参数,参数sig等于引发信号处理函数被调用的信号值,参数info附加了很多信息,前面已有介绍,这里不再重述。
示例代码 14.4.1 读取鼠标–优化异步I/O
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <signal.h>
#define MOUSE "/dev/input/event3"
static int fd;
static void io_handler(int sig,
siginfo_t *info,
void *context)
{
static int loops = 5;
char buf[100] = {0};
int ret;
if(SIGRTMIN != sig)
return;
/* 判断鼠标是否可读 */
if (POLL_IN == info->si_code) {
ret = read(fd, buf, sizeof(buf));
if (0 < ret)
printf("鼠标: 成功读取<%d>个字节数据\n", ret);
loops--;
if (0 >= loops) {
close(fd);
exit(0);
}
}
}
int main(void)
{
struct sigaction act;
int flag;
/* 打开鼠标设备文件<使能非阻塞I/O> */
fd = open(MOUSE, O_RDONLY | O_NONBLOCK);
if (-1 == fd) {
perror("open error");
exit(-1);
}
/* 使能异步I/O */
flag = fcntl(fd, F_GETFL);
flag |= O_ASYNC;
fcntl(fd, F_SETFL, flag);
/* 设置异步I/O的所有者 */
fcntl(fd, F_SETOWN, getpid());
/* 指定实时信号SIGRTMIN作为异步I/O通知信号 */
fcntl(fd, F_SETSIG, SIGRTMIN);
/* 为实时信号SIGRTMIN注册信号处理函数 */
act.sa_sigaction = io_handler;
act.sa_flags = SA_SIGINFO;
sigemptyset(&act.sa_mask);
sigaction(SIGRTMIN, &act, NULL);
for ( ; ; )
sleep(1);
}
对上述示例代码进行编译时,出现了一些报错信息,如下所示:
图 14.4.1 编译报错
报错提示没有定义F_SETSIG,确实如此,我们需要定义了_GNU_SOURCE宏之后才能使用F_SETSIG,这个宏在4.9.3小节向大家介绍过,这里不再重述!
这里笔者选择直接在源文件中使用#define定义_GNU_SOURCE宏,如下所示:
#define _GNU_SOURCE //在源文件开头定义_GNU_SOURCE宏
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <signal.h>
再次进行编译测试:
图 14.4.2 测试结果
14.5存储映射I/O
存储映射I/O(memory-mapped I/O)是一种基于内存区域的高级I/O操作,它能将一个文件映射到进程地址空间中的一块内存区域中,当从这段内存中读数据时,就相当于读文件中的数据(对文件进行read操作),将数据写入这段内存时,则相当于将数据直接写入文件中(对文件进行write操作)。这样就可以在不使用基本I/O操作函数read()和write()的情况下执行I/O操作。
14.5.1mmap()和munmap()函数
为了实现存储映射I/O这一功能,我们需要告诉内核将一个给定的文件映射到进程地址空间中的一块内存区域中,这由系统调用mmap()来实现。其函数原型如下所示:
#include <sys/mman.h>
void *mmap(void *addr, size_t length, int prot, int flags, int fd, off_t offset);
使用该函数需要包含头文件<sys/mman.h>。
函数参数和返回值含义如下:
addr:参数addr用于指定映射到内存区域的起始地址。通常将其设置为NULL,这表示由系统选择该映射区的起始地址,这是最常见的设置方式;如果参数addr不为NULL,则表示由自己指定映射区的起始地址,此函数的返回值是该映射区的起始地址。
length:参数length指定映射长度,表示将文件中的多大部分映射到内存区域中,以字节为单位,譬如length=1024 * 4,表示将文件的4K字节大小映射到内存区域中。
offset:文件映射的偏移量,通常将其设置为0,表示从文件头部开始映射;所以参数offset和参数length就确定了文件的起始位置和长度,将文件的这部分映射到内存区域中,如图 13.5.1所示。
fd:文件描述符,指定要映射到内存区域中的文件。
prot:参数prot指定了映射区的保护要求,可取值如下:
PROT_EXEC:映射区可执行;
PROT_READ:映射区可读;
PROT_WRITE:映射区可写;
PROT_NONE:映射区不可访问。
可将prot指定为为PROT_NONE,也可将其设置为PROT_EXEC、PROT_READ、PROT_WRITE中一个或多个(通过按位或运算符任意组合)。对指定映射区的保护要求不能超过文件open()时的访问权限,譬如,文件是以只读权限方式打开的,那么对映射区的不能指定为PROT_WRITE。
flags:参数flags可影响映射区的多种属性,参数flags必须要指定以下两种标志之一:
MAP_SHARED:此标志指定当对映射区写入数据时,数据会写入到文件中,也就是会将写入到映射区中的数据更新到文件中,并且允许其它进程共享。
MAP_PRIVATE:此标志指定当对映射区写入数据时,会创建映射文件的一个私人副本(copy-on-write),对映射区的任何操作都不会更新到文件中,仅仅只是对文件副本进行读写。
除此之外,还可将以下标志中的0个或多个组合到参数flags中,通过按位或运算符进行组合:
MAP_FIXED:在未指定该标志的情况下,如果参数addr不等于NULL,表示由调用者自己指定映射区的起始地址,但这只是一种建议、而并非强制,所以内核并不会保证使用参数addr指定的值作为映射区的起始地址;如果指定了MAP_FIXED标志,则表示要求必须使用参数addr指定的值作为起始地址,如果使用指定值无法成功建立映射时,则放弃!通常,不建议使用此标志,因为这不利于移植。
MAP_ANONYMOUS:建立匿名映射,此时会忽略参数fd和offset,不涉及文件,而且映射区域无法和其它进程共享。
MAP_ANON:与MAP_ANONYMOUS标志同义,不建议使用。
MAP_DENYWRITE:该标志被忽略。
MAP_EXECUTABLE:该标志被忽略。
MAP_FILE:兼容性标志,已被忽略。
MAP_LOCKED:对映射区域进行上锁。
除了以上标志之外,还有其它一些标志,这里便不再介绍,可通过man手册进行查看。在众多标志当中,通常情况下,参数flags中只指定了MAP_SHARED。
返回值:成功情况下,函数的返回值便是映射区的起始地址;发生错误时,返回(void *)-1,通常使用MAP_FAILED来表示,并且会设置errno来指示错误原因。
图 14.5.1 存储映射I/O示意图
对于mmap()函数,参数addr和offset在不为NULL和0的情况下,addr和offset的值通常被要求是系统页大小的整数倍,可通过sysconf()函数获取页大小,如下所示(以字节为单位):
sysconf(_SC_PAGE_SIZE)
或
sysconf(_SC_PAGESIZE)
虽然对addr和offset有这种限制,但对于参数length长度来说,却没有这种要求,如果映射区的长度不是页长度的整数倍时,会怎么样呢?对于这个问题的答案,我们首先需要了解到,对于mmap()函数来说,当文件成功被映射到内存区域时,这段内存区域(映射区)的大小通常是页大小的整数倍,即使参数length并不是页大小的整数倍。如果文件大小为96个字节,我们调用mmap()时参数length也是设置为96,假设系统页大小为4096字节(4K),则系统通常会提供4096个字节的映射区,其中后4000个字节会被设置为0,可以修改后面的这4000个字节,但是并不会影响到文件。但如果访问4000个字节后面的内存区域,将会导致异常情况发生,产生SIGBUS信号。
对于参数length任需要注意,参数length的值不能大于文件大小,即文件被映射的部分不能超出文件。
与映射区相关的两个信号
SIGSEGV:如果映射区被mmap()指定成了只读的,那么进程试图将数据写入到该映射区时,将会产生SIGSEGV信号,此信号由内核发送给进程。在第八章中给大家介绍过该信号,该信号的系统默认操作是终止进程、并生成核心可用于调试的核心转储文件。
SIGBUS:如果映射区的某个部分在访问时已不存在,则会产生SIGBUS信号。例如,调用mmap()进行映射时,将参数length设置为文件长度,但在访问映射区之前,另一个进程已将该文件截断(譬如调用ftruncate()函数进行截断),此时如果进程试图访问对应于该文件已截去部分的映射区,进程将会受到内核发送过来的SIGBUS信号,同样,该信号的系统默认操作是终止进程、并生成核心可用于调试的核心转储文件。
munmap()解除映射
通过open()打开文件,需要使用close()将将其关闭;同理,通过mmap()将文件映射到进程地址空间中的一块内存区域中,当不再需要时,必须解除映射,使用munmap()解除映射关系,其函数原型如下所示:
#include <sys/mman.h>
int munmap(void *addr, size_t length);
同样,使用该函数需要包含头文件<sys/mman.h>。
munmap()系统调用解除指定地址范围内的映射,参数addr指定待解除映射地址范围的起始地址,它必须是系统页大小的整数倍;参数length是一个非负整数,指定了待解除映射区域的大小(字节数),被解除映射的区域对应的大小也必须是系统页大小的整数倍,即使参数length并不等于系统页大小的整数倍,与mmap()函数相似。
需要注意的是,当进程终止时也会自动解除映射(如果程序中没有显式调用munmap()),但调用close()关闭文件时并不会解除映射。
通常将参数addr设置为mmap()函数的返回值,将参数length设置为mmap()函数的参数length,表示解除整个由mmap()函数所创建的映射。
使用示例
通过以上介绍,接下来我们编写一个简单地示例代码,使用存储映射I/O进行文件复制。
示例代码 13.5.1演示了使用存储映射I/O实现文件复制操作,将源文件中的内容全部复制到另一个目标文件中,其效果类似于cp命令。
示例代码 14.5.1 使用存储映射I/O复制文件
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <sys/mman.h>
#include <string.h>
int main(int argc, char *argv[])
{
int srcfd, dstfd;
void *srcaddr;
void *dstaddr;
int ret;
struct stat sbuf;
if (3 != argc) {
fprintf(stderr, "usage: %s <srcfile> <dstfile>\n", argv[0]);
exit(-1);
}
/* 打开源文件 */
srcfd = open(argv[1], O_RDONLY);
if (-1 == srcfd) {
perror("open error");
exit(-1);
}
/* 打开目标文件 */
dstfd = open(argv[2], O_RDWR |
O_CREAT | O_TRUNC, 0664);
if (-1 == dstfd) {
perror("open error");
ret = -1;
goto out1;
}
/* 获取源文件的大小 */
fstat(srcfd, &sbuf);
/* 设置目标文件的大小 */
ftruncate(dstfd, sbuf.st_size);
/* 将源文件映射到内存区域中 */
srcaddr = mmap(NULL, sbuf.st_size,
PROT_READ, MAP_SHARED, srcfd, 0);
if (MAP_FAILED == srcaddr) {
perror("mmap error");
ret = -1;
goto out2;
}
/* 将目标文件映射到内存区域中 */
dstaddr = mmap(NULL, sbuf.st_size,
PROT_WRITE, MAP_SHARED, dstfd, 0);
if (MAP_FAILED == dstaddr) {
perror("mmap error");
ret = -1;
goto out3;
}
/* 将源文件中的内容复制到目标文件中 */
memcpy(dstaddr, srcaddr, sbuf.st_size);
/* 程序退出前清理工作 */
out4:
/* 解除目标文件映射 */
munmap(dstaddr, sbuf.st_size);
out3:
/* 解除源文件映射 */
munmap(srcaddr, sbuf.st_size);
out2:
/* 关闭目标文件 */
close(dstfd);
out1:
/* 关闭源文件并退出 */
close(srcfd);
exit(ret);
}
当执行程序的时候,将源文件和目标文件传递给应用程序,该程序首先会将源文件和目标文件打开,源文件以只读方式打开,而目标文件以可读、可写方式打开,如果目标文件不存在则创建它,并且将文件的大小截断为0。
然后使用fstat()函数获取源文件的大小,接着调用ftruncate()函数设置目标文件的大小与源文件大小保持一致。
然后对源文件和目标文件分别调用mmap(),将文件映射到内存当中;对于源文件,调用mmap()时将参数prot指定为PROT_READ,表示对它的映射区会进行读取操作;对于目标文件,调用mmap()时将参数port指定为PROT_WRITE,表示对它的映射区会进行写入操作。最后调用memcpy()将源文件映射区中的内容复制到目标文件映射区中,完成文件的复制操作。
接下来我们进行测试,笔者使用当前目录下的srcfile作为源文件,dstfile作为目标文件,先看看源文件srcfile的内容,如下所示:
图 14.5.2 源文件中的内容
目标文件dstfile并不存在,我们需要在程序中进行创建,编译程序、运行:
图 14.5.3 测试结果
由打印信息可知,程序运行完之后,生成了目标文件dstfile,使用cat命令查看到其内容与源文件srcfile相同,本测试程序成功实现了文件复制功能!
14.5.2mprotect()函数
使用系统调用mprotect()可以更改一个现有映射区的保护要求,其函数原型如下所示:
#include <sys/mman.h>
int mprotect(void *addr, size_t len, int prot);
使用该函数,同样需要包含头文件<sys/mman.h>。
参数prot的取值与mmap()函数的prot参数的一样,mprotect()函数会将指定地址范围的保护要求更改为参数prot所指定的类型,参数addr指定该地址范围的起始地址,addr的值必须是系统页大小的整数倍;参数len指定该地址范围的大小。
mprotect()函数调用成功返回0;失败将返回-1,并且会设置errno来只是错误原因。
14.5.3msync()函数
在第四章中提到过,read()和write()系统调用在操作磁盘文件时不会直接发起磁盘访问(读写磁盘硬件),而是仅仅在用户空间缓冲区和内核缓冲区之间复制数据,在后续的某个时刻,内核会将其缓冲区中的数据写入(刷新至)磁盘中,所以由此可知,调用write()写入到磁盘文件中的数据并不会立马写入磁盘,而是会先缓存在内核缓冲区中,所以就会出现write()操作与磁盘操作并不同步,也就是数据不同步。
对于存储I/O来说亦是如此,写入到文件映射区中的数据也不会立马刷新至磁盘设备中,而是会在我们将数据写入到映射区之后的某个时刻将映射区中的数据写入磁盘中。所以会导致映射区中的内容与磁盘文件中的内容不同步。我们可以调用msync()函数将映射区中的数据刷写、更新至磁盘文件中(同步操作),系统调用msync()类似于fsync()函数,不过msync()作用于映射区。该函数原型如下所示:
#include <sys/mman.h>
int msync(void *addr, size_t length, int flags);
使用该函数,同样需要包含头文件<sys/mman.h>。
参数addr和length指定了需同步的内存区域的起始地址和大小。对于参数addr来说,同样也要求必须是系统页大小的整数倍,也就是与系统页大小对齐。譬如,调用msync()时,将addr设置为mmap()函数的返回值,将length设置为mmap()函数的length参数,将对文件的整个映射区进行同步操作。
参数flags应指定为MS_ASYNC和MS_SYNC两个标志之一,除此之外,还可以根据需求选择是否指定MS_INVALIDATE标志,作为一个可选标志。
MS_ASYNC:以异步方式进行同步操作。调用msync()函数之后,并不会等待数据完全写入磁盘之后才返回。
MS_SYNC:以同步方式进行同步操作。调用msync()函数之后,需等待数据全部写入磁盘之后才返回。
MS_INVALIDATE:是一个可选标志,请求使同一文件的其它映射无效(以便可以用刚写入的新值更新它们)。
msync()函数在调用成功情况下返回0;失败将返回-1、并设置errno。
munmap()函数并不影响被映射的文件,也就是说,当调用munmap()解除映射时并不会将映射区中的内容写到磁盘文件中。如果mmap()指定了MAP_SHARED标志,对于文件的更新,会在我们将数据写入到映射区之后的某个时刻将映射区中的数据更新到磁盘文件中,由内核根据虚拟存储算法自动进行。
如果mmap()指定了MAP_PRIVATE标志,在解除映射之后,进程对映射区的修改将会丢弃!
14.5.4普通I/O与存储映射I/O比较
通过前面的介绍,相信大家对存储映射I/O之间有了一个新的认识,本小节我们再来对普通I/O方式和存储映射I/O做一个简单的总结。
普通I/O方式的缺点
普通I/O方式一般是通过调用read()和write()函数来实现对文件的读写,使用read()和write()读写文件时,函数经过层层的调用后,才能够最终操作到文件,中间涉及到很多的函数调用过程,数据需要在不同的缓存间倒腾,效率会比较低。同样使用标准I/O(库函数fread()、fwrite())也是如此,本身标准I/O就是对普通I/O的一种封装。
那既然效率较低,为啥还要使用这种方式呢?原因在于,只有当数据量比较大时,效率的影响才会比较明显,如果数据量比较小,影响并不大,使用普通的I/O方式还是非常方便的。
存储映射I/O的优点
存储映射I/O的实质其实是共享,与IPC之内存共享很相似。譬如执行一个文件复制操作来说,对于普通I/O方式,首先需要将源文件中的数据读取出来存放在一个应用层缓冲区中,接着再将缓冲区中的数据写入到目标文件中,如下所示:
图 14.5.4 普通I/O实现文件复制示例图
而对于存储映射I/O来说,由于源文件和目标文件都已映射到了应用层的内存区域中,所以直接操作映射区来实现文件复制,如下所示:
图 14.5.5 存储映射I/O实现文件复制
首先非常直观的一点就是,使用存储映射I/O减少了数据的复制操作,所以在效率上会比普通I/O要高,其次上面也讲了,普通I/O中间涉及到了很多的函数调用过程,这些都会导致普通I/O在效率上会比存储映射I/O要低。
前面提到存储映射I/O的实质其实是共享,如何理解共享呢?其实非常简单,我们知道,应用层与内核层是不能直接进行交互的,必须要通过操作系统提供的系统调用或库函数来与内核进行数据交互,包括操作硬件。通过存储映射I/O将文件直接映射到应用程序地址空间中的一块内存区域中,也就是映射区;直接将磁盘文件直接与映射区关联起来,不用调用read()、write()系统调用,直接对映射区进行读写操作即可操作磁盘上的文件,而磁盘文件中的数据也可反应到映射区中,这就是一种共享,可以认为映射区就是应用层与内核层之间的共享内存。
存储映射I/O的不足
存储映射I/O方式并不是完美的,它所映射的文件只能是固定大小,因为文件所映射的区域已经在调用mmap()函数时通过length参数指定了。另外,文件映射的内存区域的大小必须是系统页大小的整数倍,譬如映射文件的大小为96字节,假定系统页大小为4096字节,那么剩余的4000字节全部填充为0,虽然可以通过映射地址访问剩余的这些字节数据,但不能在映射文件中反应出来,由此可知,使用存储映射I/O在进行大数据量操作时比较有效;对于少量数据,使用普通I/O方式更加方便!
存储映射I/O的应用场景
由上面介绍可知,存储映射I/O在处理大量数据时效率高,对于少量数据处理不是很划算,所以通常来说,存储映射I/O会在视频图像处理方面用的比较多,譬如在第二篇内容,我们将会介绍Framebuffer编程,通俗点说就是LCD编程,就会使用到存储映射I/O。
14.6文件锁
现象一下,当两个人同时编辑磁盘中同一份文件时,其后果将会如何呢?在Linux系统中,该文件的最后状态通常取决于写该文件的最后一个进程。多个进程同时操作同一文件,很容易导致文件中的数据发生混乱,因为多个进程对文件进行I/O操作时,容易产生竞争状态、导致文件中的内容与预想的不一致!
对于有些应用程序,进程有时需要确保只有它自己能够对某一文件进行I/O操作,在这段时间内不允许其它进程对该文件进行I/O操作。为了向进程提供这种功能,Linux系统提供了文件锁机制。
前面学习过互斥锁、自旋锁以及读写锁,文件锁与这些锁一样,都是内核提供的锁机制,锁机制实现用于对共享资源的访问进行保护;只不过互斥锁、自旋锁、读写锁与文件锁的应用场景不一样,互斥锁、自旋锁、读写锁主要用在多线程环境下,对共享资源的访问进行保护,做到线程同步。
而文件锁,顾名思义是一种应用于文件的锁机制,当多个进程同时操作同一文件时,我们怎么保证文件数据的正确性,linux通常采用的方法是对文件上锁,来避免多个进程同时操作同一文件时产生竞争状态。譬如进程对文件进行I/O操作时,首先对文件进行上锁,将其锁住,然后再进行读写操作;只要进程没有对文件进行解锁,那么其它的进程将无法对其进行操作;这样就可以保证,文件被锁住期间,只有它(该进程)可以对其进行读写操作。
一个文件既然可以被多个进程同时操作,那说明文件必然是一种共享资源,所以由此可知,归根结底,文件锁也是一种用于对共享资源的访问进行保护的机制,通过对文件上锁,来避免访问共享资源产生竞争状态。
文件锁的分类
文件锁可以分为建议性锁和强制性锁两种:
建议性锁
建议性锁本质上是一种协议,程序访问文件之前,先对文件上锁,上锁成功之后再访问文件,这是建议性锁的一种用法;但是如果你的程序不管三七二十一,在没有对文件上锁的情况下直接访问文件,也是可以访问的,并非无法访问文件;如果是这样,那么建议性锁就没有起到任何作用,如果要使得建议性锁起作用,那么大家就要遵守协议,访问文件之前先对文件上锁。这就好比交通信号灯,规定红灯不能通行,绿灯才可以通行,但如果你非要在红灯的时候通行,谁也拦不住你,那么后果将会导致发生交通事故;所以必须要大家共同遵守交通规则,交通信号灯才能起到作用。
强制性锁:
强制性锁比较好理解,它是一种强制性的要求,如果进程对文件上了强制性锁,其它的进程在没有获取到文件锁的情况下是无法对文件进行访问的。其本质原因在于,强制性锁会让内核检查每一个I/O操作(譬如read()、write()),验证调用进程是否是该文件锁的拥有者,如果不是将无法访问文件。当一个文件被上锁进行写入操作的时候,内核将阻止其它进程对其进行读写操作。采取强制性锁对性能的影响很大,每次进行读写操作都必须检查文件锁。
在Linux系统中,可以调用flock()、fcntl()以及lockf()这三个函数对文件上锁,接下来将向大家介绍每个函数的使用方法。
14.6.1flock()函数加锁
先来学习系统调用flock(),使用该函数可以对文件加锁或者解锁,但是flock()函数只能产生建议性锁,其函数原型如下所示:
#include <sys/file.h>
int flock(int fd, int operation);
使用该函数需要包含头文件<sys/file.h>。
函数参数和返回值含义如下:
fd:参数fd为文件描述符,指定需要加锁的文件。
operation:参数operation指定了操作方式,可以设置为以下值的其中一个:
LOCK_SH:在fd引用的文件上放置一把共享锁。所谓共享,指的便是多个进程可以拥有对同一个文件的共享锁,该共享锁可被多个进程同时拥有。
LOCK_EX:在fd引用的文件上放置一把排它锁(或叫互斥锁)。所谓互斥,指的便是互斥锁只能同时被一个进程所拥有。
LOCK_UN:解除文件锁定状态,解锁、释放锁。
除了以上三个标志外,还有一个标志:
LOCK_NB:表示以非阻塞方式获取锁。默认情况下,调用flock()无法获取到文件锁时会阻塞、直到其它进程释放锁为止,如果不想让程序被阻塞,可以指定LOCK_NB标志,如果无法获取到锁应立刻返回(错误返回,并将errno设置为EWOULDBLOCK),通常与LOCK_SH或LOCK_EX一起使用,通过位或运算符组合在一起。
返回值:成功将返回0;失败返回-1、并会设置errno,
对于flock(),需要注意的是,同一个文件不会同时具有共享锁和互斥锁。
使用示例
示例代码 13.6.1演示了使用flock()函数对一个文件加锁和解锁(建议性锁)。程序首先调用open()函数将文件打开,文件路径通过传参的方式传递进来;文件打开成功之后,调用flock()函数对文件加锁(非阻塞方式、排它锁),并打印出“文件加锁成功”信息,如果加锁失败便会打印出“文件加锁失败”信息。然后调用signal函数为SIGINT信号注册了一个信号处理函数,当进程接收到SIGINT信号后会执行sigint_handler()函数,在信号处理函数中对文件进行解锁,然后终止进程。
示例代码 14.6.1 使用flock()对文件加锁/解锁
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <sys/file.h>
#include <signal.h>
static int fd = -1; //文件描述符
/* 信号处理函数 */
static void sigint_handler(int sig)
{
if (SIGINT != sig)
return;
/* 解锁 */
flock(fd, LOCK_UN);
close(fd);
printf("进程1: 文件已解锁!\n");
}
int main(int argc, char *argv[])
{
if (2 != argc) {
fprintf(stderr, "usage: %s <file>\n", argv[0]);
exit(-1);
}
/* 打开文件 */
fd = open(argv[1], O_WRONLY);
if (-1 == fd) {
perror("open error");
exit(-1);
}
/* 以非阻塞方式对文件加锁(排它锁) */
if (-1 == flock(fd, LOCK_EX | LOCK_NB)) {
perror("进程1: 文件加锁失败");
exit(-1);
}
printf("进程1: 文件加锁成功!\n");
/* 为SIGINT信号注册处理函数 */
signal(SIGINT, sigint_handler);
for ( ; ; )
sleep(1);
}
加锁成功之后,程序进入了for死循环,一直持有锁;此时我们可以执行另一个程序,如示例代码 13.6.2所示,该程序首先也会打开文件,文件路径通过传参的方式传递进来,同样在程序中也会调用flock()函数对文件加锁(排它锁、非阻塞方式),不管加锁成功与否都会执行下面的I/O操作,将数据写入文件、在读取出来并打印。
示例代码 14.6.2 未获取锁情况下读写文件
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <sys/file.h>
#include <string.h>
int main(int argc, char *argv[])
{
char buf[100] = "Hello World!";
int fd;
int len;
if (2 != argc) {
fprintf(stderr, "usage: %s <file>\n", argv[0]);
exit(-1);
}
/* 打开文件 */
fd = open(argv[1], O_RDWR);
if (-1 == fd) {
perror("open error");
exit(-1);
}
/* 以非阻塞方式对文件加锁(排它锁) */
if (-1 == flock(fd, LOCK_EX | LOCK_NB))
perror("进程2: 文件加锁失败");
else
printf("进程2: 文件加锁成功!\n");
/* 写文件 */
len = strlen(buf);
if (0 > write(fd, buf, len)) {
perror("write error");
exit(-1);
}
printf("进程2: 写入到文件的字符串<%s>\n", buf);
/* 将文件读写位置移动到文件头 */
if (0 > lseek(fd, 0x0, SEEK_SET)) {
perror("lseek error");
exit(-1);
}
/* 读文件 */
memset(buf, 0x0, sizeof(buf)); //清理buf
if (0 > read(fd, buf, len)) {
perror("read error");
exit(-1);
}
printf("进程2: 从文件读取的字符串<%s>\n", buf);
/* 解锁、退出 */
flock(fd, LOCK_UN);
close(fd);
exit(0);
}
把示例代码 13.6.1作为应用程序1,把示例代码 13.6.2作为应用程序2,将它们分别编译成不同的可执行文件testApp1和testApp2,如下所示:
图 14.6.1 两份可执行文件
在进行测试之前,创建一个测试用的文件infile,直接使用touch命令创建即可,首先执行testApp1应用程序,将infile文件作为输入文件,并将其放置在后台运行:
图 14.6.2 执行testApp1
testApp1会在后台运行,由ps命令可查看到其pid为20710。接着执行testApp2应用程序,传入相同的文件infile,如下所示:
图 14.6.3 执行testApp2
从打印信息可知,testApp2进程对infile文件加锁失败,原因在于锁已经被testApp1进程所持有,所以testApp2加锁自然会失败;但是可以发现虽然加锁失败,但是testApp2对文件的读写操作是没有问题的,是成功的,这就是建议性锁的特点;正确的使用方式是,在加锁失败之后不要再对文件进行I/O操作了,遵循这个协议。
接着我们向testApp1进程发送一个SIGIO信号,让其对文件infile解锁,接着再执行一次testApp2,如下所示:
图 14.6.4 测试结果
使用kill命令向testApp1进程发送编号为2的信号,也就是SIGIO信号,testApp1接收到信号之后,对infile文件进行解锁、然后退出;接着再次执行testApp2程序,从打印信息可知,这次能够成功对infile文件加锁了,读写也是没有问题的。
关于flock()的几条规则
同一进程对文件多次加锁不会导致死锁。当进程调用flock()对文件加锁成功,再次调用flock()对文件(同一文件描述符)加锁,这样不会导致死锁,新加的锁会替换旧的锁。譬如调用flock()对文件加共享锁,再次调用flock()对文件加排它锁,最终文件锁会由共享锁替换为排它锁。
文件关闭的时候,会自动解锁。进程调用flock()对文件加锁,如果在未解锁之前将文件关闭,则会导致文件锁自动解锁,也就是说,文件锁会在相应的文件描述符被关闭之后自动释放。同理,当一个进程终止时,它所建立的锁将全部释放。
一个进程不可以对另一个进程持有的文件锁进行解锁。
由fork()创建的子进程不会继承父进程所创建的锁。这意味着,若一个进程对文件加锁成功,然后该进程调用fork()创建了子进程,那么对父进程创建的锁而言,子进程被视为另一个进程,虽然子进程从父进程继承了其文件描述符,但不能继承文件锁。这个约束是有道理的,因为锁的作用就是阻止多个进程同时写同一个文件,如果子进程通过fork()继承了父进程的锁,则父进程和子进程就可以同时写同一个文件了。
除此之外,当一个文件描述符被复制时(譬如使用dup()、dup2()或fcntl()F_DUPFD操作),这些通过复制得到的文件描述符和源文件描述符都会引用同一个文件锁,使用这些文件描述符中的任何一个进行解锁都可以,如下所示:
flock(fd, LOCK_EX); //加锁
new_fd = dup(fd);
flock(new_fd, LOCK_UN); //解锁
这段代码先在fd上设置一个排它锁,然后使用dup()对fd进行复制得到新文件描述符new_fd,最后通过new_fd来解锁,这样可以解锁成功。但是,如果不显示的调用一个解锁操作,只有当所有文件描述符都被关闭之后锁才会被释放。譬如上面的例子中,如果不调用flock(new_fd, LOCK_UN)进行解锁,只有当fd和new_fd都被关闭之后锁才会自动释放。
关于本小节内容就暂时到这里为止!接下来我们将学习使用fcntl()对文件上锁。
14.6.2fcntl()函数加锁
fcntl()函数在前面章节内容中已经多次用到了,它是一个多功能文件描述符管理工具箱,通过配合不同的cmd操作命令来实现不同的功能。为了方便述说,这里再重申一次:
#include <unistd.h>
#include <fcntl.h>
int fcntl(int fd, int cmd, … /* struct flock *flockptr */ );
与锁相关的cmd为F_SETLK、F_SETLKW、F_GETLK,第三个参数flockptr是一个struct flock结构体指针。使用fcntl()实现文件锁功能与flock()有两个比较大的区别:
flock()仅支持对整个文件进行加锁/解锁;而fcntl()可以对文件的某个区域(某部分内容)进行加锁/解锁,可以精确到某一个字节数据。
flock()仅支持建议性锁类型;而fcntl()可支持建议性锁和强制性锁两种类型。
我们先来看看struct flock结构体,如下所示:
示例代码 14.6.3 struct flock结构体
struct flock {
...
short l_type; /* Type of lock: F_RDLCK,F_WRLCK, F_UNLCK */
short l_whence; /* How to interpret l_start: SEEK_SET, SEEK_CUR, SEEK_END */
off_t l_start; /* Starting offset for lock */
off_t l_len; /* Number of bytes to lock */
pid_t l_pid; /* PID of process blocking our lock(set by F_GETLK and F_OFD_GETLK) */
...
};
对struct flock结构体说明如下:
l_type:所希望的锁类型,可以设置为F_RDLCK、F_WRLCK和F_UNLCK三种类型之一,F_RDLCK表示共享性质的读锁,F_WRLCK表示独占性质的写锁,F_UNLCK表示解锁一个区域。
l_whence和l_start:这两个变量用于指定要加锁或解锁区域的起始字节偏移量,与2.7小节所学的lseek()函数中的offset和whence参数相同,这里不再重述,如果忘记了,可以回到2.7小节再看看。
l_len:需要加锁或解锁区域的字节长度。
l_pid:一个pid,指向一个进程,表示该进程持有的锁能阻塞当前进程,当cmd=F_GETLK时有效。
以上便是对struct flock结构体各成员变量的简单介绍,对于加锁和解锁区域的说明,还需要注意以下几项规则:
锁区域可以在当前文件末尾处开始或者越过末尾处开始,但是不能在文件起始位置之前开始。
若参数l_len设置为0,表示将锁区域扩大到最大范围,也就是说从锁区域的起始位置开始,到文件的最大偏移量处(也就是文件末尾)都处于锁区域范围内。而且是动态的,这意味着不管向该文件追加写了多少数据,它们都处于锁区域范围,起始位置可以是文件的任意位置。
如果我们需要对整个文件加锁,可以将l_whence和l_start设置为指向文件的起始位置,并且指定参数l_len等于0。
两种类型的锁:F_RDLCK和F_WRLCK
上面我们提到了两种类型的锁,分别为共享性读锁(F_RDLCK)和独占性写锁(F_WRLCK)。基本的规则与12.5小节所介绍的线程同步读写锁很相似,任意多个进程在一个给定的字节上可以有一把共享的读锁,但是在一个给定的字节上只能有一个进程有一把独占写锁,进一步而言,如果在一个给定的字节上已经有一把或多把读锁,则不能在该字节上加写锁;如果在一个字节上已经有一把独占性写锁,则不能再对它加任何锁(包括读锁和写锁),下图显示了这些兼容性规则:
图 14.6.5 不同类型锁彼此之间的兼容性
如果一个进程对文件的某个区域已经上了一把锁,后来该进程又试图在该区域再加一把锁,那么通常新加的锁将替换旧的锁。譬如,若某一进程在文件的100200字节区间有一把写锁,然后又试图在100200字节区间再加一把读锁,那么该请求将会成功执行,原来的写锁会替换为读锁。
还需要注意另外一个问题,当对文件的某一区域加读锁时,调用进程必须对该文件有读权限,譬如open()时flags参数指定了O_RDONLY或O_RDWR;当对文件的某一区域加写锁时,调用进程必须对该文件有写权限,譬如open()时flags参数指定了O_WRONLY或O_RDWR。
F_SETLK、F_SETLKW和F_GETLK
我们来看看与文件锁相关的三个cmd它们的作用:
F_GETLK:这种用法一般用于测试,测试调用进程对文件加一把由参数flockptr指向的struct flock对象所描述的锁是否会加锁成功。如果加锁不成功,意味着该文件的这部分区域已经存在一把锁,并且由另一进程所持有,并且调用进程加的锁与现有锁之间存在排斥关系,现有锁会阻止调用进程想要加的锁,并且现有锁的信息将会重写参数flockptr指向的对象信息。如果不存在这种情况,也就是说flockptr指向的struct flock对象所描述的锁会加锁成功,则除了将struct flock对象的l_type修改为F_UNLCK之外,结构体中的其它信息保持不变。
F_SETLK:对文件添加由flockptr指向的struct flock对象所描述的锁。譬如试图对文件的某一区域加读锁(l_type等于F_RDLCK)或写锁(l_type等于F_WRLCK),如果加锁失败,那么fcntl()将立即出错返回,此时将errno设置为EACCES或EAGAIN。也可用于清除由flockptr指向的struct flock对象所描述的锁(l_type等于F_UNLCK)。
F_SETLKW:此命令是F_SETLK的阻塞版本(命令名中的W表示等待wait),如果所请求的读锁或写锁因另一个进程当前已经对所请求区域的某部分进行了加锁,而导致请求失败,那么调用进程将会进入阻塞状态。只有当请求的锁可用时,进程才会被唤醒。
F_GETLK命令一般很少用,事先用F_GETLK命令测试是否能够对文件加锁,然后再用F_SETLK或F_SETLKW命令对文件加锁,但这两者并不是原子操作,所以即使测试结果表明可以加锁成功,但是在使用F_SETLK或F_SETLKW命令对文件加锁之前也有可能被其它进程锁住。
使用示例与测试
示例代码 13.6.4演示了使用fcntl()对文件加锁和解锁的操作。需要加锁的文件通过外部传参传入,先调用open()函数以只写方式打开文件;接着对struct flock类型对象lock进行填充,l_type设置为F_WRLCK表示加一个写锁,通过l_whence和l_start两个变量将加锁区域的起始位置设置为文件头部,接着将l_len设置为0表示对整个文件加锁。
示例代码 14.6.4 使用fcntl()对文件加锁/解锁使用示例
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <string.h>
int main(int argc, char *argv[])
{
struct flock lock = {0};
int fd = -1;
char buf[] = "Hello World!";
/* 校验传参 */
if (2 != argc) {
fprintf(stderr, "usage: %s <file>\n", argv[0]);
exit(-1);
}
/* 打开文件 */
fd = open(argv[1], O_WRONLY);
if (-1 == fd) {
perror("open error");
exit(-1);
}
/* 对文件加锁 */
lock.l_type = F_WRLCK; //独占性写锁
lock.l_whence = SEEK_SET; //文件头部
lock.l_start = 0; //偏移量为0
lock.l_len = 0;
if (-1 == fcntl(fd, F_SETLK, &lock)) {
perror("加锁失败");
exit(-1);
}
printf("对文件加锁成功!\n");
/* 对文件进行写操作 */
if (0 > write(fd, buf, strlen(buf))) {
perror("write error");
exit(-1);
}
/* 解锁 */
lock.l_type = F_UNLCK; //解锁
fcntl(fd, F_SETLK, &lock);
/* 退出 */
close(fd);
exit(0);
}
整个代码很简单,比较容易理解,具体执行的结果就不再给大家演示了。
一个进程可以对同一个文件的不同区域进行加锁,当然这两个区域不能有重叠的情况。示例代码 13.6.5演示了一个进程对同一文件的两个不同区域分别加读锁和写锁,对文件的100200字节区间加了一个写锁,对文件的400500字节区间加了一个读锁。
示例代码 14.6.5 对文件的不同区域进行加锁
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
int main(int argc, char *argv[])
{
struct flock wr_lock = {0};
struct flock rd_lock = {0};
int fd = -1;
/* 校验传参 */
if (2 != argc) {
fprintf(stderr, "usage: %s <file>\n", argv[0]);
exit(-1);
}
/* 打开文件 */
fd = open(argv[1], O_RDWR);
if (-1 == fd) {
perror("open error");
exit(-1);
}
/* 将文件大小截断为1024字节 */
ftruncate(fd, 1024);
/* 对100~200字节区间加写锁 */
wr_lock.l_type = F_WRLCK;
wr_lock.l_whence = SEEK_SET;
wr_lock.l_start = 100;
wr_lock.l_len = 100;
if (-1 == fcntl(fd, F_SETLK, &wr_lock)) {
perror("加写锁失败");
exit(-1);
}
printf("加写锁成功!\n");
/* 对400~500字节区间加读锁 */
rd_lock.l_type = F_RDLCK;
rd_lock.l_whence = SEEK_SET;
rd_lock.l_start = 400;
rd_lock.l_len = 100;
if (-1 == fcntl(fd, F_SETLK, &rd_lock)) {
perror("加读锁失败");
exit(-1);
}
printf("加读锁成功!\n");
/* 对文件进行I/O操作 */
// ......
// ......
/* 解锁 */
wr_lock.l_type = F_UNLCK; //写锁解锁
fcntl(fd, F_SETLK, &wr_lock);
rd_lock.l_type = F_UNLCK; //读锁解锁
fcntl(fd, F_SETLK, &rd_lock);
/* 退出 */
close(fd);
exit(0);
}
如果两个区域出现了重叠,譬如100200字节区间和150250字节区间,150200就是它们的重叠部分,一个进程对同一文件的相同区域不可能同时加两把锁,新加的锁会把旧的锁替换掉,譬如先对100200字节区间加写锁、再对150250字节区间加读锁,那么150200字节区间最终是读锁控制的,关于这个问题,大家可以自己去验证、测试。
接下来对读锁和写锁彼此之间的兼容性进行测试,使用示例代码 13.6.6测试读锁的共享性。
示例代码 14.6.6 读锁的共享性测试
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
int main(int argc, char *argv[])
{
struct flock lock = {0};
int fd = -1;
/* 校验传参 */
if (2 != argc) {
fprintf(stderr, "usage: %s <file>\n", argv[0]);
exit(-1);
}
/* 打开文件 */
fd = open(argv[1], O_RDWR);
if (-1 == fd) {
perror("open error");
exit(-1);
}
/* 将文件大小截断为1024字节 */
ftruncate(fd, 1024);
/* 对400~500字节区间加读锁 */
lock.l_type = F_RDLCK;
lock.l_whence = SEEK_SET;
lock.l_start = 400;
lock.l_len = 100;
if (-1 == fcntl(fd, F_SETLK, &lock)) {
perror("加读锁失败");
exit(-1);
}
printf("加读锁成功!\n");
for ( ; ; )
sleep(1);
}
首先运行上述示例代码,程序加读锁之后会进入死循环,进程一直在运行着、持有读锁。接着多次运行上述示例代码,启动多个进程加读锁,测试结果如下所示:
图 14.6.6 读锁共享性测试
从打印信息可以发现,多个进程对同一文件的相同区域都可以加读锁,说明读锁是共享性的。由于程序是放置在后台运行的,测试完毕之后,可以使用kill命令将这些进程杀死,或者直接关闭当前终端,重新启动新的终端。
使用示例代码 13.6.7测试写锁的独占性。
示例代码 14.6.7 写锁的独占性测试
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
int main(int argc, char *argv[])
{
struct flock lock = {0};
int fd = -1;
/* 校验传参 */
if (2 != argc) {
fprintf(stderr, "usage: %s <file>\n", argv[0]);
exit(-1);
}
/* 打开文件 */
fd = open(argv[1], O_RDWR);
if (-1 == fd) {
perror("open error");
exit(-1);
}
/* 将文件大小截断为1024字节 */
ftruncate(fd, 1024);
/* 对400~500字节区间加写锁 */
lock.l_type = F_WRLCK;
lock.l_whence = SEEK_SET;
lock.l_start = 400;
lock.l_len = 100;
if (-1 == fcntl(fd, F_SETLK, &lock)) {
perror("加写锁失败");
exit(-1);
}
printf("加写锁成功!\n");
for ( ; ; )
sleep(1);
}
测试方法与读锁测试方法一样,如下所示:
图 14.6.7 写锁的独占性测试
由打印信息可知,但第一次启动的进程对文件加写锁之后,后面再启动进程对同一文件的相同区域加写锁发现都会失败,所以由此可知,写锁是独占性的。
几条规则
关于使用fcntl()创建锁的几条规则与flock()相似,如下所示:
文件关闭的时候,会自动解锁。
一个进程不可以对另一个进程持有的文件锁进行解锁。
由fork()创建的子进程不会继承父进程所创建的锁。
除此之外,当一个文件描述符被复制时(譬如使用dup()、dup2()或fcntl()F_DUPFD操作),这些通过复制得到的文件描述符和源文件描述符都会引用同一个文件锁,使用这些文件描述符中的任何一个进行解锁都可以,这点与flock()是一样的,如下所示:
lock.l_type = F_RDLCK;
fcntl(fd, F_SETLK, &lock);//加锁
new_fd = dup(fd);
lock.l_type = F_UNLCK;
fcntl(new_fd, F_SETLK, &lock);//解锁
这段代码先在fd上设置一个读锁,然后使用dup()对fd进行复制得到新文件描述符new_fd,最后通过new_fd来解锁,这样可以解锁成功。如果不显示的调用一个解锁操作,任何一个文件描述符被关闭之后锁都会自动释放,那么这点与flock()是不同的。譬如上面的例子中,如果不调用flock(new_fd, LOCK_UN)进行解锁,当fd或new_fd两个文件描述符中的任何一个被关闭之后锁都会自动释放。
建议性锁和强制性锁
前面我们提到了fcntl()支持强制性锁和建议性锁,但是一般不建议使用强制性锁,所以大部分情况下使用的都是建议性锁,那如何使能强制性锁呢?
对于一个特定的文件,开启它的强制性锁机制其实非常简单,主要跟文件的权限位有关系,在5.5小节对文件的权限进行了比较详细的介绍,这里不再重述!如果要开启强制性锁机制,需要设置文件的Set-Group-ID(S_ISGID)位为1,并且禁止文件的组用户执行权限(S_IXGRP),也就是将其设置为0。
但是,有些Linux/Unix发行版系统并不支持强制性锁机制,可以通过示例代码 13.6.8进行测试。
示例代码 14.6.8 测试系统是否支持强制性锁机制
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <sys/wait.h>
int main(int argc, char *argv[])
{
struct stat sbuf = {0};
int fd = -1;
pid_t pid;
/* 校验传参 */
if (2 != argc) {
fprintf(stderr, "usage: %s <file>\n", argv[0]);
exit(-1);
}
/* 打开文件 */
fd = open(argv[1], O_RDWR | O_CREAT | O_TRUNC, 0664);
if (-1 == fd) {
perror("open error");
exit(-1);
}
/* 写入一行字符串 */
if (12 != write(fd, "Hello World!", 12)) {
perror("write error");
exit(-1);
}
/* 开启强制性锁机制 */
if (0 > fstat(fd, &sbuf)) {//获取文件属性
perror("fstat error");
exit(-1);
}
if (0 > fchmod(fd, (sbuf.st_mode & ~S_IXGRP)
| S_ISGID)) {
perror("fchmod error");
exit(-1);
}
/* fork创建子进程 */
if (0 > (pid = fork())) //出错
perror("fork error");
else if (0 < pid) { //父进程
struct flock lock = {0};
/* 对整个文件加写锁 */
lock.l_type = F_WRLCK;
lock.l_whence = SEEK_SET;
lock.l_start = 0;
lock.l_len = 0;
if (0 > fcntl(fd, F_SETLK, &lock))
perror("父进程: 加写锁失败");
else
printf("父进程: 加写锁成功!\n");
printf("~~~~~~~~~~~~~~~~~~~\n");
if (0 > wait(NULL))
perror("wait error");
}
else { //子进程
struct flock lock = {0};
int flag;
char buf[20] = {0};
sleep(1); //休眠1秒钟,让父进程先运行
/* 设置为非阻塞方式 */
flag = fcntl(fd, F_GETFL);
flag |= O_NONBLOCK;
fcntl(fd, F_SETFL, flag);
/* 对整个文件加读锁 */
lock.l_type = F_RDLCK;
lock.l_whence = SEEK_SET;
lock.l_start = 0;
lock.l_len = 0;
if (-1 == fcntl(fd, F_SETLK, &lock))
perror("子进程: 加读锁失败");
else
printf("子进程: 加读锁成功!\n");
/* 读文件 */
if (0 > lseek(fd, 0, SEEK_SET))
perror("lseek error");
if (0 > read(fd, buf, 12))
perror("子进程: read error");
else
printf("子进程: read OK, buf = %s\n", buf);
}
exit(0);
}
此程序首先创建了一个文件,文件路径通过传参的方式传递给应用程序,如果不存在该文件则创建它。接着向文件中写入数据,开启文件的强制性锁机制。接下来程序调用fork()创建了一个子进程,在父进程分支中,对文件的所有区域加了一把独占性质的写锁,接着调用wait()等到回收子进程;在子进程分支中先是休眠了一秒钟以保证父进程先执行,子进程将文件设置为非阻塞方式,这里大家可能会有疑问?普通文件不都是非阻塞的吗?这里为什么要设置非阻塞呢?并不是多此一举,原因在于这里涉及到了强制性锁的问题,在强制性锁机制下,如果文件被进程添加了强制性写锁,其它进程读或写该文件将会被阻塞,所以我们需要显式设置为非阻塞方式。
设置为非阻塞之后,子进程试图对文件设置一把读锁,接着子进程将文件读、写位置移动到文件头,并试图read读该文件。
由于父进程已经对文件设置了写锁,子进程试图对文件设置读锁时,将会失败;子进程在没有获取到读锁的情况下,调用read()读取文件将会出现两种情况:如果系统支持强制性锁机制,那么read()将会失败;如果系统不支持强制性锁机制,read()将会成功!
接下来我们进行测试:
图 14.6.8 Ubuntu系统下测试结果
从打印信息可以发现,父进程设置了写锁的情况下,子进程再次对其设置读锁是不成功的,也就是子进程没有获取到读锁,但是读文件却是成功的,由此可知,我们测试所使用的Ubuntu系统不支持强制性锁机制。
14.6.3lockf()函数加锁
lockf()函数是一个库函数,其内部是基于fcntl()来实现的,所以lockf()是对fcntl锁的一种封装,具体的使用方法这里便不再介绍。
14.7小结
本章向大家介绍了几种高级I/O功能,非阻塞I/O、I/O多路复用、异步I/O、存储映射I/O、以及文件锁,其中有许多的功能,我们将会在后面的提高篇和进阶篇章节实例中使用到。
非阻塞I/O:进程向文件发起I/O操作,使其不会被阻塞。
I/O多路复用:select()和poll()函数。
异步I/O:当文件描述符上可以执行I/O操作时,内核会向进程发送信号通知它。
存储映射I/O:mmap()函数。
文件锁:flock()、fcntl()以及lockf()函数。