4、OpenMP同步机制:barrier、critical、atomic、ordered指令
并行编程里,最让人头疼的往往不是怎么把任务分下去,而是怎么让多个线程不乱抢资源。说白了,同步机制就是给线程们定规矩——谁先谁后,谁能用谁不能用。
OpenMP提供了四种核心同步手段:barrier、critical、atomic、ordered。我做了这么多年并行优化,可以负责任地告诉你:这四种指令用好了,你的程序稳如老狗;用不好,bug能让你调一整天。
4.1 barrier:线程的集合点
barrier,翻译过来就是“屏障”。它的作用很简单:所有线程都跑到这里等着,等最后一个到了,大家再一起往下走。
我印象很深,刚入行时写过一个并行循环,前一半代码算中间结果,后一半代码用这个结果。结果呢?有的线程跑得快,后一半代码已经开始执行了,前一半的数据还没算完。数据全乱套了。
barrier的用法非常直接:
#pragma omp parallel
{
// 第一阶段:计算
compute_partial_results();
// 所有线程在这里等待
#pragma omp barrier
// 第二阶段:使用结果
use_partial_results();
}
注意:barrier不能出现在不同的并行区域之间。它只能在同一个parallel区域内使用。另外,if语句里藏barrier也很危险——万一某个线程没执行到barrier,其他线程就永远等下去了。
其实OpenMP很多指令自带隐式barrier。比如parallel区域的末尾、for循环的末尾,都有隐式的同步点。如果你不需要同步,可以用nowait去掉它,能省不少开销。
4.2 critical:互斥锁的轻量版
critical指令用来保护临界区。说白了,就是一段代码同一时刻只能有一个线程执行。
我曾经接手过一个金融计算项目,里面有个全局累加器。多线程同时往里写数据,结果每次跑出来的结果都不一样。查了半天,就是没加critical保护。
#pragma omp parallel for
for (int i = 0; i < N; i++) {
float result = compute(i);
#pragma omp critical
{
global_sum += result;
}
}
你可以给critical起个名字,避免不同地方的critical互相阻塞:
#pragma omp critical(update_sum)
{
global_sum += result;
}
#pragma omp critical(update_count)
{
global_count++;
}
这样,两个critical可以同时执行,因为它们保护的是不同的资源。
我的建议:critical里的代码越短越好。你想想看,其他线程都在外面等着呢。如果临界区里放了个大循环,那并行效果基本就废了。
4.3 atomic:比critical更轻量
atomic是critical的轻量版。它只适用于简单的读写操作,比如加一、减一、乘除等。为什么说它轻量?因为底层用的是CPU的原子指令,不像critical那样需要操作系统级别的锁。
#pragma omp parallel for
for (int i = 0; i < N; i++) {
#pragma omp atomic
counter++;
#pragma omp atomic
global_sum += data[i];
}
atomic支持的运算符包括:+、-、*、/、&、|、^、<<、>>,以及一些赋值操作如x++、--x等。
| 特性 | critical | atomic |
|---|---|---|
| 适用范围 | 任意代码块 | 简单表达式 |
| 性能开销 | 较高(锁机制) | 较低(CPU指令) |
| 可嵌套 | 可以(有风险) | 有限制 |
核心原则:能用atomic就别用critical。atomic的开销大概是critical的十分之一甚至更低。我做过测试,在百万次累加的场景下,atomic比critical快了将近8倍。
4.4 ordered:让并行循环有序执行
ordered指令有点特别。它让并行for循环里的某一段代码,按照循环变量的顺序依次执行。其他代码还是并行的,只有ordered块是串行的。
什么时候用?比如你要打印日志,希望输出是按顺序的,而不是乱序的。或者某些算法要求按顺序更新状态。
#pragma omp parallel for ordered
for (int i = 0; i < N; i++) {
// 这部分是并行的
float val = compute(i);
#pragma omp ordered
{
// 这部分按i的顺序执行
printf("i=%d, val=%f\n", i, val);
}
// 这部分又是并行的
process(val);
}
避坑指南:我曾经在一个项目里滥用ordered,结果性能比串行还差。为什么?因为ordered强制了顺序执行,相当于把并行变成了串行。记住:ordered只适合那些必须按顺序执行的小片段,千万别把整个循环体都包进去。
另外,使用ordered时,for循环必须加上ordered子句。不然编译器会报错。
4.5 四种同步机制的选择策略
说了这么多,到底该怎么选?我一般按这个思路来:
- 先问自己:真的需要同步吗?很多时候,通过重新设计数据结构,可以避免同步。比如用局部变量累加,最后再合并。
- 如果必须同步:能用atomic就别用critical,能用critical就别用ordered。
- barrier是最后的手段:它会让所有线程停下来,开销很大。能用nowait去掉的就去掉。
- ordered能不用就不用:它本质上是在并行里插串行,性能损失很大。
我的经验:在GPU编程里,atomic的开销比CPU上大得多。但在OpenMP的CPU场景下,atomic非常高效。我建议你养成习惯:先写atomic,性能不够再考虑升级到critical。别一上来就上大锁。
嗯,同步机制就讲到这里。记住一句话:同步是必要的,但能少用就少用。并行编程的精髓,不是让线程们互相等待,而是让它们各干各的,互不干扰。