高级 IO(非阻塞、多路复用、异步、存储映射、文件锁)

本文探讨了阻塞I/O与非阻塞I/O的区别,通过读取鼠标设备文件示例展示了两种模式的工作原理。随后详细介绍了I/O多路复用技术中的poll函数,包括其参数、工作流程及与select的对比。最后,讲解了存储映射I/O原理,重点阐述了mmap和munmap函数的使用,以及映射区的特性与注意事项。
摘要由CSDN通过智能技术生成

非阻塞I/O

关于“阻塞”一词前面已经给大家多次提到,阻塞其实就是进入了休眠状态,交出了CPU 控制权。前面所学习过的函数,譬如wait()、pause()、sleep()等函数都会进入阻塞,本小节来聊一聊关于阻塞式I/O 与非阻塞式I/O。

对于某些文件类型(读管道文件、网络设备文件和字符设备文件),当对文件进行读操作时,如果数据未准备好、文件当前无数据可读,那么读操作可能会使调用者阻塞,直到有数据可读时才会被唤醒,这就是阻塞式I/O 。如果是非阻塞式I/O,即使没有数据可读,也不会被阻塞、而是会立马返回错误

阻塞I/O与非阻塞I/O读文件

本小节我们将分别演示使用阻塞式I/O 和非阻塞式I/O 对文件进行读操作。

  • 在调用open()函数打开文件时,参数flags指定O_NONBLOCK 标志,open()调用成功后,后续的I/O 操作将以非阻塞式方式进行,这就是非阻塞I/O 的打开方式
  • 如果参数flags未指定O_NONBLOCK 标志,则默认使用阻塞式I/O 进行操作。

对于普通文件来说,指定与未指定O_NONBLOCK 标志对其是没有影响,普通文件的读写操作是不会阻塞的,它总是以非阻塞的方式进行I/O 操作,这是普通文件本质上决定的。

本小节我们将以读取鼠标为例,使用两种I/O 方式进行读取,来进行对比,鼠标是一种输入设备,其对应的设备文件在/dev/input/目录下,如下所示:

在这里插入图片描述

通常情况下是mouseX(X 表示序号0、1、2),但也不一定,也有可能是eventX,如何确定到底是哪个设备文件,可以通过对设备文件进行读取来判断,譬如使用od 命令:

sudo od -x /dev/input/event3

Tips:需要添加sudo,在Ubuntu 系统下,普通用户是无法对设备文件进行读取或写入操作。

当执行命令之后,移动鼠标或按下鼠标、松开鼠标都会在终端打印出相应的数据,如下所示:

在这里插入图片描述

如果没有打印信息,那么这个设备文件就不是鼠标对应的设备文件。笔者使用的Ubuntu 系统,对应的鼠标设备文件是/dev/input/event3。接下来我们编写一个测试程序,使用阻塞式I/O 读取鼠标。

阻塞方式读取鼠标,调用open()函数打开鼠标设备文件"/dev/input/event3",以只读方式打开,没有指定O_NONBLOCK 标志,说明使用的是阻塞式I/O;程序中只调用了一次read()读取鼠标。

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

编译上述示例代码进行测试:

在这里插入图片描述

执行程序之后,发现程序没有立即结束,而是一直占用了终端,没有输出信息,原因在于调用read()之后进入了阻塞状态,因为当前鼠标没有数据可读;

如果此时我们移动鼠标、或者按下鼠标上的任何一个按键,阻塞会结束,read()会成功读取到数据并返回,如下所示:

在这里插入图片描述
打印信息提示,此次read 成功读取了48 个字节,程序当中我们明明要求读取的是100 个字节,为什么这里只读取到了48 个字节?关于这个问题将会在第二篇内容当中进行介绍,这里暂时先不去理会这个问题。

接下来,我们将示例代码修改成非阻塞式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 标志即可,对上述示例代码进行编译测试:

在这里插入图片描述

执行程序之后,程序立马就结束了,并且调用read()返回错误,提示信息为"Resource temporarilyunavailable",意思就是说资源暂时不可用;原因在于调用read()时,如果鼠标并没有移动或者被按下(没有发生输入事件),是没有数据可读,故而导致失败返回,这就是非阻塞I/O。

可以对示例代码进行修改,使用轮训方式不断地去读取,直到鼠标有数据可读,read()将会成功返回:

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

