• 原子操作 与 竞争冒险



    前言

    Linux 是一个多任务、多进程操作系统,系统中往往运行着多个不同的进程、任务,多个不同的进程就
    有可能对同一个文件进行 IO 操作,此时该文件便是它们的共享资源,它们共同操作着同一份文件;操作系统级编程不同于大家以前接触的裸机编程,裸机程序中不存在进程、多任务这种概念,而在 Linux 系统中,我们必须要留意到多进程环境下可能会导致的竞争冒险


    一、竞争冒险简介

    竞争冒险不但存在于 Linux 应用层、也存在于 Linux 内核驱动层。

    假设有两个独立的进程 A 和进程 B 都对同一个文件进行追加写操作(也就是在文件末尾写入数据),
    每一个进程都调用了 open 函数打开了该文件,但未使用 O_APPEND 标志,每个进程都有它自己的进程控制块 PCB,有自己的文件表(意味着有自己独立的读写位置偏移量),但是共享同一个 inode 节点(也就是对应同一个文件)。假定此时进程 A 处于运行状态,B 未处于等待运行状态,进程 A 调用了 lseek 函数,它将进程 A 的该文件当前位置偏移量设置为 1500 字节处(假设这里是文件末尾),刚好此时进程 A 的时间片耗尽,然后内核切换到了进程 B,进程 B 执行 lseek 函数,也将其对该文件的当前位置偏移量设置为 1500 个字节处(文件末尾)。然后进程 B 调用 write 函数,写入了 100 个字节数据,那么此时在进程 B 中,该文件的当前位置偏移量已经移动到了 1600 字节处。B 进程时间片耗尽,内核又切换到了进程 A,使进程 A 恢复运行,当进程 A 调用 write 函数时,是从进程 A 的该文件当前位置偏移量(1500 字节处)开始写入,此时文件 1500 字节处已经不再是文件末尾了,如果还从 1500字节处写入就会覆盖进程 B 刚才写入到该文件中的数据。

    其上述假设工作流程图如下图所示:
    在这里插入图片描述
    以上所描述的这样一种情形就属于竞争状态(也成为竞争冒险),操作共享资源的两个进程(或线程),其操作之后的所得到的结果往往是不可预期的,因为每个进程(或线程)去操作文件的顺序是不可预期的,即这些进程获得 CPU 使用权的先后顺序是不可预期的,完全由操作系统调配,这就是所谓的竞争状态。

    二、原子操作

    在上一节介绍 open 函数的时候就提到过“原子操作”这个概念了,同样在 Linux 驱动编程中,也有这个概念,从上一小节给大家提到的示例中可知,上述的问题出在逻辑操作“先定位到文件末尾,然后再写”,它使用了两个分开的函数调用,首先使用 lseek 函数将文件当前位置偏移量移动到文件末尾、然后在使用 write函数将数据写入到文件。

    所谓原子操作,是有多步操作组成的一个操作,原子操作要么一步也不执行,一旦执行,必须要执行完所有步骤,不可能只执行所有步骤中的一个子集。

    (1)O_APPEND 实现原子操作
    在上一小节给大家提到的示例中,进程 A 和进程 B 都对同一个文件进行追加写操作,导致进程 A 写入
    的数据覆盖了进程 B 写入的数据,解决办法就是将“先定位到文件末尾,然后写”这两个步骤组成一个原子操作即可,那如何使其变成一个原子操作呢?

    答案就是 O_APPEND 标志。

    O_APPEND 的一个非常重要的作用,那就是实现原子操作。当 open 函数的 flags 参数中包含了 O_APPEND 标志,每次执行 write 写入操作时都会将文件当前写位置偏移量移动到文件末尾,然后再写入数据,这里“移动当前写位置偏移量到文件末尾、写入数据”这两个操作步骤就组成了一个原子操作,加入 O_APPEND 标志后,不管怎么写入数据都会是从文件末尾写,这样就不会导致出现“进程 A 写入的数据覆盖了进程 B 写入的数据”这种情况了。

    (2)pread()和 pwrite()
    pread()和 pwrite()都是系统调用,与 read()、write()函数的作用一样,用于读取和写入数据。区别在于,pread()和 pwrite()可用于实现原子操作,调用 pread 函数或 pwrite 函数可传入一个位置偏移量 offset 参数,用于指定文件当前读或写的位置偏移量,所以调用 pread 相当于调用 lseek 后再调用 read;同理,调用 pwrite相当于调用 lseek 后再调用 write。所以可知,使用 pread 或 pwrite 函数不需要使用 lseek 来调整当前位置偏移量,并会将“移动当前位置偏移量、读或写”这两步操作组成一个原子操作。

    pread、pwrite 函数原型如下所示(可通过"man 2 pread"或"man 2 pwrite"命令来查看):

    #include 
    ssize_t pread(int fd, void *buf, size_t count, off_t offset);
    ssize_t pwrite(int fd, const void *buf, size_t count, off_t offset);
    
    • 1
    • 2
    • 3

    首先调用这两个函数需要包含头文件
    函数参数和返回值含义如下:
    fd、buf、count 参数与 read 或 write 函数意义相同。
    offset:表示当前需要进行读或写的位置偏移量。
    返回值:返回值与 read、write 函数返回值意义一样。

    虽然 pread(或 pwrite)函数相当于 lseek 与 pread(或 pwrite)函数的集合,但还是有下列区别:
    ⚫ 调用 pread 函数时,无法中断其定位和读操作(也就是原子操作);
    ⚫ 不更新文件表中的当前位置偏移量。

    关于第二点可以编写一个简单地代码进行测试,测试代码如下所示:

    #include 
    #include 
    #include 
    #include 
    #include 
    #include 
    
    int main(void) 
    {
    	 unsigned char buffer[100];
    	 int fd;
    	 int ret;
    	 
    	 /* 打开文件 test_file */
    	 fd = open("./test_file", O_RDWR);
    	 if (-1 == fd) 
    	 {
    		 perror("open error");
    		 exit(-1);
    	 }
    	 
    	 /* 使用 pread 函数读取数据(从偏移文件头 1024 字节处开始读取) */
    	 ret = pread(fd, buffer, sizeof(buffer), 1024);
    	 if (-1 == ret) 
    	 {
    		 perror("pread error");
    		 goto err;
    	 }
    	 
    	 /* 获取当前位置偏移量 */
    	 ret = lseek(fd, 0, SEEK_CUR);
    	 if (-1 == ret) 
    	 {
    		 perror("lseek error");
    		 goto err;
    	 }
    	 printf("Current Offset: %d\n", ret);
    	 ret = 0;
    	 
    	err:
    	 /* 关闭文件 */
    	 close(fd);
    	 exit(ret);
    }
    
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45

    在当前目录下存在一个文件 test_file,上述代码中会打开 test_file 文件,然后直接使用 pread 函数读取100 个字节数据,从偏移文件头部 1024 字节处,读取完成之后再使用 lseek 函数获取到文件当前位置偏移量,并将其打印出来。假如 pread 函数会改变文件表中记录的当前位置偏移量,则打印出来的数据应该是1024 + 100 = 1124;如果不会改变文件表中记录的当前位置偏移量,则打印出来的数据应该是 0,接下来编译代码测试:
    在这里插入图片描述
    从上图中可知,打印出来的数据为 0,pread 函数确实不会改变文件表中记录的当前位置偏移量;同理,pwrite 函数也是如此,把 pread 换成 pwrite 函数再次进行测试,打印出来的数据依然是 0。
    如果把 pread 函数换成 read(或 write)函数,那么打印出来的数据就是 100 了,因为读取了 100 个字节数据,相应的当前位置偏移量会向后移动 100 个字节。

    (3)创建一个文件
    前面介绍 open 函数的 O_EXCL 标志的时候,也提到了原子操作,其中:O_EXCL 可以用于测试一个文件是否存在,如果不存在则创建此文件,如果存在则返回错误,这使得测试和创建两者成为一个原子操作。

    创建文件中存在着的一个竞争状态。
    假设有这么一个情况:进程 A 和进程 B 都要去打开同一个文件、并且此文件还不存在。进程 A 当前正
    在运行状态、进程 B 处于等待状态,进程 A 首先调用 open(“./file”, O_RDWR)函数尝试去打开文件,结果返回错误,也就是调用 open 失败;接着进程 A 时间片耗尽、进程 B 运行,同样进程 B 调用 open(“./file”, O_RDWR)尝试打开文件,结果也失败,接着进程 B 再次调用 open(“./file”, O_RDWR | O_CREAT, …)创建此文件,这一次 open 执行成功,文件创建成功;接着进程 B 时间片耗尽、进程 A 继续运行,进程 A 也调用open(“./file”, O_RDWR | O_CREAT, …)创建文件,函数执行成功,如下图所示:
    在这里插入图片描述
    从上面的示例可知,进程 A 和进程 B 都会创建出同一个文件,同一个文件被创建两次这是不允许的,
    那如何规避这样的问题呢?那就是通过使用 O_EXCL 标志,当 open 函数中同时指定了 O_EXCL 和
    O_CREAT 标志,如果要打开的文件已经存在,则 open 返回错误;如果指定的文件不存在,则创建这个文件,这里就提供了一种机制,保证进程是打开文件的创建者,将“判断文件是否存在、创建文件”这两个步骤合成为一个原子操作,有了原子操作,就保证不会出现上图中所示的情况。

  • 相关阅读:
    【多式联运】基于帝国企鹅算法+遗传算法+粒子群算法求解不确定多式联运路径优化问题【含Matlab源码 2073期】
    手机照片备份方案Immich
    基于随机油漆优化器 (MOSPO)求解多目标优化问题附matlab代码
    基础课6——开放领域对话系统架构
    基于Python的接口自动化-JSON模块的操作
    指针与数组
    Python魔法之旅-魔法方法(08)
    1905. 统计子岛屿
    开学季ipad手写笔哪款好?平价电容笔牌子排行
    试过了,ChatGPT确实不用注册就可以使用了!
  • 原文地址:https://blog.csdn.net/Dustinthewine/article/details/126449131