Openmp Runtime 库函数汇总(下)——深刻分析锁🔒原理与实现
前言
在本篇文章当中次要给大家介绍一下 OpenMP 当中常常应用到的锁并且仔细分析它其中的外部原理!在 OpenMP 当中次要有两种类型的锁,一个是 omp_lock_t 另外一个是 omp_nest_lock_t,这两个锁的次要区别就是后者是一个可重入锁,所谓可冲入锁就是一旦一个线程曾经拿到这个锁了,那么它下一次想要拿这个锁的就是就不会阻塞,然而如果是 omp_lock_t 不论一个线程是否拿到了锁,只有以后锁没有开释,不论哪一个线程都不可能拿到这个锁。在后问当中将有认真的例子来解释这一点。本篇文章是基于 GNU OpenMP Runtime Library !
深入分析 omp_lock_t
这是 OpenMP 头文件给咱们提供的一个构造体,咱们来看一下它的定义:
typedef struct
{
unsigned char _x[4]
__attribute__((__aligned__(4)));
} omp_lock_t;
事实上这个构造体并没有什么特地的就是占 4 个字节,咱们甚至能够认为他就是一个 4 字节的 int 的类型的变量,只不过应用形式有所差别。与这个构造体相干的次要有以下几个函数:
- omp_init_lock,这个函数的次要性能是初始化 omp_lock_t 对象的,当咱们初始化之后,这个锁就处于一个没有上锁的状态,他的函数原型如下所示:
void omp_init_lock(omp_lock_t *lock);
- omp_set_lock,在调用这个函数之前肯定要先调用函数 omp_init_lock 将 omp_lock_t 进行初始化,直到这个锁被开释之前这个线程会被始终阻塞。如果这个锁被以后线程曾经获取过了,那么将会造成一个死锁,这就是下面提到了锁不可能重入的问题,而咱们在前面将要剖析的锁 omp_nest_lock_t 是可能进行重入的,即便以后线程曾经获取到了这个锁,也不会造成死锁而是会从新取得锁。这个函数的函数原型如下所示:
void omp_set_lock(omp_lock_t *lock);
- omp_test_lock,这个函数的次要作用也是用于获取锁,然而这个函数可能会失败,如果失败就会返回 false 胜利就会返回 true,与函数 omp_set_lock 不同的是,这个函数并不会导致线程被阻塞,如果获取锁胜利他就会立刻返回 true,如果失败就会立刻返回 false 。它的函数原型如下所示:
int omp_test_lock(omp_lock_t *lock);
- omp_unset_lock,这个函数和下面的函数对应,这个函数的次要作用就是用于解锁,在咱们调用这个函数之前,必须要应用 omp_set_lock 或者 omp_test_lock 获取锁,它的函数原型如下:
void omp_unset_lock(omp_lock_t *lock);
- omp_destroy_lock,这个办法次要是对锁进行回收解决,然而对于这个锁来说是没有用的,咱们在后文剖析他的具体的实现的时候会发现这是一个空函数。
咱们当初应用一个例子来具体的体验一下下面的函数:
#include <stdio.h>
#include <omp.h>
int main()
{
omp_lock_t lock;
// 对锁进行初始化操作
omp_init_lock(&lock);
int data = 0;
#pragma omp parallel num_threads(16) shared(lock, data) default(none)
{
// 进行加锁解决 同一个时刻只可能有一个线程可能获取锁
omp_set_lock(&lock);
data++;
// 解锁解决 线程在出临界区之前须要解锁 好让其余线程可能进入临界区
omp_unset_lock(&lock);
}
omp_destroy_lock(&lock);
printf("data = %d\n", data);
return 0;
}
在下面的函数咱们定义了一个 omp_lock_t 锁,并且在并行域内启动了 16 个线程去执行 data ++ 的操作,因为是多线程环境,因而咱们须要将下面的操作进行加锁解决。
omp_lock_t 源码剖析
- omp_init_lock,对于这个函数来说最终在 OpenMP 动静库外部会调用上面的函数:
typedef int gomp_mutex_t;
static inline void
gomp_mutex_init (gomp_mutex_t *mutex)
{
*mutex = 0;
}
从下面的函数咱们能够晓得这个函数的作用就是将咱们定义的 4 个字节的锁赋值为0,这就是锁的初始化,其实很简略。
- omp_set_lock,这个函数最终会调用 OpenMP 外部的一个函数,具体如下所示:
static inline void
gomp_mutex_lock (gomp_mutex_t *mutex)
{
int oldval = 0;
if (!__atomic_compare_exchange_n (mutex, &oldval, 1, false,
MEMMODEL_ACQUIRE, MEMMODEL_RELAXED))
gomp_mutex_lock_slow (mutex, oldval);
}
在下面的函数当中线程首先会调用 __atomic_compare_exchange_n 将锁的值由 0 变成 1,还记得咱们在后面对锁进行初始化的时候将锁的值变成0了吗?
咱们首先须要理解一下 __atomic_compare_exchange_n ,这个是 gcc 内嵌的一个函数,在这里咱们只关注后面三个参数,前面三个参数与内存模型无关,这并不是咱们本篇文章的重点,他的次要性能是查看 mutex 指向的地址的值等不等于 oldval ,如果等于则将这个值变成 1,这一整个操作可能保障原子性,如胜利将 mutex 指向的值变成 1 的话,那么这个函数就返回 true 否则返回 false 对应 C 语言的数据就是 1 和 0 。如果 oldval 的值不等于 mutex 所指向的值,那么这个函数就会将这个值写入 oldval 。
如果这个操作不胜利那么就会调用 gomp_mutex_lock_slow 函数这个函数的次要作用就是如果应用不可能应用原子指令获取锁的话,那么就须要进入内核态,将这个线程挂起。在这个函数的外部还会测试是否可能通过源自操作获取锁,因为可能在咱们调用 gomp_mutex_lock_slow 这个函数的时候可能有其余线程开释锁了。如果依然不可能胜利的话,那么就会真正的将这个线程挂起不会节约 CPU 资源,gomp_mutex_lock_slow 函数具体如下:
void
gomp_mutex_lock_slow (gomp_mutex_t *mutex, int oldval)
{
/* First loop spins a while. */
// 先自旋 如果自旋一段时间还没有获取锁 那就将线程刮挂起
while (oldval == 1)
{
if (do_spin (mutex, 1))
{
/* Spin timeout, nothing changed. Set waiting flag. */
oldval = __atomic_exchange_n (mutex, -1, MEMMODEL_ACQUIRE);
// 如果取得🔒 就返回
if (oldval == 0)
return;
// 如果没有取得🔒 那么就将线程刮起
futex_wait (mutex, -1);
// 这里是当挂起的线程被唤醒之后的操作 也有可能是 futex_wait 没有胜利
break;
}
else
{
/* Something changed. If now unlocked, we're good to go. */
oldval = 0;
if (__atomic_compare_exchange_n (mutex, &oldval, 1, false,
MEMMODEL_ACQUIRE, MEMMODEL_RELAXED))
return;
}
}
/* Second loop waits until mutex is unlocked. We always exit this
loop with wait flag set, so next unlock will awaken a thread. */
while ((oldval = __atomic_exchange_n (mutex, -1, MEMMODEL_ACQUIRE)))
do_wait (mutex, -1);
}
在下面的函数当中有三个依赖函数,他们的源代码如下所示:
static inline void
futex_wait (int *addr, int val)
{
// 在这里进行零碎调用,将线程挂起
int err = syscall (SYS_futex, addr, gomp_futex_wait, val, NULL);
if (__builtin_expect (err < 0 && errno == ENOSYS, 0))
{
gomp_futex_wait &= ~FUTEX_PRIVATE_FLAG;
gomp_futex_wake &= ~FUTEX_PRIVATE_FLAG;
// 在这里进行零碎调用,将线程挂起
syscall (SYS_futex, addr, gomp_futex_wait, val, NULL);
}
}
static inline void do_wait (int *addr, int val)
{
if (do_spin (addr, val))
futex_wait (addr, val);
}
static inline int do_spin (int *addr, int val)
{
unsigned long long i, count = gomp_spin_count_var;
if (__builtin_expect (__atomic_load_n (&gomp_managed_threads,
MEMMODEL_RELAXED)
> gomp_available_cpus, 0))
count = gomp_throttled_spin_count_var;
for (i = 0; i < count; i++)
if (__builtin_expect (__atomic_load_n (addr, MEMMODEL_RELAXED) != val, 0))
return 0;
else
cpu_relax ();
return 1;
}
static inline void
cpu_relax (void)
{
__asm volatile ("" : : : "memory");
}
如果大家对具体的外部实现十分感兴趣能够认真研读下面的代码,如果从 0 开始解释下面的代码比拟麻烦,这里就不做具体的剖析了,简要做一下概括:
- 在锁的设计当中有一个十分重要的准则:一个线程最好不要进入内核态被挂起,如果可能在用户态最好在用户态应用原子指令获取锁,这是因为进入内核态是一个十分耗时的事件相比起原子指令来说。
-
锁(就是咱们在后面探讨的一个 4 个字节的 int 类型的值)有以下三个值:
- -1 示意当初有线程被挂起了。
- 0 示意当初是一个无锁状态,这个状态就示意锁的竞争比拟强烈。
- 1 示意这个线程正在被一个线程用一个原子指令——比拟并替换(CAS)取得了,这个状态示意当初锁的竞争比拟轻。
- _atomic_exchange_n (mutex, -1, MEMMODEL_ACQUIRE); ,这个函数也是 gcc 内嵌的一个函数,这个函数的次要作用就是将 mutex 的值变成 -1,而后将 mutex 指向的地址的原来的值返回。
- __atomic_load_n (addr, MEMMODEL_RELAXED),这个函数的作用次要作用是原子的加载 addr 指向的数据。
- futex_wait 函数的性能是将线程挂起,将线程挂起的零碎调用为 futex ,大家能够应用命令 man futex 去查看 futex 的手册。
- do_spin 函数的性能是进行肯定次数的原子操作(自旋),如果超过这个次数就示意当初这个锁的竞争比拟强烈为了更好的应用 CPU 的计算资源能够将这个线程挂起。如果在自旋(spin)的时候发现锁的值等于 val 那么就返回 0 ,如果在进行 count 次操作之后咱们还没有发现锁的值变成 val 那么就返回 1 ,这就示意锁的竞争比拟强烈。
- 可能你会纳闷在函数 gomp_mutex_lock_slow 的最初一部分为什么要用 while 循环,这是因为 do_wait 函数不肯定会将线程挂起,这个和 futex 零碎调用无关,感兴趣的同学能够去看一下 futex 的文档,就理解这么设计的起因了。
- 在下面的源代码当中有两个 OpenMP 外部全局变量,gomp_throttled_spin_count_var 和 gomp_spin_count_var 用于示意自旋的次数,这个也是 OpenMP 本人进行设计的这个值和环境变量 OMP_WAIT_POLICY 也有关系,具体的数值也是设计团队的经验值,在这里就不介绍这一部分的源代码了。
其实下面的加锁过程是非常复杂的,大家能够本人自行去好好剖析一下这其中的设计,其实是十分值得学习的,下面的加锁代码贯彻的主旨就是:能不进内核态就别进内核态。
- omp_unset_lock,这个函数的次要性能就是解锁了,咱们再来看一下他的源代码设计。这个函数最终调用的 OpenMP 外部的函数为 gomp_mutex_unlock ,其源代码如下所示:
static inline void
gomp_mutex_unlock (gomp_mutex_t *mutex)
{
int wait = __atomic_exchange_n (mutex, 0, MEMMODEL_RELEASE);
if (__builtin_expect (wait < 0, 0))
gomp_mutex_unlock_slow (mutex);
}
在下面的函数当中调用一个函数 gomp_mutex_unlock_slow ,其源代码如下:
void
gomp_mutex_unlock_slow (gomp_mutex_t *mutex)
{
// 示意唤醒 1 个线程
futex_wake (mutex, 1);
}
static inline void
futex_wake (int *addr, int count)
{
int err = syscall (SYS_futex, addr, gomp_futex_wake, count);
if (__builtin_expect (err < 0 && errno == ENOSYS, 0))
{
gomp_futex_wait &= ~FUTEX_PRIVATE_FLAG;
gomp_futex_wake &= ~FUTEX_PRIVATE_FLAG;
syscall (SYS_futex, addr, gomp_futex_wake, count);
}
}
在函数 gomp_mutex_unlock 当中首先调用原子操作 __atomic_exchange_n,将锁的值变成 0 也就是无锁状态,这个其实是不便被唤醒的线程可能不被阻塞(对于这一点大家能够好好去分剖析 gomp_mutex_lock_slow 最初的 while 循环,就可能了解其中的深意了),而后如果 mutex 原来的值(这个值会被赋值给 wait )小于 0 ,咱们在后面曾经谈到过,这个值只能是 -1,这就示意之前有线程进入内核态被挂起了,因而这个线程须要唤醒之前被阻塞的线程,好让他们可能继续执行。唤醒之前线程的函数就是 gomp_mutex_unlock_slow,在这个函数外部会调用 futex_wake 去真正的唤醒一个之前被锁阻塞的线程。
- omp_test_lock,这个函数次要是应用原子指令看是否可能获取锁,而不尝试进入内核,如果胜利获取锁返回 1 ,否则返回 0 。这个函数在 OpenMP 外部会最终调用上面的函数。
int
gomp_test_lock_30 (omp_lock_t *lock)
{
int oldval = 0;
return __atomic_compare_exchange_n (lock, &oldval, 1, false,
MEMMODEL_ACQUIRE, MEMMODEL_RELAXED);
}
从下面源代码来看这函数就是做了原子的比拟并替换操作,如果胜利就是获取锁并且返回值为 1 ,反之没有获取锁那么就不胜利返回值就是 0 。
总的说来下面的锁的设计次要有一下的两个方向:
- Fast path : 可能在用户态解决的事儿就别进内核态,只有可能通过原子指令获取锁,那么就应用原子指令,因为进入内核态是一件十分耗时的事件。
- Slow path : 当经验过肯定数目的自旋操作之后发现还是不可能取得锁,那么就可能判断此时锁的竞争比拟强烈,如果这个时候还不将线程挂起的话,那么这个线程好就会始终耗费 CPU ,因而这个时候咱们应该要进入内核态将线程挂起以节俭 CPU 的计算资源。
杂谈:
- 其实下面的锁的设计是非偏心的咱们能够看到在 gomp_mutex_unlock 函数当中,他是间接将 mutex 和 0 进行替换,依据后面的剖析当初的锁处于一个没有线程获取的状态,如果这个时候有其余线程进来那么就能够间接通过原子操作获取锁了,而这个线程如果将之前被阻塞的线程唤醒,那么这个被唤醒的线程就会处于 gomp_mutex_lock_slow 最初的那个循环当中,如果这个时候 mutex 的值不等于 0 (因为有新来的线程通过原子指令将 mutex 的值由 0 变成 1 了),那么这个线程将持续阻塞,而且会将 mutex 的值设置成 -1。
- 下面的锁设计加锁和解锁的交互状况是非常复杂的,因为须要确保加锁和解锁的操作不会造成死锁,大家能够应用各种程序去设想一下代码的执行就可能发现其中的奇妙之处了。
- 不要将获取锁和线程的唤醒关联起来,线程被唤醒不肯定取得锁,而且 futex 零碎调用存在虚伪唤醒的可能(对于这一点能够查看 futex 的手册)。
深入分析 omp_nest_lock_t
在介绍可重入锁(omp_nest_lock_t)之前,咱们首先来介绍一个需要,看看之前的锁能不能够满足这个需要。
#include <stdio.h>
#include <omp.h>
void echo(int n, omp_nest_lock_t* lock, int * s)
{
if (n > 5)
{
omp_set_nest_lock(lock);
// 在这里进行递归调用 因为在上一行代码曾经获取锁了 递归调用还须要获取锁
// omp_lock_t 是不能满足这个要求的 而 omp_nest_lock_t 能
echo(n - 1, lock, s);
*s += 1;
omp_unset_nest_lock(lock);
}
else
{
omp_set_nest_lock(lock);
*s += n;
omp_unset_nest_lock(lock);
}
}
int main()
{
int n = 100;
int s = 0;
omp_nest_lock_t lock;
omp_init_nest_lock(&lock);
echo(n, &lock, &s);
printf("s = %d\n", s);
omp_destroy_nest_lock(&lock);
printf("%ld\n", sizeof (omp_nest_lock_t));
return 0;
}
在下面的代码当中会调用函数 echo
,而在 echo
函数当中会进行递归调用,然而在递归调用之前线程曾经获取锁了,如果进行递归调用的话,因为之前这个锁曾经被获取了,因而如果再获取锁的话就会产生死锁,因为线程曾经被获取了。
如果要解决下面的问题就须要应用的可重入锁了,所谓可重入锁就是当一个线程获取锁之后,如果这个线程还想获取锁他依然可能获取到锁,而不会产生死锁的景象。如果将下面的锁改成可重入锁 omp_nest_lock_t 那么程序就会失常执行实现,而不会产生死锁。
#include <stdio.h>
#include <omp.h>
void echo(int n, omp_nest_lock_t* lock, int * s)
{
if (n > 5)
{
omp_set_nest_lock(lock);
echo(n - 1, lock, s);
*s += 1;
omp_unset_nest_lock(lock);
}
else
{
omp_set_nest_lock(lock);
*s += n;
omp_unset_nest_lock(lock);
}
}
int main()
{
int n = 100;
int s = 0;
omp_nest_lock_t lock;
omp_init_nest_lock(&lock);
echo(n, &lock, &s);
printf("s = %d\n", s);
omp_destroy_nest_lock(&lock);
return 0;
}
下面的各个函数的应用办法和之前的 omp_lock_t 的应用办法是一样的:
- 锁的初始化 —— init 。
- 加锁 —— set_lock。
- 解锁 —— unset_lock 。
- 锁的开释 —— destroy 。
咱们当初来剖析一下 omp_nest_lock_t 的实现原理,首先须要理解的是 omp_nest_lock_t 这个构造体一共占用 16 个字节,这 16个字节的字段如下所示:
typedef struct {
int lock;
int count;
void *owner;
} omp_nest_lock_t;
下面的构造体一共占 16 个字节当初咱们来仔细分析以下面的三个字段的含意:
- lock,这个字段和下面谈到的 omp_lock_t 是一样的作用都是占用 4 个字节,次要是用于原子操作。
- count,在后面咱们曾经谈到了 omp_nest_lock_t 同一个线程在获取锁之后依然可能获取锁,因而这个字段的含意就是示意线程获取了多少次锁。
- owner,这个字段的含意就比较简单了,咱们须要记录是哪个线程获取的锁,这个字段的意义就是执行获取到锁的线程。
- 这里大家只须要略微理解一下这几个字段的含意,在前面剖析源代码的时候大家就可能领会到这其中设计的精妙之处了。
omp_nest_lock_t 源码剖析
- omp_init_nest_lock,这个函数的作用次要是进行初始化操作,将 omp_nest_lock_t 中的数据中所有的比特全副变成 0 。在 OpenMP 外部中最终会调用上面的函数:
void
gomp_init_nest_lock_30 (omp_nest_lock_t *lock)
{
// 字符 '\0' 对应的数值就是 0 这个就是将 lock 指向的 16 个字节全副清零
memset (lock, '\0', sizeof (*lock));
}
- omp_set_nest_lock,这个函数的次要作用就是加锁,在 OpenMP 外部最终调用的函数如下所示:
void
gomp_set_nest_lock_30 (omp_nest_lock_t *lock)
{
// 首先获取以后线程的指针
void *me = gomp_icv (true);
// 如果锁的所有者不是以后线程,那么就调用函数 gomp_mutex_lock 去获取锁
// 这里的 gomp_mutex_lock 函数和咱们之前在 omp_lock_t 当中所剖析的函数
// 是同一个函数
if (lock->owner != me)
{
gomp_mutex_lock (&lock->lock);
// 当获取锁胜利之后将以后线程的所有者设置成本人
lock->owner = me;
}
// 因为获取锁了所以须要将以后线程获取锁的次数加一
lock->count++;
}
在下面的程序当中次要的流程如下:
- 如果以后锁的所有者是本人,也就是说如果以后线程之前曾经获取到锁了,那么久间接将 count 进行加一操作。
- 如果当线程还还没有获取到锁,那么就应用 gomp_mutex_lock 去获取锁,如果以后曾经有线程获取到锁了,那么线程就会被挂起。
- omp_unset_nest_lock
void
gomp_unset_nest_lock_30 (omp_nest_lock_t *lock)
{
if (--lock->count == 0)
{
lock->owner = NULL;
gomp_mutex_unlock (&lock->lock);
}
}
在由了 omp_lock_t 的剖析根底之后下面的代码也是比拟容易剖析的,首先会将 count 的值减去一,如果 count 的值变成 0,那么就能够进行解锁操作,将锁的所有者变成 NULL ,而后应用 gomp_mutex_unlock 函数解锁,唤醒之前被阻塞的线程。
- omp_test_nest_lock
int
gomp_test_nest_lock_30 (omp_nest_lock_t *lock)
{
void *me = gomp_icv (true);
int oldval;
if (lock->owner == me)
return ++lock->count;
oldval = 0;
if (__atomic_compare_exchange_n (&lock->lock, &oldval, 1, false,
MEMMODEL_ACQUIRE, MEMMODEL_RELAXED))
{
lock->owner = me;
lock->count = 1;
return 1;
}
return 0;
}
这个不进入内核态获取锁的代码也比拟容易,首先剖析以后锁的拥有者是不是以后线程,如果是那么就将 count 的值加一,否则就应用原子指令看看能不能获取锁,如果可能获取锁就返回 1 ,否则就返回 0 。
源代码函数名称不同的起因揭秘
在下面的源代码剖析当中咱们能够看到咱们真正剖析的代码并不是在 omp.h 的头文件当中定义的,这是因为在 OpenMP 外部做了很多的重命名解决:
# define gomp_init_lock_30 omp_init_lock
# define gomp_destroy_lock_30 omp_destroy_lock
# define gomp_set_lock_30 omp_set_lock
# define gomp_unset_lock_30 omp_unset_lock
# define gomp_test_lock_30 omp_test_lock
# define gomp_init_nest_lock_30 omp_init_nest_lock
# define gomp_destroy_nest_lock_30 omp_destroy_nest_lock
# define gomp_set_nest_lock_30 omp_set_nest_lock
# define gomp_unset_nest_lock_30 omp_unset_nest_lock
# define gomp_test_nest_lock_30 omp_test_nest_lock
在 OponMP 当中一个跟锁十分重要的文件就是 lock.c,当初查看一下他的源代码,你的纳闷就可能揭开了:
#include <string.h>
#include "libgomp.h"
/* The internal gomp_mutex_t and the external non-recursive omp_lock_t
have the same form. Re-use it. */
void
gomp_init_lock_30 (omp_lock_t *lock)
{
gomp_mutex_init (lock);
}
void
gomp_destroy_lock_30 (omp_lock_t *lock)
{
gomp_mutex_destroy (lock);
}
void
gomp_set_lock_30 (omp_lock_t *lock)
{
gomp_mutex_lock (lock);
}
void
gomp_unset_lock_30 (omp_lock_t *lock)
{
gomp_mutex_unlock (lock);
}
int
gomp_test_lock_30 (omp_lock_t *lock)
{
int oldval = 0;
return __atomic_compare_exchange_n (lock, &oldval, 1, false,
MEMMODEL_ACQUIRE, MEMMODEL_RELAXED);
}
void
gomp_init_nest_lock_30 (omp_nest_lock_t *lock)
{
memset (lock, '\0', sizeof (*lock));
}
void
gomp_destroy_nest_lock_30 (omp_nest_lock_t *lock)
{
}
void
gomp_set_nest_lock_30 (omp_nest_lock_t *lock)
{
void *me = gomp_icv (true);
if (lock->owner != me)
{
gomp_mutex_lock (&lock->lock);
lock->owner = me;
}
lock->count++;
}
void
gomp_unset_nest_lock_30 (omp_nest_lock_t *lock)
{
if (--lock->count == 0)
{
lock->owner = NULL;
gomp_mutex_unlock (&lock->lock);
}
}
int
gomp_test_nest_lock_30 (omp_nest_lock_t *lock)
{
void *me = gomp_icv (true);
int oldval;
if (lock->owner == me)
return ++lock->count;
oldval = 0;
if (__atomic_compare_exchange_n (&lock->lock, &oldval, 1, false,
MEMMODEL_ACQUIRE, MEMMODEL_RELAXED))
{
lock->owner = me;
lock->count = 1;
return 1;
}
return 0;
}
总结
在本篇文章当中次要给大家剖析了 OpenMP 当中两种次要的锁的实现,别离是 omp_lock_t 和 omp_nest_lock_t,一种是简略的锁实现,另外一种是可重入锁的实现。其实 critical 子句在 OpenMP 外部的也是利用下面的锁实现的。整个锁的实现还是非常复杂的,外面有很多回味无穷的细节,这些代码真的很值得一读,看看能操刀 OpenMP Runtime Library 这些编程巨匠的作品,真的很有播种。
更多精彩内容合集可拜访我的项目:https://github.com/Chang-LeHu…
关注公众号:一无是处的钻研僧,理解更多计算机(Java、Python、计算机系统根底、算法与数据结构)常识。
发表回复