Linux系统编程(十一):高级 IO

发布时间:2024年01月13日

参考引用

1. 非阻塞 I/O

  • 阻塞就是进入了休眠状态,交出了 CPU 控制权
  • 阻塞 I/O 就是对文件的 I/O 操作(读写操作)是阻塞式的,非阻塞 I/O 就是对文件的 I/O 操作是非阻塞的
    • 阻塞式 I/O:对于某些文件类型(读管道文件、网络设备文件和字符设备文件),当对文件进行读操作时,如果数据未准备好或文件当前无数据可读,那么读操作可能会使调用者阻塞,直到有数据可读时才会被唤醒
    • 非阻塞式 I/O:即使没有数据可读也不会被阻塞,而是会立马返回错误
  • 普通文件不管读写多少个字节数据,read() 或 write() 一定会在有限的时间内返回,所以普通文件总是以非阻塞的方式进行 I/O 操作,但管道文件、设备文件等,既可使用阻塞式 I/O 操作,也可使用非阻塞式 I/O 进行操作

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

  • 以读取鼠标为例,鼠标是一种输入设备,其对应的设备文件在 /dev/input/ 目录下
    $ cd /dev/input
    $ ls -lh
    总用量 0
    drwxr-xr-x 2 root root      80 1月   8 20:18 by-id
    drwxr-xr-x 2 root root     180 1月   8 20:18 by-path
    crw-rw---- 1 root input 13, 64 1月   8 20:18 event0
    crw-rw---- 1 root input 13, 65 1月   8 20:18 event1
    crw-rw---- 1 root input 13, 66 1月   8 20:18 event2
    crw-rw---- 1 root input 13, 67 1月   8 20:18 event3
    crw-rw---- 1 root input 13, 68 1月   8 20:18 event4
    crw-rw---- 1 root input 13, 69 1月   8 20:18 event5
    crw-rw---- 1 root input 13, 70 1月   8 20:18 event6
    crw-rw-r-- 1 root input 13,  0 1月   8 20:18 js0
    crw-rw-r-- 1 root input 13,  1 1月   8 20:18 js1
    crw-rw---- 1 root input 13, 63 1月   8 20:18 mice
    crw-rw---- 1 root input 13, 32 1月   8 20:18 mouse0
    crw-rw---- 1 root input 13, 33 1月   8 20:18 mouse1
    crw-rw---- 1 root input 13, 34 1月   8 20:18 mouse2
    
    # Ubuntu 中,普通用户无法对设备文件进行读取或写入操作,因此需要加 sudo 执行
    # 当执行命令之后,移动鼠标或按下鼠标、松开鼠标都会在终端打印出相应的数据,证明 mouse2 是鼠标对应的设备文件
    $ sudo od -x /dev/input/mouse2
    0000000 c138 2881 8100 0028 2881 fd00 ff18 1800
    0000020 7ffd 0008 087f 3900 fe38 38fd fefe fd38
    0000040 38fd f7f7 f238 38f2 f2f3 ef38 38f0 f0f0
    0000060 ec38 38ef c7ae d238 38f3 ffe9 ed18 1800
    0000100 08eb eb18 180b 09f5 f618 180a 06fd fe18
    0000120 0808 2a00 0408 0813 1807 0d08 0823 3e1c
    0000140 0c08 0814 271d 1008 080f 090f 0608 0802
    0000160 0105 0408 0800 0003 0128 28fd fd02 0028
    0000200 28ff f703 0128 28fe f803 0128 28fd f802
    0000220 0128 28fa fe01 0028 28fe fd03 0128 28fe
    0000240 ff01 0128 28ff fe03 0108 0800 0001 0108
    0000260 0800 0001 0108 0800 0001 0108 0800 0003
    0000300 1208 0801 0001 0208 0800 0001 0408 0800
    0000320 0001 0208 0800 0005 0728 28ff fe0c 0528
    0000340 28fd fb0d 0928 28fa fd05 0328 28fc f806
    0000360 0828 28e5 f600 0128 28fa ed00 0028 38f3
    0000400 d5f6 fc38 38f3 f8fd fe38 38fa f6fb fb38
    0000420 38f1 fbfd ff38 38fc fcfe 0028 28ff ff00
    0000440 ff18 2800 ff00 0028 28ff ff00 ff38 28ff
    
1.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/mouse2", O_RDONLY);
    if (fd == -1) {
        perror("open error");
        exit(-1);
    }

    // 读文件
    memset(buf, 0, sizeof(buf));
    ret = read(fd, buf, sizeof(buf));
    if (ret < 0) {
        perror("read error");
        close(fd);
        exit(-1);
    }

    printf("成功读取<%d>个字节数据\n", ret);

    /* 关闭文件 */
    close(fd);

    exit(0);
}
$ gcc block.c -o block
# 执行程序没有立即结束,而是一直占用了终端没有输出信息,原因在于调用 read() 之后进入了阻塞状态,因为当前鼠标没有数据可读
# 如果此时移动鼠标、或者按下鼠标上的任何一个按键,阻塞会结束,read() 会成功读取到数据并返回
$ sudo ./block 
成功读取<3>个字节数据
$
1.1.2 非阻塞式 I/O 读取鼠标数据
...
fd = open("/dev/input/event3", O_RDONLY | O_NONBLOCK);
...
$ gcc block2.c -o block2
$ sudo ./block2 
# 执行程序后立马就结束了,并且调用 read() 返回错误
# 意思就是说资源暂时不可用;原因在于调用 read() 时,如果鼠标并没有移动或者被按下(没有发生输入事件),是没有数据可读,故而导致失败返回,这就是非阻塞 I/O
read error: Resource temporarily unavailable
1.1.3 轮训+非阻塞式 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/mouse2", 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);
        }
    }
}

