关于linux:OpenMP-线程同步-Construct-实现原理以及源码分析上

41次阅读

共计 10518 个字符,预计需要花费 27 分钟才能阅读完成。

OpenMP 线程同步 Construct 实现原理以及源码剖析(上)

前言

在本篇文章当中次要给大家介绍在 OpenMP 当中应用的一些同步的 construct 的实现原理,如 master, single, critical 等等!并且会联合对应的汇编程序进行认真的剖析。(本篇文章的汇编程序剖析基于 x86_86 平台)

Flush Construct

首先先理解一下 flush construct 的语法:

#pragma omp flush(变量列表)

这个结构比较简单,其实就是减少一个内存屏障,保障多线程环境上面的数据的可见性,简略来说一个线程对某个数据进行批改之后,批改之后的后果对其余线程来说是可见的。

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

int main()
{
  int data = 100;
#pragma omp parallel num_threads(4) default(none) shared(data)
  {#pragma omp flush(data)
  }
  return 0;
}

下面是一个非常简单的 OpenMP 的程序,依据后面的文章 OpenMp Parallel Construct 实现原理与源码剖析 咱们能够晓得会讲并行域编译成一个函数,咱们当初来看一下这个编译后的汇编程序是怎么样的!

gcc-4 编译之后的后果

00000000004005f6 <main._omp_fn.0>:
  4005f6:       55                      push   %rbp
  4005f7:       48 89 e5                mov    %rsp,%rbp
  4005fa:       48 89 7d f8             mov    %rdi,-0x8(%rbp)
  4005fe:       0f ae f0                mfence 
  400601:       5d                      pop    %rbp
  400602:       c3                      retq   
  400603:       66 2e 0f 1f 84 00 00    nopw   %cs:0x0(%rax,%rax,1)
  40060a:       00 00 00 
  40060d:       0f 1f 00                nopl   (%rax)

从下面的后果咱们能够看到最终的一条指令是 mfence 这是一条 full 的内存屏障,用于保障数据的可见性,次要是 cache line 中数据的可见性。

gcc-11 编译之后的后果

0000000000401165 <main._omp_fn.0>:
  401165:       55                      push   %rbp
  401166:       48 89 e5                mov    %rsp,%rbp
  401169:       48 89 7d f8             mov    %rdi,-0x8(%rbp)
  40116d:       f0 48 83 0c 24 00       lock orq $0x0,(%rsp)
  401173:       5d                      pop    %rbp
  401174:       c3                      retq   
  401175:       66 2e 0f 1f 84 00 00    nopw   %cs:0x0(%rax,%rax,1)
  40117c:       00 00 00 
  40117f:       90                      nop

从编译之后的后果来看,这个汇编程序次要是应用 lock 指令实现可见性,咱们晓得 lock 指令是用来保障原子性的,然而事实上这同样也能够保障可见性,试想一下如果不保障可见性是不可能保障原子性的!因为如果这个线程看到的数据都不是最新批改的数据的话,那么即便操作是原子的那么也达不到咱们想要的成果。

下面两种形式的编译后果的次要区别就是一个应用 lock 指令,一个应用 mfence 指令,实际上 lock 的效率比 mfence 效率更高因而在很多场景下,当初都是应用 lock 指令进行实现。

在我的机器上上面的代码别离应用 gcc-11 和 gcc-4 编译之后执行的后果差别很大,gcc-11 大概应用了 11 秒,而 gcc-4 编译进去的后果执行了 20 秒,这其中次要的区别就是 lock 指令和 mfence 指令的差别。

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

int main()
{double start = omp_get_wtime();
  for(long i = 0; i < 1000000000L; ++i)
  {__sync_synchronize();
  }
  printf("time = %lf\n", omp_get_wtime() - start);
  return 0;
}

Master Construct

master construct 的应用办法如下所示:

#pragma omp master

事实上编译器会将下面的编译领导语句编译成与上面的代码等价的汇编程序:

if (omp_get_thread_num () == 0)
  block // master 的代码块 

咱们当初来剖析一个理论的例子,看看程序编译之后的后果是什么:

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

