OpenMP进阶
OpenMP 第二阶段进阶——同步控制、任务并行与嵌套循环优化
摘要
在掌握 OpenMP 第一阶段的 parallel for、reduction、private/shared 和 schedule 后,就可以进入第二阶段。
第二阶段重点解决的问题是:
- 多个线程同时修改共享变量怎么办?
- 一段代码只能一个线程执行怎么办?
- 双重循环外层次数太少怎么办?
- 几个不同任务如何并行执行?
- 递归、树遍历、图搜索这种不规则任务如何并行?
- 线程之间什么时候需要等待,什么时候可以不等待?
本文整理以下知识点:
atomiccriticalsectionstasksingletaskwaitcollapsebarriernowait- 如何避免反复创建并行区域
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 中,如果没有 taskwait,x 和 y 可能还没有算完,就被拿去相加。
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 并行循环”提升到“能判断并行结构和同步开销”的水平。

浙公网安备 33010602011771号