1.2 阻塞 I/O 的优缺点

  • 当对文件进行读取操作时,如果文件当前无数据可读,那么阻塞式 I/O 会将调用者应用程序挂起、进入休眠阻塞状态,直到有数据可读时才会解除阻塞;而对于非阻塞 I/O,应用程序不会被挂起,而是会立即返回,它要么一直轮训等待,直到数据可读,要么直接放弃
  • 优点:能提升 CPU 的处理效率,当自身条件不满足时,进入阻塞状态并交出 CPU 资源,将 CPU 资源让给别人使用,而非阻塞式则是抓紧利用 CPU 资源(如不断地去轮训),这样就会导致该程序占用非常高的 CPU 使用率

1.3 使用非阻塞 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>
    
    #define MOUSE "/dev/input/mouse2"
    
    int main(void) {
        char buf[100];
        int fd, ret;
    
        /* 打开鼠标设备文件 */
        fd = open(MOUSE, O_RDONLY);
        if (fd == -1) {
            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);
    } 
    
    $ gcc test.c -o test
    # 需要先动鼠标在按键盘(按下键盘上的按键、按完之后按下回车),这样才能既成功读取鼠标、又成功读取键盘
    $ sudo ./test
    鼠标: 成功读取<3>个字节数据
    hunan
    键盘: 成功读取<6>个字节数据
    

因为 read() 此时是阻塞式读取,先读取了鼠标,没有数据可读将会一直被阻塞,后面的读取键盘将得不到执行,这就是阻塞式 I/O 的一个困境:无法实现并发读取(同时读取),主要原因在于阻塞

  • 方法一:使用多线程,一个线程读取鼠标、另一个线程读取键盘
  • 方法二:者创建一个子进程,父进程读取鼠标、子进程读取键盘
  • 方法三:使用非阻塞式 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>
    
    #define MOUSE "/dev/input/mouse2"
    
    int main(void) {
        char buf[100];
        int fd, ret, flag;
    
        /* 非阻塞方式打开鼠标设备文件 */
        fd = open(MOUSE, O_RDONLY | O_NONBLOCK);
        if (fd == -1) {
            perror("open error");
            exit(-1);
        }   
    
        // 标准输入文件描述符(键盘)是从其父进程进程而来,并不是在程序中调用 open() 打开得到
        /* 将键盘设置为非阻塞方式 */
        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 (ret > 0) {
                printf("鼠标: 成功读取<%d>个字节数据\n", ret);
            }
    
            /* 读键盘 */
            ret = read(0, buf, sizeof(buf));
            if (ret > 0) {
                printf("键盘: 成功读取<%d>个字节数据\n", ret);
            }
        }
    
        /* 关闭文件 */
        close(fd);
    
        exit(0);
    }
    
    $ gcc test2.c -o test2
    # 不管是先动鼠标还是先按键盘都可以成功读取到相应数据
    $ sudo ./test2
    hunan  # 键盘输入
    键盘: 成功读取<6>个字节数据
    鼠标: 成功读取<3>个字节数据
    鼠标: 成功读取<3>个字节数据
    鼠标: 成功读取<3>个字节数据
    changsha  # 键盘输入
    键盘: 成功读取<9>个字节数据
    鼠标: 成功读取<3>个字节数据
    鼠标: 成功读取<3>个字节数据
    鼠标: 成功读取<3>个字节数据
    

2. I/O 多路复用

虽然使用非阻塞式 I/O 解决了阻塞式 I/O 情况下并发读取文件所出现的问题,但使得程序的 CPU 占用率特别高,为了解决这个问题,就要用到 I/O 多路复用方法

2.1 定义

  • I/O 多路复用通过一种机制,可以监视多个文件描述符,一旦某个文件描述符(也就是某个文件)可以执行 I/O 操作时,能够通知应用程序进行相应的读写操作
    • I/O 多路复用技术是为了解决:在并发式 I/O 场景中使进程或线程不阻塞于某个特定的 I/O 系统调用
    • I/O 多路复用一般用于并发非阻塞 I/O(多路非阻塞 I/O),如:既要读取鼠标、又要读取键盘
    • I/O 多路复用特征:外部阻塞式,内部监视多路 I/O
    • 采用两个功能几乎相同的系统调用来执行 I/O 多路复用操作,分别是 select() 和 poll()

