OpenMP进阶

OpenMP 第二阶段进阶——同步控制、任务并行与嵌套循环优化

摘要

在掌握 OpenMP 第一阶段的 parallel forreductionprivate/sharedschedule 后,就可以进入第二阶段。

第二阶段重点解决的问题是:

  • 多个线程同时修改共享变量怎么办?
  • 一段代码只能一个线程执行怎么办?
  • 双重循环外层次数太少怎么办?
  • 几个不同任务如何并行执行?
  • 递归、树遍历、图搜索这种不规则任务如何并行?
  • 线程之间什么时候需要等待,什么时候可以不等待?

本文整理以下知识点:

  • atomic
  • critical
  • sections
  • task
  • single
  • taskwait
  • collapse
  • barrier
  • nowait
  • 如何避免反复创建并行区域

1. atomic:保护一个简单更新

先看一个常见错误:

int counter = 0;

#pragma omp parallel for
for (int i = 0; i < n; i++) {
    if (a[i] > 0) {
        counter++;
    }
}

这段代码有数据竞争。

原因是 counter++ 并不是一个真正的单步操作,它大致包含:

读取 counter
counter 加 1
写回 counter

多个线程同时执行时,结果可能丢失更新。

正确写法:

int counter = 0;

#pragma omp parallel for
for (int i = 0; i < n; i++) {
    if (a[i] > 0) {
        #pragma omp atomic
        counter++;
    }
}

atomic 的作用是:

保证某个简单的读改写操作是原子的。

适合场景:

x++;
x--;
x += value;
x -= value;
x *= value;

但是如果是求和,通常优先用 reduction

double sum = 0;

#pragma omp parallel for reduction(+:sum)
for (int i = 0; i < n; i++) {
    sum += a[i];
}

不要优先写成:

double sum = 0;

#pragma omp parallel for
for (int i = 0; i < n; i++) {
    #pragma omp atomic
    sum += a[i];
}

因为 reduction 通常性能更好。


2. critical:保护一整段代码

如果不只是一个简单变量更新,而是一整段代码必须一次只能一个线程执行,可以使用 critical

示例:

#pragma omp parallel for
for (int i = 0; i < n; i++) {
    double value = compute(i);

    #pragma omp critical
    {
        std::cout << "i = " << i << ", value = " << value << std::endl;
        results.push_back(value);
    }
}

critical 的含义是:

同一时刻只有一个线程可以进入这段代码块
其他线程必须等待

适合场景:

  • 多线程写日志
  • 多线程输出调试信息
  • 多线程修改同一个容器
  • 一段复杂逻辑必须互斥执行

但是 critical 会降低并行性能,因为它会把这段代码串行化。

atomic、critical、reduction 的区别

指令 适合场景 性能倾向
atomic 简单变量更新 较好
critical 一整段代码互斥执行 较差
reduction 求和、最大值、最小值等归约操作 通常最好

错误倾向:

double sum = 0;

#pragma omp parallel for
for (int i = 0; i < n; i++) {
    #pragma omp critical
    {
        sum += a[i];
    }
}

更好的写法:

double sum = 0;

#pragma omp parallel for reduction(+:sum)
for (int i = 0; i < n; i++) {
    sum += a[i];
}

3. sections:不同代码块并行执行

parallel for 适合拆分一个循环。

sections 适合拆分几个不同任务。

例如有三个互不依赖的函数:

load_data();
process_image();
process_audio();

可以写成:

#pragma omp parallel sections
{
    #pragma omp section
    {
        load_data();
    }

    #pragma omp section
    {
        process_image();
    }

    #pragma omp section
    {
        process_audio();
    }
}

含义是:

不同 section 可以由不同线程执行

适合:

几个大任务互不依赖
任务数量比较少
每个任务耗时比较明显

不适合:

大量小任务
递归任务
动态生成的任务

这些更适合使用 task


4. task:任务并行

parallel for 适合规则循环。

但有些问题不是规则循环,例如:

  • 递归搜索
  • 树遍历
  • 图遍历
  • 分治算法
  • 动态任务队列

这类情况可以使用 task

4.1 基本结构