具体的执行的效果便不再演示了,各位读者自己动手试试。

阻塞I/O:阻塞后进入休眠,交出cpu资源降低使用率,不能并发

  • 当对文件进行读取操作时,如果文件当前无数据可读,那么阻塞式I/O 会将调用者应用程序挂起、进入休眠阻塞状态,直到有数据可读时才会解除阻塞
  • 而对于非阻塞I/O,应用程序不会被挂起,而是会立即返回,它要么一直轮询等待,直到数据可读,要么直接放弃

阻塞式I/O 的优点在于能够提升CPU 的处理效率,当自身条件不满足时,进入阻塞状态,交出CPU资源,将CPU 资源让给别人使用;而非阻塞式则是抓紧利用CPU 资源,譬如不断地去轮训,这样就会导致该程序占用了非常高的CPU 使用率

执行示例代码13.1.3 对应的程序时,通过top 命令可以发现该程序的占用了非常高的CPU 使用率,如下所示:

在这里插入图片描述

其CPU 占用率几乎达到了100%,在一个系统当中,一个进程的CPU 占用率这么高是一件非常危险的事情。而示例代码13.1.1 这种阻塞式方式,其CPU 占用率几乎为0。

非阻塞I/O:实现并发读取,cpu占用率高

上一小节给大家所举的例子当中,只读取了鼠标的数据,如果要在程序当中同时读取鼠标和键盘,那又该如何呢?本小节我们将分别演示使用阻塞式I/O 和非阻塞式I/O 同时读取鼠标和键盘。

键盘也是一种输入类设备,但是键盘是标准输入设备stdin,进程会自动从父进程中继承标准输入、标准输出以及标准错误,标准输入设备对应的文件描述符为0,所以在程序当中直接使用即可,不需要再调用open 打开。

首先我们使用阻塞式方式同时读取鼠标和键盘,示例代码如下所示:

#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()打开得到的,将标准输入设置为非阻塞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 演示了以非阻塞方式同时读取鼠标和键盘。

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

将读取鼠标和读取键盘操作放入到一个循环中,通过轮训方式来实现并发读取鼠标和键盘,对上述代码进行编译,测试结果:

在这里插入图片描述

这样就解决了示例代码13.1.4 所出现的问题,不管是先动鼠标还是先按键盘都可以成功读取到相应数据。

虽然使用非阻塞I/O 方式解决了示例代码13.1.4 出现的问题,但由于程序当中使用轮训方式,故而会使得该程序的CPU 占用率特别高,终归还是不太安全。

I/O 多路复用(外部阻塞式,内部监视多路I/O)

上一小节虽然使用非阻塞式I/O 解决了阻塞式I/O 情况下并发读取文件所出现的问题,但依然不够完美,使得程序的CPU 占用率特别高。解决这个问题,就要用到本小节将要介绍的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。

select函数(博文摘录)

参考:https://blog.csdn.net/y396397735/article/details/55004775

select函数的功能和调用顺序

使用select函数时统一监视多个文件描述符的:
1、 是否存在套接字接收数据?
2、 无需阻塞传输数据的套接字有哪些?
3、 哪些套接字发生了异常?

select函数调用过程:

在这里插入图片描述

由上图知,调用select函数需要一些准备工作,调用后还需要查看结果。

设置文件描述符

select可以同时监视多个文件描述符(套接字)。
此时需要先将文件描述符集中到一起。集中时也要按照监视项(接收,传输,异常)进行区分,即按照上述3种监视项分成三类。
使用fd_set数组变量执行此项操作,该数组是存有0和1的位数组。

在这里插入图片描述

最左端的位表示文件描述符0(位置)。如果该位值为1,则表示该文件描述符是监视对象。
图上显然监视对象为fd1和fd3。

“是否应当通过文件描述符的数字直接将值注册到fd_set变量?”
当然不是!操作fd_set的值由如下宏来完成:

  • FD_ZERO(fd_set * fdset): 将 fdset 变量的所有位初始化为0。
  • FD_SET(int fd, fd_set * fdset):在参数 fdset 指向的变量中注册文件描述符 fd 的信息。
  • FD_CLR(int fd, fd_set * fdset):参数 fdset 指向的变量中清除文件描述符 fd 的信息。
  • FD_ISSET(int fd, fd_set * fdset): 若参数 fdset 指向的变量中包含文件描述符fd的信息,则返回真。