2.2 select() 函数

  • 调用 select() 函数将阻塞直到有以下事情发生

    • readfds、writefds 或 exceptfds 指定的文件描述符中至少有一个成为就绪态
    • 该调用被信号处理函数中断
    • 参数 timeout 中指定的时间上限已经超时
    #include <sys/select.h>
    
    // nfds:在 readfds、writefds 和 exceptfds 3 个描述符集中找出最大描述符编号值,然后加 1
    // timeout:可用于设定 select() 阻塞的时间上限,控制 select 的阻塞行为
        // 1、可将 timeout 设置为 NULL,表示 select() 将会一直阻塞,直到某一个或多个文件描述符成为就绪态
        // 2、也可将其指向一个 struct timeval 结构体对象,如果结构体对象中的两个成员变量都为 0
        // 那么此时 select() 函数不会阻塞,只是简单地轮训指定的文件描述符集合,看其中是否有就绪的文件描述符并立刻返回
        // 否则,参数 timeout 将为 select() 指定一个等待(阻塞)时间的上限值
        // 如果在阻塞期间内,文件描述符集合中的某一个或多个文件描述符成为就绪态,将会结束阻塞并返回
        // 如果超过了阻塞时间的上限值,select() 函数将会返回
    int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
    
  • fd_set 数据类型(以位掩码的形式来实现)是一个文件描述符的集合体

    • readfds 是用来检测读是否就绪(是否可读)的文件描述符集合
    • writefds 是用来检测写是否就绪(是否可写)的文件描述符集合
    • exceptfds 是用来检测异常情况是否发生的文件描述符集合
    • 如果上述三个参数都设置为 NULL,则可以将 select() 当做为一个类似于 sleep() 休眠的函数来使用,通过 select() 函数的最后一个参数 timeout 来设置休眠时间

    位掩码

  • 所有关于文件描述符集合的操作都是通过四个宏完成:FD_CLR()、FD_ISSET()、FD_SET()、FD_ZERO()

    #include <sys/select.h>
    
    void FD_CLR(int fd, fd_set *set);   // 将文件描述符 fd 从参数 set 所指向的集合中移除
    int FD_ISSET(int fd, fd_set *set);  // 如果 fd 是参数 set 所指向集合中的成员,则 FD_ISSET() 返回 true,否则返回 false
    void FD_SET(int fd, fd_set *set);   // 将 fd 添加到参数 set 所指向的集合中
    void FD_ZERO(fd_set *set);          // 将参数 set 所指向的集合初始化为空
    // 文件描述符集合有一个最大容量限制,由常量 FD_SETSIZE 决定,Linux 系统该常量的值为 1024
    // 如果要在循环中重复调用 select(),必须保证每次都要重新初始化并设置 readfds、writefds、exceptfds 这些集合
    
    • 用法示例
    fd_set fset;       // 定义文件描述符集合
    
    FD_ZERO(&fset);    // 将集合初始化为空
    
    FD_SET(3, &fset);  // 向集合中添加文件描述符 3
    FD_SET(4, &fset);  // 向集合中添加文件描述符 4
    FD_SET(5, &fset);  // 向集合中添加文件描述符 5
    
  • 返回值

    • 返回 -1:表示有错误发生,并且会设置 errno
      • 可能的错误码包括 EBADF、EINTR、EINVAL、EINVAL 以及 ENOMEM
      • EBADF 表示 readfds、writefds 或 exceptfds 中有一个文件描述符是非法的
      • EINTR 表示该函数被信号处理函数中断了
    • 返回 0:表示在任何文件描述符成为就绪态之前 select() 调用已经超时
      • 这种情况下,readfds,writefds 以及 exceptfds 所指向的文件描述符集合都会被清空
    • 返回一个正整数:表示有一个或多个文件描述符已达到就绪态
      • 返回值表示处于就绪态的文件描述符的个数,在这种情况下,每个返回的文件描述符集合都需要检查,通过 FD_ISSET() 宏进行检查,以此找出发生的 I/O 事件是什么
      • 如果同一个文件描述符在 readfds,writefds 以及 exceptfds 中同时被指定,且它多于多个 I/O 事件都处于就绪态的话,select() 返回三个集合中被标记为就绪态的文件描述符的总数
  • 示例:使用 select 实现同时读取键盘和鼠标(非阻塞 I/O 方式)

    #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/mouse2"
    
    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 (ret < 0) {
                perror("select error");
                goto out;
            } else if (ret == 0) {
                fprintf(stderr, "select timeout.\n");
                continue;
            }
    
            /* 检查键盘是否为就绪态 */
            if (FD_ISSET(0, &rdfds)) {
                ret = read(0, buf, sizeof(buf));
                if (ret > 0) {
                    printf("键盘: 成功读取<%d>个字节数据\n", ret);
                }
            }
    
            /* 检查鼠标是否为就绪态 */
            if (FD_ISSET(fd, &rdfds)) {
                ret = read(fd, buf, sizeof(buf));
                if (ret > 0) {
                    printf("鼠标: 成功读取<%d>个字节数据\n", ret);
                }
            }
        }
    
    out:
        /* 关闭文件 */
        close(fd);
    
        exit(ret);
    }
    

2.3 poll() 函数

  • 在 poll() 函数中,需要构造一个 struct pollfd 类型的数组,每个数组元素指定一个文件描述符以及对该文件描述符所关心的条件(数据可读、可写或异常情况)

    #include <poll.h>
    
    int poll(struct pollfd *fds, nfds_t nfds, int timeout);
    
    // 调用者初始化 events 来指定需要为文件描述符 fd 做检查的事件
    // 当 poll() 函数返回时,revents 变量由 poll() 函数内部进行设置,用于说明文件描述符 fd 发生了哪些事件
    struct pollfd {
        int fd;         /* 文件描述符 */
        short events;   /* 位掩码:requested events */
        short revents;  /* 位掩码:returned events */
    };
    
    • fds:指向一个 struct pollfd 类型的数组,数组中的每个元素都会指定一个文件描述符以及对该文件描述符所关心的条件
    • nfds:指定了 fds 数组中的元素个数,数据类型 nfds_t 实际为无符号整形
    • timeout:该参数与 select() 函数的 timeout 参数相似,用于决定 poll() 函数的阻塞行为
      • timeout == -1:poll() 会一直阻塞(与 select()函数的 timeout 等于 NULL 相同),直到 fds 数组中列出的文件描述符有一个达到就绪态或者捕获到一个信号时返回
      • timeout == 0:poll()不会阻塞,只是执行一次检查看看哪个文件描述符处于就绪态
      • timeout > 0:表示设置 poll() 函数阻塞时间的上限值,意味着 poll() 函数最多阻塞 timeout 毫秒,直到 fds 数组中列出的文件描述符有一个达到就绪态或者捕获到一个信号为止
  • poll 的 events 和 revents 标志

    • 一般用的最多的还是 POLLIN 和 POLLOUT
      在这里插入图片描述
  • 返回值

    • 返回 -1 表示有错误发生,并且会设置 errno
    • 返回 0 表示该调用在任意一个文件描述符成为就绪态之前就超时了
    • 返回一个正整数表示有一个或多个文件描述符处于就绪态,返回值表示 fds 数组中返回的 revents 变量不为 0 的 struct pollfd 对象的数量
  • 示例:使用 poll 实现同时读取鼠标和键盘(非阻塞 I/O 方式)

    #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 (ret < 0) {
                perror("poll error");
                goto out;
            }
            else if (ret == 0) {
                fprintf(stderr, "poll timeout.\n");
                continue;
            }
    
            /* 检查键盘是否为就绪态 */
            if (fds[0].revents & POLLIN) {
                ret = read(0, buf, sizeof(buf));
                if (ret > 0)
                    printf("键盘: 成功读取<%d>个字节数据\n", ret);
            }
    
            /* 检查鼠标是否为就绪态 */
            if (fds[1].revents & POLLIN) {
                ret = read(fd, buf, sizeof(buf));
                if (ret > 0)
                    printf("鼠标: 成功读取<%d>个字节数据\n", ret);
            }
        }
    
    out:
        /* 关闭文件 */
        close(fd);
    
        exit(ret);
    }
    