int main()
{#pragma omp parallel num_threads(4) default(none)
  {
#pragma omp master
    {printf("I am master and my tid = %d\n", omp_get_thread_num());
    }
  }
  return 0;
}

下面的程序编译之后的后果如下所示(汇编程序的大抵剖析如下):

000000000040117a <main._omp_fn.0>:
  40117a:       55                      push   %rbp
  40117b:       48 89 e5                mov    %rsp,%rbp
  40117e:       48 83 ec 10             sub    $0x10,%rsp
  401182:       48 89 7d f8             mov    %rdi,-0x8(%rbp)
  401186:       e8 a5 fe ff ff          callq  401030 <omp_get_thread_num@plt> # 失去线程的 id 并保留到 eax 寄存器当中
  40118b:       85 c0                   test   %eax,%eax # 看看寄存器 eax 是不是等于 0
  40118d:       75 16                   jne     4011a5  <main._omp_fn.0+0x2b> # 如果不等于 0 则跳转到 4011a5 的地位 也就是间接退出程序了 如果是那么就继续执行前面的 printf 语句
  40118f:       e8 9c fe ff ff          callq  401030 <omp_get_thread_num@plt>
  401194:       89 c6                   mov    %eax,%esi
  401196:       bf 10 20 40 00          mov    $0x402010,%edi
  40119b:       b8 00 00 00 00          mov    $0x0,%eax
  4011a0:       e8 9b fe ff ff          callq  401040 <printf@plt>
  4011a5:       90                      nop
  4011a6:       c9                      leaveq 
  4011a7:       c3                      retq   
  4011a8:       0f 1f 84 00 00 00 00    nopl   0x0(%rax,%rax,1)
  4011af:       00 

这里咱们只须要理解一下 test 指令就可能了解下面的汇编程序了,”test %eax, %eax” 是 x86 汇编语言中的一条指令,它的含意是对寄存器 EAX 和 EAX 进行逻辑与运算,并将后果存储在状态寄存器中,然而不扭转 EAX 的值。这条指令会影响标记位(如 ZF、SF、OF),可用于判断 EAX 是否等于零。

从下面的汇编程序剖析咱们也能够晓得,master construct 就是一条 if 语句,然而前面咱们将要谈到的 single 不一样他还须要进行同步。

Critical Construct

pragma omp critical

首先咱们须要理解的是 critical 的两种应用办法,在 OpenMP 当中 critical 子句有以下两种应用办法:

#pragma omp critical
#pragma omp critical(name)

须要理解的是在 OpenMP 当中每一个 critical 子句的背地都会应用到一个锁,不同的 name 对应不同的锁,如果你应用第一种 critical 的话,那么就是应用 OpenMP 默认的全局锁,须要晓得的是同一个时刻只可能有一个线程取得锁,如果你在你的代码当中应用全局的 critical 的话,那么须要留神他的效率,因为在一个时刻只可能有一个线程获取锁。

首先咱们先剖析第一种应用形式下,编译器会生成什么样的代码,如果咱们应用 #pragma omp critical 那么在理论的汇编程序当中会应用上面两个动静库函数,GOMP_critical_start 在刚进入临界区的时候调用,GOMP_critical_end 在来到临界区的时候调用。

void GOMP_critical_start (void);
void GOMP_critical_end (void);

咱们应用上面的程序进行阐明:

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

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

依据咱们后面的一些文章的剖析,并行域在通过编译之后会被编译成一个函数,下面的程序在进行编译之后咱们失去如下的后果:

00000000004011b7 <main._omp_fn.0>:
  4011b7:       55                      push   %rbp
  4011b8:       48 89 e5                mov    %rsp,%rbp
  4011bb:       48 83 ec 10             sub    $0x10,%rsp
  4011bf:       48 89 7d f8             mov    %rdi,-0x8(%rbp)
  4011c3:       e8 b8 fe ff ff          callq  401080 <GOMP_critical_start@plt>
  4011c8:       48 8b 45 f8             mov    -0x8(%rbp),%rax
  4011cc:       8b 00                   mov    (%rax),%eax
  4011ce:       8d 50 01                lea    0x1(%rax),%edx
  4011d1:       48 8b 45 f8             mov    -0x8(%rbp),%rax
  4011d5:       89 10                   mov    %edx,(%rax)
  4011d7:       e8 54 fe ff ff          callq  401030 <GOMP_critical_end@plt>
  4011dc:       c9                      leaveq 
  4011dd:       c3                      retq   
  4011de:       66 90                   xchg   %ax,%ax