OpenMP 任务并行常见结构是:

#pragma omp parallel
{
    #pragma omp single
    {
        // 在这里创建 task
    }
}

为什么需要 single

因为 parallel 区域中每个线程都会执行代码。如果不加 single,每个线程都会重复创建一批任务。

single 的作用是:

只让一个线程执行任务创建逻辑。


5. task 示例:递归 Fibonacci

下面的例子主要用于理解 task,不是高性能 Fibonacci 写法。

#include <iostream>
#include <omp.h>

int serial_fib(int n) {
    if (n < 2) return n;
    return serial_fib(n - 1) + serial_fib(n - 2);
}

int fib(int n) {
    if (n < 20) {
        return serial_fib(n);
    }

    int x, y;

    #pragma omp task shared(x)
    x = fib(n - 1);

    #pragma omp task shared(y)
    y = fib(n - 2);

    #pragma omp taskwait

    return x + y;
}

int main() {
    int result = 0;

    #pragma omp parallel
    {
        #pragma omp single
        {
            result = fib(30);
        }
    }

    std::cout << result << std::endl;

    return 0;
}

几个关键点:

5.1 task

#pragma omp task

表示创建一个任务。该任务不一定由当前线程立即执行,也可能由线程组中的其他线程执行。

5.2 single

#pragma omp single

#pragma omp parallel
{
    //在parallel创建的多线程区域中,只让一个线程去生成三个task
    #pragma omp single
    {
        #pragma omp task
        work1();

        #pragma omp task
        work2();

        #pragma omp task
        work3();
    }
}

表示只让一个线程执行这段代码。

在任务并行中,常用于控制任务的初始创建。

5.3 taskwait

#pragma omp taskwait

表示等待当前任务创建的子任务完成。

在 Fibonacci 中,如果没有 taskwaitxy 可能还没有算完,就被拿去相加。


6. task 使用注意事项

task 并不是创建得越多越好。

如果任务太小,开销可能超过计算收益。

错误倾向:

#pragma omp task
very_small_work();

更合理的做法是设置阈值:

if (problem_size < threshold) {
    serial_solve(problem_size);
} else {
    #pragma omp task
    solve(left_part);

    #pragma omp task
    solve(right_part);

    #pragma omp taskwait
}

经验规则:

任务本身要足够大,才值得创建 task

7. collapse:合并多层循环并行

普通双重循环:

#pragma omp parallel for
for (int i = 0; i < N; i++) {
    for (int j = 0; j < M; j++) {
        A[i][j] = B[i][j] + C[i][j];
    }
}

这主要并行外层 i

如果:

N 很大
M 一般

通常没问题。

但如果:

N = 4
M = 1000000
线程数 = 16

只并行外层时,最多只有 4 个 i 任务,无法充分利用 16 个线程。

这时可以使用:

#pragma omp parallel for collapse(2)
for (int i = 0; i < N; i++) {
    for (int j = 0; j < M; j++) {
        A[i][j] = B[i][j] + C[i][j];
    }
}

collapse(2) 的含义是:

把 i 和 j 两层循环合并成一个更大的迭代空间
总任务数量约等于 N * M
然后分配给多个线程

适合场景:

外层循环次数少
内层循环次数多
每个 (i, j) 之间相互独立

不适合的情况:

for (int i = 0; i < N; i++) {
    for (int j = 0; j < i; j++) {
        work(i, j);
    }
}

因为内层循环范围依赖外层 i,不是规则的矩形循环空间。可以使用schedule(dynamic)实现每个线程负载均衡。


8. barrier:线程同步点

barrier 用于让所有线程在某个位置等待。

示例:

#pragma omp parallel
{
    int id = omp_get_thread_num();

    do_step_1(id);

    #pragma omp barrier

    do_step_2(id);
}

含义是:

所有线程都完成 do_step_1 后
才能继续执行 do_step_2

很多 OpenMP 构造末尾默认带有隐式 barrier。

例如:

#pragma omp parallel
{
    #pragma omp for
    for (int i = 0; i < n; i++) {
        A[i] = f(i);
    }

    // 走到这里时,默认所有线程已经完成上面的 for
}