使用 select() 或 poll() 时需要注意一个问题

  • 当监测到某一个或多个文件描述符成为就绪态(可读或写)时,需要执行相应的 I/O 操作以清除该状态,否则该状态会一直存在,那么下一次调用 select() 或 poll() 时,文件描述符已经处于就绪态了,将直接返回

3. 异步 I/O

  • 在 I/O 多路复用中,进程通过系统调用 select() 或 poll() 来主动查询文件描述符上是否可以执行 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
    • 以上步骤完成之后,进程就可以执行其它任务了,当 I/O 操作就绪时,内核会向进程发送一个 SIGIO 信号,当进程接收到信号时,会执行预先注册好的信号处理函数,就可以在信号处理函数中进行 I/O 操作

3.1 O_ASYNC 标志

  • O_ASYNC 标志可用于使能文件描述符的异步 I/O 事件,当文件描述符可执行 I/O 操作时,内核会向异步 I/O 事件的接收进程发送 SIGIO 信号(默认情况下)
    • 在调用 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
    

3.2 设置异步 I/O 事件的接收进程

  • 为文件描述符设置异步 I/O 事件的接收进程,也就是设置异步 I/O 的所有者。同样也是通过 fcntl() 函数进行设置,操作命令 cmd 设置为 F_SETOWN,第三个参数传入接收进程的进程 ID(PID),通常将调用进程的 PID 传入
    fcntl(fd, F_SETOWN, getpid());
    

3.3 注册 SIGIO 信号的处理函数

  • 通过 signal() 或 sigaction() 函数为 SIGIO 信号注册一个信号处理函数,当进程接收到内核发送过来的 SIGIO 信号时,会执行该处理函数

3.4 示例:以异步 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/mouse2"

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 (ret > 0) {
        printf("鼠标: 成功读取<%d>个字节数据\n", ret);
    }

    loops--;
    if (loops <= 0) {
        close(fd);
        exit(0);
    }
}

int main(void) {
    int flag;

    /* 打开鼠标设备文件<使能非阻塞 I/O> */
    fd = open(MOUSE, O_RDONLY | O_NONBLOCK);
    if (fd == -1) {
        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);
    }
}

4. 优化异步 I/O

  • 在一个需要同时检查大量文件描述符(如数千个)的应用程序中,例如某种类型的网络服务端程序,与 select() 和 poll() 相比,异步 I/O 能够提供显著的性能优势

    • 原因:对于异步 I/O,内核可以 “记住” 要检查的文件描述符,且仅当这些文件描述符上可执行 I/O 操作时,内核才会向应用程序发送信号
  • 对于 select() 或 poll() 函数来说,内部实现原理其实是通过轮训的方式来检查多个文件描述符是否可执行 I/O 操作,所以,当需要检查的文件描述符数量较多时,随之也将会消耗大量的 CPU 资源来实现轮训检查操作

    • 当需要检查的文件描述符并不是很多时,使用 select() 或 poll() 是一种非常不错的方案
    • 当需要检查大量文件描述符时,可以使用 epoll 解决 select()或 poll()性能低的问题
  • 默认异步 I/O 的缺陷

    • 默认的异步 I/O 通知信号 SIGIO 是非排队信号
      • SIGIO 信号是标准信号(非实时信号、不可靠信号),不支持信号排队机制
      • 如:当前正在执行 SIGIO 信号的处理函数,此时内核又发送多次 SIGIO 信号给进程,这些信号将会被阻塞,只有当信号处理函数执行完毕之后才会传递给进程,并且只能传递一次,而其它后续的信号都会丢失
    • 无法得知文件描述符发生了什么事件
      • 在 3.3 小节代码中的信号处理函数 sigio_handler(),直接调用 read() 函数读取鼠标,而未判断文件描述符是否处于可读就绪态

4.1 使用实时信号替换默认信号 SIGIO

  • 使用 fcntl() 函数进行设置,调用函数时将操作命令 cmd 参数设置为 F_SETSIG,第三个参数 arg 指定一个实时信号编号,表示将该信号作为异步 I/O 通知信号
    // 指定了 SIGRTMIN 实时信号作为文件描述符 fd 的异步 I/O 通知信号
    // 如果第三个参数 arg 设置为 0,则表示指定 SIGIO 信号作为异步 I/O 通知信号
    fcntl(fd, F_SETSIG, SIGRTMIN);
    