从下面的反汇编后果来看的确调用了 GOMP_critical_start 和 GOMP_critical_end 两个函数,并且别离是在进入临界区之前和来到临界区之前调用的。在 GOMP_critical_start 函数中会进行加锁操作,在函数 GOMP_critical_end 当中会进行解锁操作,在后面咱们曾经提到过,这个加锁和解锁操作应用的是 OpenMP 外部的默认的全局锁。

咱们看一下这两个函数的源程序:

void
GOMP_critical_start (void)
{
  /* There is an implicit flush on entry to a critical region. */
  __atomic_thread_fence (MEMMODEL_RELEASE);
  gomp_mutex_lock (&default_lock); // default_lock 是一个 OpenMP 外部的锁
}

void
GOMP_critical_end (void)
{gomp_mutex_unlock (&default_lock);
}

从下面的代码来看次要是调用 gomp_mutex_lock 进行加锁操作,调用 gomp_mutex_unlock 进行解锁操作,这两个函数的外部实现原理咱们在后面的文章当中曾经进行了具体的解释阐明和剖析,如果大家感兴趣,能够参考这篇文章 OpenMP Runtime Library : Openmp 常见的动静库函数应用(下)——深刻分析锁🔒原理与实现。

pragma omp critical(name)

如果咱们应用命令的 critical 的话,那么调用的库函数和后面是不一样的,具体来说是调用上面两个库函数:

void GOMP_critical_name_end (void **pptr);
void GOMP_critical_name_start (void **pptr);

其中 pptr 是指向一个指向锁的指针,在后面的文章 OpenMP Runtime Library : Openmp 常见的动静库函数应用(下)——深刻分析锁🔒原理与实现 当中咱们认真探讨过这个锁其实就是一个 int 类型的变量。这个变量在编译期间就会在 bss 节调配空间,在程序启动的时候将其初始化为 0,示意没上锁的状态,对于这一点在下面谈到的文章当中有认真的探讨。

这里可能须要辨别一下 data 节和 bss 节,.data 节是用来存放程序中定义的全局变量和动态变量的初始值的内存区域。这些变量的值在程序开始执行前就曾经确定。.bss 节是用来存放程序中定义的全局变量和动态变量的未初始化的内存区域。这些变量在程序开始执行前并没有初始化的值。在程序开始执行时,这些变量会被零碎主动初始化为 0。总的来说,.data 寄存已初始化数据,.bss 寄存未初始化数据。

咱们当初来剖析一个命名的 critical 子句他的汇编程序:

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

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

下面的代码通过编译之后失去上面的后果:

00000000004011b7 <main._omp_fn.0>:
  4011b7:       55                      push   %rbp
  4011b8:       48 89 e5                mov    %rsp,%rbp
  4011bb:       48 83 ec 10             sub    $0x10,%rsp
  4011bf:       48 89 7d f8             mov    %rdi,-0x8(%rbp)
  4011c3:       bf 58 40 40 00          mov    $0x404058,%edi
  4011c8:       e8 a3 fe ff ff          callq  401070 <GOMP_critical_name_start@plt>
  4011cd:       48 8b 45 f8             mov    -0x8(%rbp),%rax
  4011d1:       8b 00                   mov    (%rax),%eax
  4011d3:       8d 50 01                lea    0x1(%rax),%edx
  4011d6:       48 8b 45 f8             mov    -0x8(%rbp),%rax
  4011da:       89 10                   mov    %edx,(%rax)
  4011dc:       bf 58 40 40 00          mov    $0x404058,%edi
  4011e1:       e8 4a fe ff ff          callq  401030 <GOMP_critical_name_end@plt>
  4011e6:       c9                      leaveq 
  4011e7:       c3                      retq   
  4011e8:       0f 1f 84 00 00 00 00    nopl   0x0(%rax,%rax,1)

