OpenMP 原子指令设计与实现

news2024/9/27 5:47:41

OpenMP 原子指令设计与实现

前言

在本篇文章当中主要与大家分享一下 openmp 当中的原子指令 atomic,分析 #pragma omp atomic 在背后究竟做了什么,编译器是如何处理这条指令的。

为什么需要原子指令

加入现在有两个线程分别执行在 CPU0 和 CPU1,如果这两个线程都要对同一个共享变量进行更新操作,就会产生竞争条件。如果没有保护机制来避免这种竞争,可能会导致结果错误或者程序崩溃。原子指令就是解决这个问题的一种解决方案,它能够保证操作的原子性,即操作不会被打断或者更改。这样就能保证在多线程环境下更新共享变量的正确性。

比如在下面的图当中,两个线程分别在 CPU0 和 CPU1 执行 data++ 语句,如果目前主存当中的 data = 1 ,然后按照图中的顺序去执行,那么主存当中的 data 的最终值等于 2 ,但是这并不是我们想要的结果,因为有两次加法操作我们希望最终在内存当中的 data 的值等于 3 ,那么有什么方法能够保证一个线程在执行 data++ 操作的时候下面的三步操作是原子的嘛(不可以分割):

  • Load data : 从主存当中将 data 加载到 cpu 的缓存。
  • data++ : 执行 data + 1 操作。
  • Store data : 将 data 的值写回主存。

事实上硬件就给我们提供了这种机制,比如 x86 的 lock 指令,在这里我们先不去讨论这一点,我们将在后文当中对此进行仔细的分析。

OpenMP 原子指令

在 openmp 当中 #pragma omp atomic 的表达式格式如下所示:

#pragma omp atomic
表达式;

其中表达式可以是一下几种形式:

x binop = 表达式;
x++;
x--;
++x;
--x;

二元运算符 binop 为++, --, +, -, *, /, &, ^, | , >>, <<或 || ,x 是基本数据类型 int,short,long,float 等数据类型。

我们现在来使用一个例子熟悉一下上面锁谈到的语法:



#include <stdio.h>
#include <omp.h>

int main()
{
  int data = 1;
#pragma omp parallel num_threads(4) shared(data) default(none)
  {
#pragma omp atomic
    data += data * 2;
  }
  printf("data = %d\n", data);
  return 0;
}

上面的程序最终的输出结果如下:

data = 81

上面的 data += data * 2 ,相当于每次操作将 data 的值扩大三倍,因此最终的结果就是 81 。

原子操作和锁的区别

OpenMP 中的 atomic 指令允许执行无锁操作,而不会影响其他线程的并行执行。这是通过在硬件层面上实现原子性完成的。锁则是通过软件来实现的,它阻塞了其他线程对共享资源的访问。

在选择使用 atomic 或锁时,应该考虑操作的复杂性和频率。对于简单的操作和高频率的操作,atomic 更加高效,因为它不会影响其他线程的并行执行。但是,对于复杂的操作或者需要多个操作来完成一个任务,锁可能更加合适。

原子操作只能够进行一些简单的操作,如果操作复杂的是没有原子指令进行操作的,这一点我们在后文当中详细谈到,如果你想要原子性的是一个代码块的只能够使用锁,而使用不了原子指令。

深入剖析原子指令——从汇编角度

加法和减法原子操作

我们现在来仔细分析一下下面的代码的汇编指令,看看编译器在背后为我们做了些什么:


#include <stdio.h>
#include <omp.h>

int main()
{
  int data = 0;
#pragma omp parallel num_threads(4) shared(data) default(none)
  {
#pragma omp atomic
    data += 1;
  }
  printf("data = %d\n", data);
  return 0;
}

首先我们需要了解一点编译器会将并行域的代码编译成一个函数,我们现在看看上面的 parallel 并行域的对应的函数的的汇编程序:

0000000000401193 <main._omp_fn.0>:
  401193:       55                      push   %rbp
  401194:       48 89 e5                mov    %rsp,%rbp
  401197:       48 89 7d f8             mov    %rdi,-0x8(%rbp)
  40119b:       48 8b 45 f8             mov    -0x8(%rbp),%rax
  40119f:       48 8b 00                mov    (%rax),%rax
  4011a2:       f0 83 00 01             lock addl $0x1,(%rax) # 这就是编译出来的原子指令——对应x86平台
  4011a6:       5d                      pop    %rbp
  4011a7:       c3                      retq   
  4011a8:       0f 1f 84 00 00 00 00    nopl   0x0(%rax,%rax,1)
  4011af:       00 

在上面的汇编代码当中最终的一条指令就是 lock addl $0x1,(%rax),这条指令便是编译器在编译 #pragma omp atomic 的时候将 data += 1 转化成硬件的对应的指令。我们可以注意到和普通的加法指令的区别就是这条指令前面有一个 lock ,这是告诉硬件在指令 lock 后面的指令的时候需要保证指令的原子性。

以上就是在 x86 平台下加法操作对应的原子指令。我们现在将上面的 data += 1,改成 data -= 1,在来看一下它对应的汇编程序:

0000000000401193 <main._omp_fn.0>:
  401193:       55                      push   %rbp
  401194:       48 89 e5                mov    %rsp,%rbp
  401197:       48 89 7d f8             mov    %rdi,-0x8(%rbp)
  40119b:       48 8b 45 f8             mov    -0x8(%rbp),%rax
  40119f:       48 8b 00                mov    (%rax),%rax
  4011a2:       f0 83 28 01             lock subl $0x1,(%rax)
  4011a6:       5d                      pop    %rbp
  4011a7:       c3                      retq   
  4011a8:       0f 1f 84 00 00 00 00    nopl   0x0(%rax,%rax,1)
  4011af:       00 

可以看到它和加法指令的主要区别就是 addl 和 subl,其他的程序是一样的。

乘法和除法原子操作

我们现在将下面的程序进行编译:



#include <stdio.h>
#include <omp.h>

int main()
{
  int data = 1;
#pragma omp parallel num_threads(4) shared(data) default(none)
  {
#pragma omp atomic
    data *= 2;
  }
  printf("data = %d\n", data);
  return 0;
}

上面代码的并行域被编译之后的汇编程序如下所示:

0000000000401193 <main._omp_fn.0>:
  401193:       55                      push   %rbp
  401194:       48 89 e5                mov    %rsp,%rbp
  401197:       48 89 7d f8             mov    %rdi,-0x8(%rbp)
  40119b:       48 8b 45 f8             mov    -0x8(%rbp),%rax
  40119f:       48 8b 08                mov    (%rax),%rcx
  4011a2:       8b 01                   mov    (%rcx),%eax
  4011a4:       89 c2                   mov    %eax,%edx
  4011a6:       8d 34 12                lea    (%rdx,%rdx,1),%esi # 这条语句的含义为 data *= 2
  4011a9:       89 d0                   mov    %edx,%eax
  4011ab:       f0 0f b1 31             lock cmpxchg %esi,(%rcx)
  4011af:       89 d6                   mov    %edx,%esi
  4011b1:       89 c2                   mov    %eax,%edx
  4011b3:       39 f0                   cmp    %esi,%eax
  4011b5:       75 ef                   jne    4011a6 <main._omp_fn.0+0x13>
  4011b7:       5d                      pop    %rbp
  4011b8:       c3                      retq   
  4011b9:       0f 1f 80 00 00 00 00    nopl   0x0(%rax)

我们先不仔细去分析上面的汇编程序,我们先来看一下上面程序的行为:

  • 首先加载 data 的值,保存为 temp,这个 temp 的值保存在寄存器当中。
  • 然后将 temp 的值乘以 2 保存在寄存器当中。
  • 最后比较 temp 的值是否等于 data,如果等于那么就将 data 的值变成 temp ,如果不相等(也就是说有其他线程更改了 data 的值,此时不能赋值给 data)回到第一步,这个操作主要是基于指令 cmpxchg

上面的三个步骤当中第三步是一个原子操作对应上面的汇编指令 lock cmpxchg %esi,(%rcx) ,cmpxchg 指令前面加了 lock 主要是保存这条 cmpxchg 指令的原子性。

如果我们将上面的汇编程序使用 C 语言重写的话,那么就是下面的程序那样:



#include <stdio.h>
#include <stdbool.h>
#include <stdatomic.h>

// 这个函数对应上面的汇编程序
void atomic_multiply(int* data)
{
  int oldval = *data;
  int write = oldval * 2;
  // __atomic_compare_exchange_n 这个函数的作用就是
  // 将 data 指向的值和 old 的值进行比较,如果相等就将 write 的值写入 data
  // 指向的内存地址 如果操作成功返回 true 否则返回 false
  while (!__atomic_compare_exchange_n (data, &oldval, write, false,
                                      __ATOMIC_ACQUIRE, __ATOMIC_RELAXED))
  {
    oldval = *data;
    write = oldval * 2;
  }
}

int main()
{
  int data = 2;
  atomic_multiply(&data);
  printf("data = %d\n", data);
  return 0;
}

现在我们在来仔细分析一下上面的汇编程序,首先我们需要仔细了解一下 cmpxchg 指令,这个指令在上面的汇编程序当中的作用是比较 eax 寄存器和 rcx 寄存器指向的内存地址的数据,如果相等就将 esi 寄存器的值写入到 rcx 指向的内存地址,如果不想等就将 rcx 寄存器指向的内存的值写入到 eax 寄存器。

通过理解上面的指令,在 cmpxchg 指令之后的就是查看是否 esi 寄存器的值写入到了 rcx 寄存器指向的内存地址,如果是则不执行跳转语句,否则指令回到位置 4011a6 重新执行,这就是一个 while 循环。

我们在来看一下将乘法变成除法之后的汇编指令:

0000000000401193 <main._omp_fn.0>:
  401193:       55                      push   %rbp
  401194:       48 89 e5                mov    %rsp,%rbp
  401197:       48 89 7d f8             mov    %rdi,-0x8(%rbp)
  40119b:       48 8b 45 f8             mov    -0x8(%rbp),%rax
  40119f:       48 8b 08                mov    (%rax),%rcx
  4011a2:       8b 01                   mov    (%rcx),%eax
  4011a4:       89 c2                   mov    %eax,%edx
  4011a6:       89 d0                   mov    %edx,%eax
  4011a8:       c1 e8 1f                shr    $0x1f,%eax
  4011ab:       01 d0                   add    %edx,%eax
  4011ad:       d1 f8                   sar    %eax
  4011af:       89 c6                   mov    %eax,%esi
  4011b1:       89 d0                   mov    %edx,%eax
  4011b3:       f0 0f b1 31             lock cmpxchg %esi,(%rcx)
  4011b7:       89 d6                   mov    %edx,%esi
  4011b9:       89 c2                   mov    %eax,%edx
  4011bb:       39 f0                   cmp    %esi,%eax
  4011bd:       75 e7                   jne    4011a6 <main._omp_fn.0+0x13>
  4011bf:       5d                      pop    %rbp
  4011c0:       c3                      retq   
  4011c1:       66 2e 0f 1f 84 00 00    nopw   %cs:0x0(%rax,%rax,1)
  4011c8:       00 00 00 
  4011cb:       0f 1f 44 00 00          nopl   0x0(%rax,%rax,1)

从上面的汇编代码当中的 cmpxchg 和 jne 指令可以看出除法操作使用的还是比较并交换指令(CAS) cmpxchg,并且也是使用 while 循环。

其实复杂的表达式都是使用这个方式实现的:while 循环 + cmpxchg 指令,我们就不一一的将其他的使用方式也拿出来一一解释了。简单的表达式可以直接使用 lock + 具体的指令实现。

总结

在本篇文章当中主要是深入剖析了 OpenMP 当中各种原子指令的实现原理以及分析了他们对应的汇编程序,OpenMP 在处理 #pragma omp atomic 的时候如果能够使用原子指令完成需求那就直接使用原子指令,否则的话就使用 CAS cmpxchg 指令和 while 循环完成对应的需求。


更多精彩内容合集可访问项目:https://github.com/Chang-LeHung/CSCore

关注公众号:一无是处的研究僧,了解更多计算机(Java、Python、计算机系统基础、算法与数据结构)知识。

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/175538.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

LeetCode102_102. 二叉树的层序遍历