4.2 使用 sigaction() 函数注册信号处理函数

  • 在应用程序中需要为实时信号注册信号处理函数,使用 sigaction 函数进行注册,并为 sa_flags 参数指定 SA_SIGINFO,表示使用 sa_sigaction 指向的函数作为信号处理函数,而不使用 sa_handler 指向的函数。因为 sa_sigaction 指向的函数作为信号处理函数提供了更多的参数,可以获取到更多信息

  • 对于异步 I/O 事件而言,传递给信号处理函数的 siginfo_t 结构体中与之相关的字段如下

    • si_signo:引发处理函数被调用的信号。这个值与信号处理函数的第一个参数一致
    • si_fd:表示发生异步 I/O 事件的文件描述符
    • si_code:表示文件描述符 si_fd 发生了什么事件,读就绪态、写就绪态或者是异常事件等
    • si_band:是一个位掩码,其中包含的值与系统调用 poll() 中返回的 revents 字段中的值相同
      在这里插入图片描述

4.3 示例:优化异步 I/O 方式读取鼠标

// 使用实时信号 + sigaction 优化异步 I/O
#define _GNU_SOURCE  // 需要定义了_GNU_SOURCE 宏之后才能使用 F_SETSIG

#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/mouse2"

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

5. 存储映射 I/O

在这里插入图片描述

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

5.1 mmap() 和 munmap() 函数

  • mmap() 用于告诉内核将一个给定的文件映射到进程地址空间中的一块内存区域中

    #include <sys/mman.h>
    
    void *mmap(void *addr, size_t length, int prot, int flags, int fd, off_t offset);
    
    • addr:用于指定映射到内存区域的起始地址
      • 通常将其设置为 NULL,这表示由系统选择该映射区的起始地址,这是最常见的设置方式
      • 如果参数 addr 不为 NULL,则表示由自己指定映射区的起始地址,此函数的返回值是该映射区的起始地址
    • length:指定映射长度,表示将文件中的多大部分映射到内存区域中
      • 以字节为单位,如:length=1024 * 4,表示将文件的 4K 字节大小映射到内存区域中
      • 参数 length 的值不能大于文件大小,即文件被映射的部分不能超出文件
    • offset:文件映射的偏移量
      • 通常将其设置为 0,表示从文件头部开始映射
      • 参数 offset 和参数 length 就确定了文件的起始位置和长度,将文件的这部分映射到内存区域中
    • fd:文件描述符,指定要映射到内存区域中的文件
    • prot:指定了映射区的保护要求,可取值如下
      • PROT_EXEC:映射区可执行
      • PROT_READ:映射区可读
      • PROT_WRITE:映射区可写
      • PROT_NONE:映射区不可访问

      对指定映射区的保护要求不能超过文件 open() 时的访问权限,如:文件是以只读权限方式打开的,那么对映射区的不能指定为 PROT_WRITE

    • flags:可影响映射区的多种属性,参数 flags 必须要指定以下两种标志之一
      • MAP_SHARED:此标志指定当对映射区写入数据时,数据会写入到文件中,也就是会将写入到映射区中的数据更新到文件中,并且允许其它进程共享
      • MAP_PRIVATE:此标志指定当对映射区写入数据时,会创建映射文件的一个私人副本,对映射区的任何操作都不会更新到文件中,仅仅只是对文件副本进行读写。
    • 返回值
      • 成功情况下,函数的返回值便是映射区的起始地址
      • 发生错误时,返回 (void *)-1,通常使用 MAP_FAILED 来表示,并且会设置 errno 来指示错误原因
    • 对于 mmap() 函数,参数 addr 和 offset 在不为 NULL 和 0 的情况下,addr 和 offset 的值通常被要求是系统页大小的整数倍,可通过 sysconf() 函数获取页大小
  • 通过 mmap() 将文件映射到进程地址空间中的一块内存区域中,当不再需要时,使用 munmap() 解除映射关系

    • 被解除映射的区域对应的大小也必须是系统页大小的整数倍,即使参数 length 并不等于系统页大小的整数倍
    • 当进程终止时也会自动解除映射(如果没有显式调用 munmap()),但调用 close() 关闭文件时并不会解除映射
    #include <sys/mman.h>
    
    int munmap(void *addr, size_t length);
    
  • 示例:使用存储映射 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 (argc != 3) {
            fprintf(stderr, "usage: %s <srcfile> <dstfile>\n", argv[0]);
            exit(-1);
        }
        
        /* 打开源文件 */
        srcfd = open(argv[1], O_RDONLY);
        if (srcfd == -1) {
            perror("open error");
            exit(-1);
        }
    
        /* 打开目标文件 */
        dstfd = open(argv[2], O_RDWR | O_CREAT | O_TRUNC, 0664);
        if (dstfd == -1) {
            perror("open error");
            ret = -1;
            goto out1;
        }
    
        /* 获取源文件的大小 */
        fstat(srcfd, &sbuf);
        
        /* 设置目标文件的大小 */
        ftruncate(dstfd, sbuf.st_size);
    
        /* 将源文件映射到内存区域中 */
        // 将参数 prot 指定为 PROT_READ,表示对它的映射区会进行读取操作
        srcaddr = mmap(NULL, sbuf.st_size, PROT_READ, MAP_SHARED, srcfd, 0);
        if (srcaddr == MAP_FAILED) {
            perror("mmap error");
            ret = -1;
            goto out2;
        }
    
        /* 将目标文件映射到内存区域中 */
        // 将参数 port 指定为 PROT_WRITE,表示对它的映射区会进行写入操作
        dstaddr = mmap(NULL, sbuf.st_size, PROT_WRITE, MAP_SHARED, dstfd, 0);
        if (dstaddr == MAP_FAILED) {
            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);
    }
    
    $ touch srcfile
    $ vi srcfile 
    $ cat srcfile 
    Hello World!
    Hunan normal university.
    
    $ gcc copy.c -o copy
    $ ./copy ./srcfile ./dstfile
    $ cat dstfile 
    Hello World!
    Hunan normal university.
    