画图解释:

在这里插入图片描述

设置监视范围及超时

select函数:

#include <sys/select.h>
#include <sys/time.h>

int select(int maxfd, 
          fd_set* readset, 
          fd_set* writeset, 
          fd_set* exceptset, 
          const struct timeval* timeout);

其中参数和返回值:

maxfd:监视对象文件描述符数量(最大的文件描述符值加1)。
readset: 将所有关注“是否存在待接收数据”的文件描述符注册到fd_set变量,并传递其地址值。
writeset: 将所有关注“是否可传输无阻塞数据”的文件描述符注册到fd_set变量,并传递其地址值。
exceptset: 将所有关注“是否发生异常”的文件描述符注册到fd_set变量,并传递其地址值。
timeout: 调用select后,为防止陷入无限阻塞状态,传递超时信息。

返回值错误返回-1,超时返回0。因关注的事件返回时,返回大于0的值,该值是发生事件的文件描述符数。

select函数用来验证3种监视项的变化情况。根据监视项声明 3个fd_set 变量,分别向其注册文件描述符信息,并把变量的地址传递到函数的第二到第四个参数。但是,在调用select函数前需要决定2件事:
“文件描述符的监视范围是?”
“如何设定select函数的超时时间?”

第一,文件描述符的监视范围与第一个参数有关,实际上,select函数要求通过第一个参数传递监视对象文件描述符的数量。因此,需要得到注册在fd_set变量中的文件描述符数。但每次新建文件描述符时,其值都会增1,故只需将最大的文件描述符值加1再传递到select函数即可。(加1是因为文件描述符的值从0开始)
第二,超时时间与最后一个参数有关,其中timeval结构体如下:

struct timeval
{
    long tv_sec;
    long tv_usec;
};

本来select函数只有在监视文件描述符发生变化时才返回,未发生变化会进入阻塞状态。指定超时时间就是为了防止这种情况发生。
将上述结构体填入时间值,然后将结构体地址值传给select函数的最后一个参数,此时,即使文件描述符中未发生变化,只要过了指定时间,也可以从函数返回。不过这种情况下,select函数返回0。
不想设置超时最后一个参数只需要传递NULL。

调用select函数后查看结果

如果select返回值大于0,说明文件描述符发生了变化。

关于文件描述符变化:
    文件描述符变化是指监视的文件描述符中发生了相应的监视事件。
    例如通过select的第二个参数传递的集合中存在需要读取数据的描述符时,就意味着文件描述符发生变化。

怎样获知哪些文件描述符发生了变化?向select函数的第二到第四个参数传递的fd_set变量中将产生变化,如下图:

在这里插入图片描述

select函数调用完成后,向其传递的fd_set变量中将发生变化。原来为1的所有位均变为0,但发生变化的文件描述符对应位除外。因此,可以认为值为1的位置上的文件描述符发生了变化。

select函数调用实例

#include <stdio.h>
#include <unistd.h>
#include <sys/time.h>
#include <sys/select.h>

#define BUF_SIZE 30

int main(int argc, char* argv[])
{
    fd_set reads, temps;
    int result, str_len;
    char buf[BUF_SIZE];
    struct timeval timeout;
    FD_ZERO(&reads);
    FD_SET(0, &reads);//监视文件描述符 0 的变化, 即标准输入的变化
    /*
    超时不能在此设置!
    因为调用select后,结构体timeval的成员tv_sec和tv_usec的值将被替换为超时前剩余时间.
    调用select函数前,每次都需要初始化timeval结构体变量.
    timeout.tv_sec = 5;
    timeout.tv_usec = 5000;
    */
    while(1)
    {
        /*将准备好的fd_set变量reads的内容复制到temps变量,因为调用select函数后,除了发生变化的fd对应位外,剩下的所有位
        都将初始化为0,为了记住初始值,必须经过这种复制过程。
        */
        temps = reads;
        //设置超时
        timeout.tv_sec = 5;
        timeout.tv_usec = 0;

        //调用select函数. 若有控制台输入数据,则返回大于0的整数,如果没有输入数据而引发超时,返回0.
        result = select(1, &temps, 0, 0, &timeout);
        if(result == -1)
        {
            perror("select() error");
            break;
        }
        else if(result == 0)
        {
            puts("timeout");
        }
        else
        {
            //读取数据并输出
            if(FD_ISSET(0, &temps))
            {
                str_len = read(0, buf, BUF_SIZE);
                buf[str_len] = 0;
                printf("message from console: %s", buf);
            }
        }
    }

    return 0;
}
程序运行结果:

nihao
message from console: nihao
goodbye
message from console: goodbye
timeout
timeout

select函数实现I/O复用服务端

参照博文。

select() 的 timeout 陷阱

select() 的 timeout 陷阱

struct timeval timout;
timeout.tv_sec = 5;
timeout.tv_usec =0;
 
while(1)
{
    select(fds, &rd_set, NULL, NULL, &timeout);
}
// 上述代码,如果第一次是5秒超时;进入下一次循环,就是0秒超时了
 
 
// 如果希望每次5秒超时,将代码调整为如下:
while(1)
{
    struct timeval timout;
    timeout.tv_sec = 5;
    timeout.tv_usec =0;
    select(fds, &rd_set, NULL, NULL, &timeout);
}

select函数(原子例程,鼠标键盘两路IO)

系统调用select()可用于执行I/O 多路复用操作,调用select()会一直阻塞,直到某一个或多个文件描述符成为就绪态(可以读或写)。其函数原型如下所示:

#include <sys/select.h>

int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);

fd_set 数据类型是一个文件描述符的集合体。

  • ⚫ 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()调用会陷入阻塞,直到发生输入事件(鼠标移动、键盘上的按键按下或松开)才会返回。

#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 集合中是否包含鼠标文件描述符以及键盘文件描述符,如果包含则表示可以读取数据了。

编译运行:

在这里插入图片描述

示例代码13.2.1 将鼠标和键盘都设置为了非阻塞I/O 方式,其实设置为阻塞I/O 方式也是可以的,因为select()返回时意味着此时数据是可读取的,所以以非阻塞和阻塞两种方式读取数据均不会发生阻塞。

poll函数

