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 四种同步机制的选择策略

说了这么多,到底该怎么选?我一般按这个思路来:

  1. 先问自己:真的需要同步吗?很多时候,通过重新设计数据结构,可以避免同步。比如用局部变量累加,最后再合并。
  2. 如果必须同步:能用atomic就别用critical,能用critical就别用ordered。
  3. barrier是最后的手段:它会让所有线程停下来,开销很大。能用nowait去掉的就去掉。
  4. ordered能不用就不用:它本质上是在并行里插串行,性能损失很大。

我的经验:在GPU编程里,atomic的开销比CPU上大得多。但在OpenMP的CPU场景下,atomic非常高效。我建议你养成习惯:先写atomic,性能不够再考虑升级到critical。别一上来就上大锁。

嗯,同步机制就讲到这里。记住一句话:同步是必要的,但能少用就少用。并行编程的精髓,不是让线程们互相等待,而是让它们各干各的,互不干扰。