5.2 mprotect() 函数

  • 使用系统调用 mprotect() 可以更改一个现有映射区的保护要求
    #include <sys/mman.h>
    
    // 参数 prot 的取值与 mmap() 函数一样,mprotect() 函数会将指定地址范围的保护要求更改为参数 prot 所指定的类型
    // 参数 addr 指定该地址范围的起始地址,addr 的值必须是系统页大小的整数倍
    // 参数 len 指定该地址范围的大小
    int mprotect(void *addr, size_t len, int prot);
    

5.3 msync() 函数

  • 调用 write() 写入到磁盘文件中的数据并不会立马写入磁盘,而是会先缓存在内核缓冲区中,所以就会出现 write() 操作与磁盘操作并不同步,也就是数据不同步
  • 对于存储 I/O 来说亦是如此,写入到文件映射区中的数据不会立马刷新至磁盘设备中,而是会在将数据写入到映射区之后的某个时刻将映射区中的数据写入磁盘中,所以会导致映射区中的内容与磁盘文件中的内容不同步
    • 可以调用 msync() 函数将映射区中的数据刷写、更新至磁盘文件中(同步操作)
    #include <sys/mman.h>
    
    int msync(void *addr, size_t length, int flags);
    
    • addr 和 length:分别指定了需同步的内存区域的起始地址和大小
    • flags:应指定为 MS_ASYNC 和 MS_SYNC 两个标志之一,除此之外,还可以根据需求选择是否指定 MS_INVALIDATE 标志,作为一个可选标志
      • MS_ASYNC:以异步方式进行同步操作。调用 msync() 函数并不会等待数据完全写入磁盘之后才返回
      • MS_SYNC:以同步方式进行同步操作。调用 msync() 函数需等待数据全部写入磁盘之后才返回
      • MS_INVALIDATE:可选标志,请求使同一文件的其它映射无效(以便可以用刚写入的新值更新它们)

5.4 普通 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 的实质其实是共享

    通过存储映射 I/O 将文件直接映射到应用程序地址空间中的一块内存区域中,也就是映射区;直接将磁盘文件直接与映射区关联起来,不用调用 read()、write()系统调用,直接对映射区进行读写操作即可操作磁盘上的文件,而磁盘文件中的数据也可反应到映射区中,这就是一种共享,可以认为映射区就是应用层与内核层之间的共享内存

    • 使用存储映射 I/O 减少了数据的复制操作,效率比普通 I/O 高
    • 如执行一个文件复制操作来说,对于普通 I/O 方式,首先需要将源文件中的数据读取出来存放在一个应用层缓冲区中,接着再将缓冲区中的数据写入到目标文件中,而对于存储映射 I/O 来说,由于源文件和目标文件都已映射到了应用层的内存区域中,所以直接操作映射区来实现文件复制
      在这里插入图片描述

在这里插入图片描述

  • 存储映射 I/O 的不足
    • 所映射的文件只能是固定大小,因为文件所映射的区域已经在调用 mmap() 函数时通过 length 参数指定
    • 文件映射的内存区域的大小必须是系统页大小的整数倍
    • 使用存储映射 I/O 在进行大数据量操作时比较有效,对于少量数据则使用普通 I/O 方式更方便

6. 文件锁

多个进程同时操作同一文件,很容易导致文件中的数据发生混乱,因为多个进程对文件进行 I/O 操作时,容易产生竞争状态,导致文件中的内容与预想的不一致

  • 进程有时需要确保只有它自己能够对某一文件进行 I/O 操作,在这段时间内不允许其它进程对该文件进行 I/O 操作
    • Linux 系统提供了文件锁机制来实现:文件锁是用于对共享资源的访问进行保护的机制,对文件上锁,来避免多个进程同时操作同一文件时产生竞争状态

6.1 文件锁的分类

  • 建议性锁

    • 本质上是一种协议,程序访问文件前先对文件上锁,上锁成功后再访问文件
    • 在文件没有上锁的情况下直接访问文件也可以实现,但这样的话建议性锁就没有起作用
  • 强制性锁

    • 如果进程对文件上了强制性锁,其它进程在没有获取到文件锁的情况下是无法对文件进行访问的
    • 原因:强制性锁会让内核检查每一个 I/O 操作(如 read()、write()),验证调用进程是否是该文件锁的拥有者,如果不是将无法访问文件
    • 采取强制性锁对性能的影响很大,每次进行读写操作都必须检查文件锁