LeetCode102_102. 二叉树的层序遍历 一、描述 给你二叉树的根节点 root &#xff0c;返回其节点值的 层序遍历 。 &#xff08;即逐层地&#xff0c;从左到右访问所有节点&#xff09;。 示例 1&#xff1a; 输入&#xff1a;root [3,9,20,null,null,15,7] 输出&#xff…

java抽象类和接口2023026

抽象类&#xff1a; 当编写一个类时&#xff0c;常常会为该类定义一些方法&#xff0c;这些方法用以描述该类的行为方式&#xff0c;那么这些方法都有具体的方法体。但在某些情况下&#xff0c;某个父类只是知道其子类应该包含怎样的方法&#xff0c;但无法准确地知道这些子类如…

使用阿里云服务器ECS 及一些问题白话阐述

阿里云服务器ECS的申请流程 首先登录阿里云官网 https://www.aliyun.com/ 查看产品文档学习观看然后看完后 大致有了了解后 我们按照我下面梳理的流程走首先购买阿里云服务器点击产品 下拉找到 云服务器ECS 然后点击进入进入到ECS的页面如果你是新人可以享受优惠购买因为还是比…

Linux常用命令——strace命令

在线Linux命令查询工具(http://www.lzltool.com/LinuxCommand) strace 跟踪系统调用和信号 补充说明 strace命令是一个集诊断、调试、统计与一体的工具&#xff0c;我们可以使用strace对应用的系统调用和信号传递的跟踪结果来对应用进行分析&#xff0c;以达到解决问题或者…

go test的简单使用

go test go 集成了比较好用的test测试命令&#xff0c;该命令可以测试Go代码的可用性。 前奏 该文所需的项目目录结构为: example||---------function.go||---------function_test.go||---------go.modfunction.go文件是我们写用户代码的地方&#xff0c;function_test.go文…

力扣(LeetCode)388. 文件的最长绝对路径(2023.01.21)

假设有一个同时存储文件和目录的文件系统。下图展示了文件系统的一个示例&#xff1a; 这里将 dir 作为根目录中的唯一目录。dir 包含两个子目录 subdir1 和 subdir2 。subdir1 包含文件 file1.ext 和子目录 subsubdir1&#xff1b;subdir2 包含子目录 subsubdir2&#xff0c;…

架构设计中的布隆过滤器与布谷鸟过滤器

场景: 某业务后端涉及数据库&#xff0c;当请求消息查询某些信息时&#xff0c;可能先检查缓存中是否有相关信息&#xff0c;有的话返回&#xff0c;如果没有的话可能就要去数据库里面查询&#xff0c;这时候有一个问题&#xff0c;如果很多请求是在请求数据库根本不存在的数据…

活动星投票中国青年好网民网络评选微信的投票方式线上免费投票

“中国青年好网民”网络评选投票_投票微信搭建程序_微信多项免费投票_如何利用微信群投票如果通过一个小程序免费制作一个微信投票活动呢&#xff1f;文章详细讲解如何利用一款免费好用的微信小程序“活动星投票”小程序来制作投票活动&#xff0c;无需注册即可免费制作&#x…

“华为杯”研究生数学建模竞赛2005年-【华为杯】A题:高速公路行车时间估计及最优路径选择问题(附获奖论文)

赛题描述 A: Highway Traveling time Estimate and Optimal Routing Ⅰ Highway traveling time estimate is crucial to travelers. Hence, detectors are mounted on some of the US highways. For instance, detectors are mounted on every two-way six-lane highways o…

创建者模式-建造者模式

1.概述 将一个复杂对象的构建与表示分离&#xff0c;使得同样的构建过程可以创建不同的表示。 分离了部件的构造(由Builder来负责)和装配(由Director负责)。 从而可以构造出复杂的对象。这个模式适用于&#xff1a;某个对象的构建过程复杂的情况。由于实现了构建和装配的解耦…

idea启动java项目报错——error:java: 无效的源发行版: 10

问题背景 今天在新建了一个项目的后&#xff0c;项目搭建好以后&#xff0c;想要看一下是否能够正常启动。但是在启动项目的时候&#xff0c;控制台报错如下&#xff1a;error:java: 无效的源发行版: 10。脑残审核&#xff0c;你告诉我哪个是广告&#xff1f;&#xff1f;&…

Linux下进程以及相关概念理解

目录 一、进程概念 二、描述进程PCB 三、查看进程 3.1 通过系统目录查看 3.2 通过ps命令查看 四、进程状态 运行状态R 睡眠状态S 磁盘休眠状态D 暂停状态T 僵尸状态Z 死亡状态X 五、僵尸进程与孤儿进程 5.1 僵尸进程 5.1.1 僵尸进程的概念 5.1.2 僵尸进程的危害…

【C++】从0到1入门C++编程学习笔记 - 核心编程篇:类和对象(上)

文章目录一、封装1.1 封装的意义1.2 struct和class区别1.3 成员属性设置为私有二、对象的初始化和清理2.1 构造函数和析构函数2.2 构造函数的分类及调用2.3 拷贝构造函数调用时机2.4 构造函数调用规则2.5 深拷贝与浅拷贝2.6 初始化列表2.7 类对象作为类成员2.8 静态成员三、C对…

day27-单元测试/日志

1.管理系统与服务器集成 1.1准备工作【应用】 需求 对之前写过的信息管理系统进行改进,实现可以通过浏览器进行访问的功能 准备工作 将资料中的管理系统代码拷贝到当前模块下 导包的代码可能报错,因为之前的包路径可能和当前代码不一致,将导包的代码修改下 业务分析 解…

【Linux】Linux下的调试器-gdb的使用

目录1.debug和release拓展2.如何使用gdb调试3.指令集我们平常调试C/C代码大多实在Windows平台下的VS中&#xff0c;在LInux中&#xff0c;我们通常使用gdb来调试代码&#xff0c;虽然我们很少在LInux上对代码进行调试&#xff0c;gdb在实际的使用中用的较少&#xff0c;但我们必…

【C++】从0到1入门C++编程学习笔记 - 核心编程篇:类和对象(下)

文章目录五、运算符重载5.1 加号运算符重载5.2 左移运算符重载5.3 递增运算符重载5.4 赋值运算符重载5.5 关系运算符重载5.6 函数调用运算符重载六、继承6.1 继承的基本语法6.2 继承方式6.3 继承中的对象模型6.4 继承中构造和析构顺序6.5 继承同名成员处理方式6.6 继承同名静态…

Java练习:面向对象进阶(上)

Java练习&#xff1a;面向对象进阶&#xff08;上&#xff09;一、定义数组工具类a. 工具类b. 测试类c. 输出结果二、定义学生工具类a. 学生类b. 工具类c. 测试类d. 输出结果三、继承和多态综合练习a. 动物类b. 饲养员类c. 狗类d. 猫类e. 测试类f. 输出结果一、定义数组工具类 …

S60v3固件备份

清理老硬盘 该删资料了 以前的N年前备份的帖子放在CSDN备份吧 没啥用的 以后用来讲故事的 大家不要介意. RM-632102.002 E5-00极限版 RM-566031.023 6730c极限固件 RM-469091.004 E52极限固件 E5-00 一代神机 RM-632 WIFI 横屏 500MP 内存256 S60V3FP2E5的ROM估计现在太难找…

Special Weekly | 瑞兔送福,Live Long and Prosper

SOFAWish 送虎迎兔各位 SOFAStack 社区的朋友好&#xff1a;我是 SOFAStack 社区的负责人鲁直&#xff0c;度过了令人难忘的虎年&#xff0c;我们即将迈入充满希望的兔年&#xff0c;在这里给大家拜个早年&#xff0c;祝大家兔年吉祥。虎年虽然有诸多的不便与艰难&#xff0c;…

ROS2机器人编程简述humble-第二章-SIMULATED ROBOT SETUP .4

ROS2机器人编程简述新书推荐-A Concise Introduction to Robot Programming with ROS2ROS2机器人编程简述humble-第二章-Executors .3.5书中没有使用几乎所有教程都会采用的turtlesim。美美的圣诞树画出来-CoCube如何将数学曲线变为机器人轨迹-花式show爱心代码-turtlesim篇直接…