从下面的后果咱们能够看到在调用函数 GOMP_critical_name_start 时,传递的参数的值为 0x404058(显然这个就是在编译的时候就确定的),咱们当初来看一下 0x404058 地位在哪一个节。

依据 x86 的调用规约,rdi/edi 寄存器存储的就是调用函数的第一个参数,而在函数 GOMP_critical_name_start 被调用之前咱们能够看到 edi 寄存器的值是 0x404058,(mov $0x404058,%edi) 因而 pptr 指针的值就是 0x404058。

为了确定指针指向的数据的地位咱们能够查看节头表当中各个节在可执行程序当中的地位,判断 0x404058 在哪个节当中,下面的程序的节头表如下所示:

Section Headers:
  [Nr] Name              Type             Address           Offset
       Size              EntSize          Flags  Link  Info  Align
  [0]                   NULL             0000000000000000  00000000
       0000000000000000  0000000000000000           0     0     0
  [1] .interp           PROGBITS         00000000004002a8  000002a8
       000000000000001c  0000000000000000   A       0     0     1
  [2] .note.gnu.build-i NOTE             00000000004002c4  000002c4
       0000000000000024  0000000000000000   A       0     0     4
  [3] .note.ABI-tag     NOTE             00000000004002e8  000002e8
       0000000000000020  0000000000000000   A       0     0     4
  [4] .gnu.hash         GNU_HASH         0000000000400308  00000308
       0000000000000060  0000000000000000   A       5     0     8
  [5] .dynsym           DYNSYM           0000000000400368  00000368
       00000000000001e0  0000000000000018   A       6     1     8
  [6] .dynstr           STRTAB           0000000000400548  00000548
       0000000000000111  0000000000000000   A       0     0     1
  [7] .gnu.version      VERSYM           000000000040065a  0000065a
       0000000000000028  0000000000000002   A       5     0     2
  [8] .gnu.version_r    VERNEED          0000000000400688  00000688
       0000000000000050  0000000000000000   A       6     2     8
  [9] .rela.dyn         RELA             00000000004006d8  000006d8
       0000000000000018  0000000000000018   A       5     0     8
  [10] .rela.plt         RELA             00000000004006f0  000006f0
       0000000000000090  0000000000000018  AI       5    22     8
  [11] .init             PROGBITS         0000000000401000  00001000
       000000000000001a  0000000000000000  AX       0     0     4
  [12] .plt              PROGBITS         0000000000401020  00001020
       0000000000000070  0000000000000010  AX       0     0     16
  [13] .text             PROGBITS         0000000000401090  00001090
       00000000000001d2  0000000000000000  AX       0     0     16
  [14] .fini             PROGBITS         0000000000401264  00001264
       0000000000000009  0000000000000000  AX       0     0     4
  [15] .rodata           PROGBITS         0000000000402000  00002000
       000000000000001b  0000000000000000   A       0     0     8
  [16] .eh_frame_hdr     PROGBITS         000000000040201c  0000201c
       000000000000003c  0000000000000000   A       0     0     4
  [17] .eh_frame         PROGBITS         0000000000402058  00002058
       0000000000000110  0000000000000000   A       0     0     8
  [18] .init_array       INIT_ARRAY       0000000000403df8  00002df8
       0000000000000008  0000000000000008  WA       0     0     8
  [19] .fini_array       FINI_ARRAY       0000000000403e00  00002e00
       0000000000000008  0000000000000008  WA       0     0     8
  [20] .dynamic          DYNAMIC          0000000000403e08  00002e08
       00000000000001f0  0000000000000010  WA       6     0     8
  [21] .got              PROGBITS         0000000000403ff8  00002ff8
       0000000000000008  0000000000000008  WA       0     0     8
  [22] .got.plt          PROGBITS         0000000000404000  00003000
       0000000000000048  0000000000000008  WA       0     0     8
  [23] .data             PROGBITS         0000000000404048  00003048
       0000000000000004  0000000000000000  WA       0     0     1
  [24] .bss              NOBITS           0000000000404050  0000304c
       0000000000000010  0000000000000000  WA       0     0     8
  [25] .comment          PROGBITS         0000000000000000  0000304c
       000000000000005b  0000000000000001  MS       0     0     1
  [26] .debug_aranges    PROGBITS         0000000000000000  000030a7
       0000000000000030  0000000000000000           0     0     1
  [27] .debug_info       PROGBITS         0000000000000000  000030d7
       0000000000000115  0000000000000000           0     0     1
  [28] .debug_abbrev     PROGBITS         0000000000000000  000031ec
       00000000000000d7  0000000000000000           0     0     1
  [29] .debug_line       PROGBITS         0000000000000000  000032c3
       00000000000000a7  0000000000000000           0     0     1
  [30] .debug_str        PROGBITS         0000000000000000  0000336a
       0000000000000122  0000000000000001  MS       0     0     1
  [31] .symtab           SYMTAB           0000000000000000  00003490
       00000000000003c0  0000000000000018          32    21     8
  [32] .strtab           STRTAB           0000000000000000  00003850
       000000000000023c  0000000000000000           0     0     1
  [33] .shstrtab         STRTAB           0000000000000000  00003a8c
       0000000000000143  0000000000000000           0     0     1