6.2 flock() 函数加锁

  • 使用 flock() 函数可以对文件加锁或者解锁,但只能产生建议性锁

    • fd:文件描述符,指定需要加锁的文件
    • operation:指定了操作方式,可以设置为以下值的其中一个
      • LOCK_SH:在 fd 引用的文件上放置一把共享锁。所谓共享,指的便是多个进程可以拥有对同一个文件的共享锁,该共享锁可被多个进程同时拥有
      • LOCK_EX:在 fd 引用的文件上放置一把互斥锁。所谓互斥,指的便是互斥锁只能同时被一个进程所拥有
      • LOCK_UN:解除文件锁定状态,解锁、释放锁
      • LOCK_NB:表示以非阻塞方式获取锁。默认情况下,调用 flock() 无法获取到文件锁时会阻塞,直到其它进程释放锁为止,如果不想程序被阻塞,可以指定 LOCK_NB 标志,如果无法获取到锁应立刻返回(errno 设置为 EWOULDBLOCK),通常与 LOCK_SH 或 LOCK_EX 一起使用,通过位或运算符组合
    • 对于 flock(),同一个文件不会同时具有共享锁和互斥锁
    #include <sys/file.h>
    
    int flock(int fd, int operation);
    
  • 示例 1:使用 flock() 对文件加锁/解锁

    // test1.c
    #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 (sig != SIGINT) {
            return;
        }
    
        /* 解锁 */
        flock(fd, LOCK_UN);
        close(fd);
        printf("进程 1: 文件已解锁!\n");
    }
    
    int main(int argc, char *argv[]) {
        if (argc != 2) {
            fprintf(stderr, "usage: %s<file>\n", argv[0]);
            exit(-1);
        }
    
        /* 打开文件 */    
        fd = open(argv[1], O_WRONLY);
        if (fd == -1) {
            perror("open error");
            exit(-1);
        }
    
        /* 以非阻塞方式对文件加锁(互斥锁) */
        if (flock(fd, LOCK_EX | LOCK_NB)) {
            perror("进程 1: 文件加锁失败");
            exit(-1);
        }
    
        printf("进程 1: 文件加锁成功!\n");
    
        /* 为 SIGINT 信号注册处理函数 */
        // 当进程接收到 SIGINT 信号后会执行 sigint_handler() 函数
        // 在信号处理函数中对文件进行解锁,然后终止进程
        signal(SIGINT, sigint_handler);
    
        for (;;) {
            sleep(1);
        }
    }
    
  • 示例 2:未获取锁情况下读写文件

    // test2.c
    #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 (argc != 2) {
            fprintf(stderr, "usage: %s <file>\n", argv[0]);
            exit(-1);
        }
    
        /* 可读可写方式打开文件 */
        fd = open(argv[1], O_RDWR);
        if (fd == -1) {
            perror("open error");
            exit(-1);
        }
    
        /* 以非阻塞方式对文件加锁(互斥锁) */
        if (flock(fd, LOCK_EX | LOCK_NB) == -1) {
            perror("进程 2: 文件加锁失败");
        } else {
            printf("进程 2: 文件加锁成功!\n");
        }
    
        /* 写文件 */
        len = strlen(buf);
        if (write(fd, buf, len) < 0) {
            perror("write error");
            exit(-1);
        }
        printf("进程 2: 写入到文件的字符串<%s>\n", buf);
    
        /* 将文件读写位置移动到文件头 */
        if (lseek(fd, 0x0, SEEK_SET) < 0) {
            perror("lseek error");
            exit(-1);
        }
    
        /* 读文件 */
        memset(buf, 0x0, sizeof(buf)); //清理 buf
        if (read(fd, buf, len) < 0) {
            perror("read error");
            exit(-1);
        }
        printf("进程 2: 从文件读取的字符串<%s>\n", buf);
    
        /* 解锁、退出 */
        flock(fd, LOCK_UN);
        close(fd);
    
        exit(0);
    } 
    
    $ gcc test1.c -o test1
    $ gcc test2.c -o test2
    $ touch infile
    
    # 首先执行 test1 应用程序,将 infile 文件作为输入文件,并将其放置在后台运行
    $ ./test1 ./infile &
    [1] 3234
    进程 1: 文件加锁成功!
    
    # test1 会在后台运行,由 ps 命令可查看到其 pid 为 3234
    $ ps
      PID TTY          TIME CMD
     3068 pts/0    00:00:00 bash
     3234 pts/0    00:00:00 test1
     3235 pts/0    00:00:00 ps
    
    # 接着执行 test2 应用程序,传入相同的文件 infile
    # test2 进程对 infile 文件加锁失败,原因在于锁已经被 test1 进程所持有
    # 但是 test2 对文件的读写操作是没有问题的
    $ ./test2 ./infile 
    进程 2: 文件加锁失败: Resource temporarily unavailable
    进程 2: 写入到文件的字符串<Hello World!>
    进程 2: 从文件读取的字符串<Hello World!>
    
    # 接着向 test1 进程发送一个 SIGIO 信号(编号为 2),让其对文件 infile 解锁
    $ kill -2 3234
    进程 1: 文件已解锁!
    
    # 接着再执行一次 test2
    $ ./test2 ./infile
    # test2 成功对 infile 文件加锁,读写也是没有问题的
    $ ./test2 ./infile 
    进程 2: 文件加锁成功!
    进程 2: 写入到文件的字符串<Hello World!>
    进程 2: 从文件读取的字符串<Hello World!>
    

关于 flock() 的几条规则

  • 1. 同一进程对文件多次加锁不会导致死锁
    新加的锁会替换旧的锁,如:先调用 flock() 对文件加共享锁,然后再调用 flock() 对文件加互斥锁,最终文件锁会由共享锁替换为互斥锁
  • 2. 文件关闭时会自动解锁
    文件锁会在相应的文件描述符被关闭之后自动释放(当一个进程终止时,它所建立的锁将全部释放)
  • 3. 一个进程不可以对另一个进程持有的文件锁进行解锁
  • 4. 由 fork() 创建的子进程不会继承父进程所创建的锁
    若一个进程对文件加锁成功,然后该进程调用 fork() 创建了子进程,那么对父进程创建的锁而言,子进程被视为另一个进程,虽然子进程从父进程继承了其文件描述符,但不能继承文件锁,因为锁的作用就是阻止多个进程同时写同一个文件
  • 5. 当一个文件描述符被复制时(使用 dup()、dup2() 或 fcntl() F_DUPFD 操作)
    这些通过复制得到的文件描述符和源文件描述符都会引用同一个文件锁,使用这些文件描述符中的任何一个进行解锁都可以