系统调用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 结构体如下所示:

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 中第一组标志(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。

#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 事件,判断鼠标或键盘数据是否可读。测试结果:

在这里插入图片描述

总结

在使用select()或poll()时需要注意一个问题,当监测到某一个或多个文件描述符成为就绪态(可以读或写)时,需要执行相应的I/O 操作,以清除该状态,否则该状态将会一直存在;譬如示例代码13.2.1 中,调用select()函数监测鼠标和键盘这两个文件描述符,当select()返回时,通过FD_ISSET()宏判断文件描述符上是否可执行I/O 操作;如果可以执行I/O 操作时,应在应用程序中对该文件描述符执行I/O 操作,以清除文件描述符的就绪态,如果不清除就绪态,那么该状态将会一直存在,那么下一次调用select()时,文件描述符已经处于就绪态了,将直接返回。

同理对于poll()函数来说亦是如此,譬如示例代码13.2.3,当poll()成功返回时,检查文件描述符是否称为就绪态,如果文件描述符上可执行I/O 操作时,也需要对文件描述符执行I/O 操作,以清除就绪状态。

异步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()读取鼠标数据。

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

代码比较简单,这里我们进行编译测试:

在这里插入图片描述

优化异步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 方式并未告知应用程序文件描述符上发生了什么事件,是可读取还是可写入亦或者发生异常等。

所以本小节我们将会针对以上列举出的两个缺陷进行优化。

使用实时信号替换默认信号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 通知信号,也就是回到了默认状态。

使用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 中的位掩码有着一一对应关系。

在这里插入图片描述

所以,由此可知,可以在信号处理函数中通过对比siginfo_t 结构体的si_code 变量来检查文件描述符发生了什么事件,以采取相应的I/O 操作。

使用示例

通过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 附加了很多信息,前面已有介绍,这里不再重述。

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

对上述示例代码进行编译时,出现了一些报错信息,如下所示:

在这里插入图片描述

报错提示没有定义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>

再次进行编译测试:

在这里插入图片描述

存储映射I/O

存储映射I/O(memory-mapped I/O)是一种基于内存区域的高级I/O 操作,它能将一个文件映射到进程地址空间中的一块内存区域中,当从这段内存中读数据时,就相当于读文件中的数据(对文件进行read 操作),将数据写入这段内存时,则相当于将数据直接写入文件中(对文件进行write 操作)。这样就可以在不使用基本I/O 操作函数read()和write()的情况下执行I/O 操作。

mmap()和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 来指示错误原因。

在这里插入图片描述

对于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 命令。

#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 的内容,如下所示:

在这里插入图片描述

目标文件dstfile 并不存在,我们需要在程序中进行创建,编译程序、运行:

在这里插入图片描述

由打印信息可知,程序运行完之后,生成了目标文件dstfile,使用cat 命令查看到其内容与源文件srcfile相同,本测试程序成功实现了文件复制功能!

mprotect()函数

使用系统调用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 来只是错误原因。

msync()函数

在第四章中提到过,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 标志,在解除映射之后,进程对映射区的修改将会丢弃!

普通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 方式,首先需要将源文件中的数据读取出来存放在一个应用层缓冲区中,接着再将缓冲区中的数据写入到目标文件中,如下所示:

在这里插入图片描述

而对于存储映射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。

文件锁

现象一下,当两个人同时编辑磁盘中同一份文件时,其后果将会如何呢?在Linux 系统中,该文件的最后状态通常取决于写该文件的最后一个进程。多个进程同时操作同一文件,很容易导致文件中的数据发生混乱,因为多个进程对文件进行I/O 操作时,容易产生竞争状态、导致文件中的内容与预想的不一致!

对于有些应用程序,进程有时需要确保只有它自己能够对某一文件进行I/O 操作,在这段时间内不允许其它进程对该文件进行I/O 操作。为了向进程提供这种功能,Linux 系统提供了文件锁机制。
前面学习过互斥锁、自旋锁以及读写锁,文件锁与这些锁一样,都是内核提供的锁机制,锁机制实现用于对共享资源的访问进行保护;只不过互斥锁、自旋锁、读写锁与文件锁的应用场景不一样,互斥锁、自旋锁、读写锁主要用在多线程环境下,对共享资源的访问进行保护,做到线程同步。
而文件锁,顾名思义是一种应用于文件的锁机制,当多个进程同时操作同一文件时,我们怎么保证文件数据的正确性,linux 通常采用的方法是对文件上锁,来避免多个进程同时操作同一文件时产生竞争状态。譬如进程对文件进行I/O 操作时,首先对文件进行上锁,将其锁住,然后再进行读写操作;只要进程没有对文件进行解锁,那么其它的进程将无法对其进行操作;这样就可以保证,文件被锁住期间,只有它(该进程)可以对其进行读写操作。
一个文件既然可以被多个进程同时操作,那说明文件必然是一种共享资源,所以由此可知,归根结底,文件锁也是一种用于对共享资源的访问进行保护的机制,通过对文件上锁,来避免访问共享资源产生竞争状态。

文件锁的分类
文件锁可以分为建议性锁和强制性锁两种:
⚫ 建议性锁
建议性锁本质上是一种协议,程序访问文件之前,先对文件上锁,上锁成功之后再访问文件,这是建议性锁的一种用法;但是如果你的程序不管三七二十一,在没有对文件上锁的情况下直接访问文件,也是可以访问的,并非无法访问文件;如果是这样,那么建议性锁就没有起到任何作用,如果要使得建议性锁起作用,那么大家就要遵守协议,访问文件之前先对文件上锁。这就好比交通信号灯,规定红灯不能通行,绿灯才可以通行,但如果你非要在红灯的时候通行,谁也拦不住你,那么后果将会导致发生交通事故;所以必须要大家共同遵守交通规则,交通信号灯才能起到作用。
⚫ 强制性锁:
强制性锁比较好理解,它是一种强制性的要求,如果进程对文件上了强制性锁,其它的进程在没有获取到文件锁的情况下是无法对文件进行访问的。其本质原因在于,强制性锁会让内核检查每一个I/O 操作(譬如read()、write()),验证调用进程是否是该文件锁的拥有者,如果不是将无法访问文件。当一个文件被上锁进行写入操作的时候,内核将阻止其它进程对其进行读写操作。采取强制性锁对性能的影响很大,每次进行读写操作都必须检查文件锁。

在Linux 系统中,可以调用flock()、fcntl()以及lockf()这三个函数对文件上锁,接下来将向大家介绍每个函数的使用方法。

flock()函数加锁

先来学习系统调用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()函数,在信号处理函数中对文件进行解锁,然后终止进程。

#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 操作,将数据写入文件、在读取出来并打印。

#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,如下所示:

在这里插入图片描述

在进行测试之前,创建一个测试用的文件infile,直接使用touch 命令创建即可,首先执行testApp1 应用程序,将infile 文件作为输入文件,并将其放置在后台运行:

在这里插入图片描述

testApp1 会在后台运行,由ps 命令可查看到其pid 为20710。接着执行testApp2 应用程序,传入相同的文件infile,如下所示:

在这里插入图片描述

从打印信息可知,testApp2 进程对infile 文件加锁失败,原因在于锁已经被testApp1 进程所持有,所以testApp2 加锁自然会失败;但是可以发现虽然加锁失败,但是testApp2 对文件的读写操作是没有问题的,是成功的,这就是建议性锁的特点;正确的使用方式是,在加锁失败之后不要再对文件进行I/O 操作了,遵循这个协议。
接着我们向testApp1 进程发送一个SIGIO 信号,让其对文件infile 解锁,接着再执行一次testApp2,如下所示:

在这里插入图片描述

使用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()对文件上锁。

fcntl()函数加锁

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 结构体,如下所示:

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 小节所介绍的线程同步读写锁很相似,任意多个进程在一个给定的字节上可以有一把共享的读锁,但是在一个给定的字节上只能有一个进程有一把独占写锁,进一步而言,如果在一个给定的字节上已经有一把或多把读锁,则不能在该字节上加写锁;如果在一个字节上已经有一把独占性写锁,则不能再对它加任何锁(包括读锁和写锁),下图显示了这些兼容性规则:

在这里插入图片描述

如果一个进程对文件的某个区域已经上了一把锁,后来该进程又试图在该区域再加一把锁,那么通常新加的锁将替换旧的锁。譬如,若某一进程在文件的100~ 200 字节区间有一把写锁,然后又试图在100~200 字节区间再加一把读锁,那么该请求将会成功执行,原来的写锁会替换为读锁。
还需要注意另外一个问题,当对文件的某一区域加读锁时,调用进程必须对该文件有读权限,譬如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 表示对整个文件加锁。

#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
演示了一个进程对同一文件的两个不同区域分别加读锁和写锁,对文件的100~ 200 字节区间加了一个写锁,对文件的400~500 字节区间加了一个读锁。

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

如果两个区域出现了重叠,譬如100~ 200 字节区间和150~ 250 字节区间,150~ 200 就是它们的重叠部分,一个进程对同一文件的相同区域不可能同时加两把锁,新加的锁会把旧的锁替换掉,譬如先对100~ 200字节区间加写锁、再对150~ 250 字节区间加读锁,那么150~200 字节区间最终是读锁控制的,关于这个问题,大家可以自己去验证、测试。
接下来对读锁和写锁彼此之间的兼容性进行测试,使用示例代码13.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);
}