9. nowait:取消不必要的等待

默认情况下,某些 OpenMP 构造结束时会让线程等待。

例如:

#pragma omp parallel
{
    #pragma omp for
    for (int i = 0; i < n; i++) {
        A[i] = f(i);
    }

    #pragma omp for
    for (int i = 0; i < n; i++) {
        B[i] = g(i);
    }
}

第一个 omp for 结束后,默认存在同步等待。

如果两个循环没有依赖关系,可以写:

#pragma omp parallel
{
    #pragma omp for nowait
    for (int i = 0; i < n; i++) {
        A[i] = f(i);
    }

    #pragma omp for
    for (int i = 0; i < n; i++) {
        B[i] = g(i);
    }
}

nowait 的含义是:

做完第一个循环的线程不必等待其他线程
可以继续向下执行

但如果第二个循环依赖第一个循环的结果,就不能加 nowait

错误示例:

#pragma omp parallel
{
    #pragma omp for nowait
    for (int i = 0; i < n; i++) {
        A[i] = f(i);
    }

    #pragma omp for
    for (int i = 0; i < n; i++) {
        B[i] = A[i] + 1;
    }
}

这里第二个循环读取 A[i],而第一个循环可能还没全部写完,所以存在风险。


10. 避免反复创建并行区域

下面这种写法不太推荐:

for (int t = 0; t < steps; t++) {
    #pragma omp parallel for
    for (int i = 0; i < n; i++) {
        update(i, t);
    }
}

原因是每一轮 t 都进入一次并行区域,可能产生较多开销。

更好的写法:

#pragma omp parallel
{
    for (int t = 0; t < steps; t++) {
        #pragma omp for
        for (int i = 0; i < n; i++) {
            update(i, t);
        }
    }
}

含义是:

只创建一次线程组
在多轮循环中复用这些线程

这种写法在迭代算法、时间步进模拟、重复计算中很常见。


11. 第二阶段判断逻辑

可以按照下面的思路选择 OpenMP 指令:

是普通大循环?
→ parallel for

是求和、最大值、最小值?
→ reduction

是双重或三重循环,并且外层次数太少?
→ collapse

是简单共享变量更新?
→ atomic

是一段复杂代码必须互斥?
→ critical

是几个不同函数并行?
→ sections

是递归、树、图、不规则任务?
→ task

需要所有线程阶段同步?
→ barrier

确定不需要等待?
→ nowait

12. 常用指令总结

指令 解决的问题 典型用途
atomic 简单共享变量更新 counter++
critical 一段代码互斥执行 输出、写日志、修改容器
sections 几个独立大任务并行 同时处理多个模块
task 动态任务并行 递归、树遍历、图搜索
single 只让一个线程执行 创建初始任务、初始化
taskwait 等待子任务完成 分治算法合并结果
collapse 合并多层循环 双重/三重循环并行
barrier 强制线程同步 分阶段计算
nowait 去掉不必要等待 无依赖的连续循环

13. 第二阶段最应该掌握的三个点

第二阶段建议优先掌握:

#pragma omp atomic
#pragma omp critical
#pragma omp parallel for collapse(2)

然后再理解:

#pragma omp task
#pragma omp single
#pragma omp taskwait

前者主要解决同步与嵌套循环并行问题。

后者主要解决递归、树遍历、图搜索等不规则任务并行问题。


14. 小结

OpenMP 第一阶段主要解决“规则循环如何并行”。

OpenMP 第二阶段主要解决:

如何安全地共享数据
如何控制线程同步
如何并行不规则任务
如何优化嵌套循环

常见选择如下:

能用 parallel for,就不要复杂化
能用 reduction,就不要用 atomic/critical 做求和
能用 atomic,就不要用 critical
任务太小,不要轻易 task
没有依赖,才考虑 nowait
外层循环太少,才考虑 collapse

掌握这些后,就可以从“会用 OpenMP 并行循环”提升到“能判断并行结构和同步开销”的水平。

posted @ 2026-05-19 22:47  Ytytyty  阅读(24)  评论(0)    收藏  举报