Key to Flags:
  W (write), A (alloc), X (execute), M (merge), S (strings), I (info),
  L (link order), O (extra OS processing required), G (group), T (TLS),
  C (compressed), x (unknown), o (OS specific), E (exclude),
  l (large), p (processor specific)

从下面的节头表咱们能够看到第 24 个大节 bss 他的起始地址为 0000000000404050 一共站 16 个字节,也就是说 0x404058 指向的数据在 bss 节的数据范畴,也就是说锁对应的 int 类型(4 个字节)的数据在 bss 节,程序执行的时候会将 bss 节当中的数据初始化为 0,0 示意无锁状态。

咱们当初来看一下函数 GOMP_critical_name_start 源代码(为了不便查看删除了局部代码):

void
GOMP_critical_name_start (void **pptr)
{
  gomp_mutex_t *plock;

  /* If a mutex fits within the space for a pointer, and is zero initialized,
     then use the pointer space directly.  */
  if (GOMP_MUTEX_INIT_0
      && sizeof (gomp_mutex_t) <= sizeof (void *)
      && __alignof (gomp_mutex_t) <= sizeof (void *))
    plock = (gomp_mutex_t *)pptr; // gomp_mutex_t 就是 int 类型

  gomp_mutex_lock (plock);
}

从语句 plock = (gomp_mutex_t *)pptr 能够晓得将传递的参数作为一个 int 类型的指针应用,这个指针指向的就是 bss 节的数据,而后对这个数据进行加锁操作(gomp_mutex_lock (plock)),对于函数 gomp_mutex_lock,在文章 OpenMP Runtime Library : Openmp 常见的动静库函数应用(下)——深刻分析锁🔒原理与实现 当中有具体的解说。

咱们在来看一下 GOMP_critical_name_end 的源代码:

void
GOMP_critical_name_end (void **pptr)
{
  gomp_mutex_t *plock;

  /* If a mutex fits within the space for a pointer, and is zero initialized,
     then use the pointer space directly.  */
  if (GOMP_MUTEX_INIT_0
      && sizeof (gomp_mutex_t) <= sizeof (void *)
      && __alignof (gomp_mutex_t) <= sizeof (void *))
    plock = (gomp_mutex_t *)pptr;
  else
    plock = *pptr;

  gomp_mutex_unlock (plock);
}

同样的还是应用 bss 节的数据进行解锁操作,对于加锁解锁操作的细节能够浏览这篇文章 OpenMP Runtime Library : Openmp 常见的动静库函数应用(下)——深刻分析锁🔒原理与实现。

总结

在本篇文章当中次要给大家介绍了 flush, master 和 critical 指令的实现细节和他的调用的库函数,并且深入分析了这几个 construct 当中设计的库函数的源代码,心愿大家有所播种。


更多精彩内容合集可拜访我的项目:https://github.com/Chang-LeHu…

关注公众号:一无是处的钻研僧,理解更多计算机(Java、Python、计算机系统根底、算法与数据结构)常识。

正文完
 0