首先运行上述示例代码,程序加读锁之后会进入死循环,进程一直在运行着、持有读锁。接着多次运行上述示例代码,启动多个进程加读锁,测试结果如下所示:

在这里插入图片描述

从打印信息可以发现,多个进程对同一文件的相同区域都可以加读锁,说明读锁是共享性的。由于程序是放置在后台运行的,测试完毕之后,可以使用kill 命令将这些进程杀死,或者直接关闭当前终端,重新启动新的终端。
使用示例代码13.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);
}

测试方法与读锁测试方法一样,如下所示:

在这里插入图片描述

由打印信息可知,但第一次启动的进程对文件加写锁之后,后面再启动进程对同一文件的相同区域加写锁发现都会失败,所以由此可知,写锁是独占性的。
几条规则
关于使用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 进行测试。

#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()将会成功!
接下来我们进行测试:

在这里插入图片描述

从打印信息可以发现,父进程设置了写锁的情况下,子进程再次对其设置读锁是不成功的,也就是子进程没有获取到读锁,但是读文件却是成功的,由此可知,我们测试所使用的Ubuntu 系统不支持强制性锁机制。

lockf()函数加锁

lockf()函数是一个库函数,其内部是基于fcntl()来实现的,所以lockf()是对fcntl 锁的一种封装,具体的使用方法这里便不再介绍。

小结

本章向大家介绍了几种高级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()函数。

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

行稳方能走远

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值