flock(fd, LOCK_EX);      // 加锁
new_fd = dup(fd);
flock(new_fd, LOCK_UN);  // 解锁
// 如果不显示的调用一个解锁操作,只有当所有文件描述符都被关闭之后锁才会被释放
// 如果不调用 flock(new_fd, LOCK_UN) 进行解锁,只有当 fd 和 new_fd 都被关闭之后锁才会自动释放

6.3 fcntl() 函数加锁

  • fcntl() 实现文件锁功能与 flock() 的区别

    • flock() 仅支持对整个文件进行加锁/ 解锁;而 fcntl() 可以对文件的某个区域(某部分内容)进行加锁/解锁 ,可以精确到某一个字节数据
    • flock() 仅支持建议性锁类型,而 fcntl() 可支持建议性锁和强制性锁两种类型,但一般不建议使用强制性锁
    #include <unistd.h>
    #include <fcntl.h>
    
    // 与锁相关的 cmd 为 F_SETLK、F_SETLKW、F_GETLK,第三个参数 flockptr 是一个 struct flock 结构体指针
    int fcntl(int fd, int cmd, ... /* struct flock *flockptr */ );
    
  • struct flock 结构体

    struct flock {
        ...
        /* 锁类型,可设置为:F_RDLCK 表示共享性质的读锁,F_WRLCK 表示独占性质的写锁,F_UNLCK 表示解锁一个区域 */
        short l_type;   
        /* l_whence 和 l_start:这两个变量用于指定要加锁或解锁区域的起始字节偏移量 */
        short l_whence;  /* 插入 l_start 的位置: SEEK_SET, SEEK_CUR, SEEK_END */
        off_t l_start;   /* Starting offset for lock */
    
        off_t l_len;     /* 需要加锁或解锁区域的字节长度 */
        // 一个 pid,指向一个进程,表示该进程持有的锁能阻塞当前进程,当 cmd=F_GETLK 时有效
        pid_t l_pid;
        ...
    };
    
  • 两种类型的锁:F_RDLCK(共享性读锁)和 F_WRLCK(独占性写锁)

    • 任意多个进程在一个给定的字节上可以有一把共享读锁,但在一个给定字节上只能有一个进程有一把独占写锁
    • 当对文件的某一区域加读锁时,调用进程必须对该文件有读权限:如 open() 时 flags 参数指定了 O_RDONLY 或 O_RDWR
    • 当对文件的某一区域加写锁时,调用进程必须对该文件有写权限:如 open() 时 flags 参数指定了 O_WRONLY 或 O_RDWR
      在这里插入图片描述
  • 文件锁相关的三个 cmd:F_SETLK、F_SETLKW 和 F_GETLK

    • F_GETLK
      • 一般很少用,通常用于测试,测试调用进程对文件加一把由参数 flockptr 指向的 struct flock 对象所描述的锁是否会加锁成功
    • F_SETLK
      • 对文件添加由 flockptr 指向的 struct flock 对象所描述的锁
      • 如试图对文件的某一区域加读锁(l_type 等于 F_RDLCK)或写锁(l_type 等于 F_WRLCK)
      • 也可用于清除由 flockptr 指向的 struct flock 对象所描述的锁(l_type 等于 F_UNLCK)
    • F_SETLKW
      • 此命令是 F_SETLK 的阻塞版本,如果所请求的读锁或写锁因另一个进程当前已经对所请求区域的某部分进行了加锁,而导致请求失败,那么调用进程将会进入阻塞状态
      • 只有当请求的锁可用时,进程才会被唤醒
  • 示例 1:使用 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;            // 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);
    }
    
    $ gcc fcntl.c -o fcntl
    $ touch testfile
    $ ./fcntl ./testfile 
    对文件加锁成功!
    
  • 示例 2:使用 fcntl() 对文件不同区域进行加锁

    • 一个进程可以对同一个文件的不同区域进行加锁,当然这两个区域不能有重叠的情况
    • 如果两个区域出现了重叠,如 100~200 字节区间和 150~250 字节区间,150~200 就是它们的重叠部分,一个进程对同一文件的相同区域不可能同时加两把锁,新加的锁会把旧的锁替换掉
    #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);
    }
    
  • 示例 3:读锁的共享性测试

    #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);
    }
    
    # 程序加读锁之后会进入死循环,进程一直在运行并持有读锁
    # 多个进程对同一文件的相同区域都可以加读锁,说明读锁是共享性的
    $ ./read ./testfile &
    [1] 2771
    加读锁成功!
    $ ./read ./testfile &
    [2] 2773
    加读锁成功!
    $ ./read ./testfile &
    [3] 2774
    加读锁成功!
    ...
    
  • 示例 4:写锁的独占性测试

    #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);
    }
    
    # 第一次启动的进程对文件加写锁之后,后面再启动进程对同一文件的相同区域加写锁都会失败,因此写锁是独占性的
    $ ./write ./testfile2 &
    [5] 2812
    加写锁成功!
    $ ./write ./testfile2 &
    [6] 2813
    加写锁失败: Resource temporarily unavailable
    ...
    

使用 fcntl() 创建锁的几条规则

  • 文件关闭的时候,会自动解锁
  • 一个进程不可以对另一个进程持有的文件锁进行解锁
  • 由 fork() 创建的子进程不会继承父进程所创建的锁
  • 当一个文件描述符被复制时(譬如使用 dup()、dup2()或 fcntl()F_DUPFD 操作),这些通过复制得到的文件描述符和源文件描述符都会引用同一个文件锁,使用这些文件描述符中的任何一个进行解锁都可以

6.4 lockf() 函数加锁

  • lockf() 函数是一个库函数,其内部基于 fcntl() 实现,所以 lockf() 是对 fcntl 锁的一种封装
文章来源:https://blog.csdn.net/qq_42994487/article/details/135572126
本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。