这一部分的内容对应于 lec 34 的相关内容, (b 大上的参考课程为 [Summer 20])这一部分延续了上一讲关于 TLP 的相关内容,并在 OpenMP 的框架下进行了并行编程,由此展示了并行编程的具体操作和潜在陷阱,并在最后引入了“锁”(Lock) 的概念,为异步并发提供了解决方案。
与前面两讲不同的是,本讲的思路将从具体的框架工具出发,介绍其实践流程以及可针对解决的问题,并在最后给出针对并发的解决方案。
Open MP
事实上,针对并发编程的工具有很多,从 Erlang 到 OpenMP,以及 DL 中常用的 CUDA,都是并行计算的相关工具。但不同语言的侧重领域不同,比如 Erlang 擅长高并发分布式系统,CUDA 专为 GPU 大规模并行计算,而 OpenMP 适合共享内存的多核CPU科学计算,这也是此处讲座选择 OpenMP 进行演示的原因。
What is Open MP?
Open MP(Open Multi-Processing)是一个用于共享内存并行编程的API,它通过编译器指令、库函数和环境变量,简化了多线程程序的开发。主要用于C、C++和Fortran语言,在并行化循环、任务并行、同步工具与数据环境共享等功能下,允许开发者通过简单的指令将串行代码并行化,充分利用多核处理器的计算能力。
How to use?
1.在代码中包含头文件:
#include <omp.h>
2. 编译时添加 OpenMP 支持(例如 GCC):
gcc -fopenmp program.c -o program
3. 使用 #pragma omp parallel
创建并行区域,其中的代码会被多个线程执行:
#include <stdio.h>
#include <omp.h>
int main() {
#pragma omp parallel
{
int thread_id = omp_get_thread_num();
printf("Hello from thread %d\n", thread_id);
}
return 0;
}
4. 使用 #pragma omp parallel for
自动分配循环迭代到线程:
#include <stdio.h>
#include <omp.h>
int main() {
int n = 10;
#pragma omp parallel for
for (int i = 0; i < n; i++) {
printf("Thread %d processes iteration %d\n", omp_get_thread_num(), i);
}
return 0;
}
OpenMP 的参数可以通过环境变量进行设置:
export OMP_NUM_THREADS=4 # Linux/Mac
set OMP_NUM_THREADS=4 # Windows
或直接在代码中设置:
omp_set_num_threads(4);
5. 使用 private
和 shared
子句明确变量作用域:
int main() {
int shared_var = 100;
#pragma omp parallel private(shared_var)
{
// 每个线程有自己的一份 shared_var 副本
printf("%d\n", shared_var);
}
}
常用指令和函数
指令/函数 | 说明 |
---|---|
#pragma omp parallel | 创建并行区域 |
#pragma omp for | 分配循环迭代 |
#pragma omp critical | 互斥临界区 |
#pragma omp atomic | 原子操作 |
omp_get_thread_num() | 获取当前线程ID |
omp_get_num_threads() | 获取总线程数 |
注意事项
- 避免数据竞争:使用
critical
、atomic
或同步机制保护共享数据。 - 负载均衡:循环迭代应尽量均匀分配。
- 嵌套并行:默认禁用,需通过
omp_set_nested(1)
开启
竞态条件(Race Condition)
在了解了并发编程的基本工具框架后,Prof.Dan为我们展示了一个非线程安全的例子,此处用 Java 复现一下是:
// 线程非安全版
public class ThreadUnsafe {
public static int sum = 0;
public static final int THREAD_NUM = 10;
static class sumRunnable implements Runnable{
int lowbound, upbound;
public sumRunnable(int lb, int ub) {
this.lowbound = lb;
this.upbound = ub;
}
@Override
public void run() {
for(int i = lowbound; i <= upbound; ++i) {
sum += i;
}
System.out.println(Thread.currentThread().getName()
+ "执行完毕,当前sum的值为:" + sum);
}
}
public static void main(String [] args) throws InterruptedException {
Thread [] threads = new Thread [THREAD_NUM];
for(int i = 0; i < THREAD_NUM; ++i) {
StringBuilder threadName = new StringBuilder();
threadName.append("Thread ");
if(i < 9) {
threadName.append(' ');
threadName.append(i + 1);
}else {
threadName.append(i + 1);
}
int lb = 10*i + 1, ub = 10*(i + 1);
threads[i] = new Thread(new sumRunnable(lb, ub), threadName.toString());
}
for(Thread t:threads) {
t.start();
}
for(Thread t:threads) {
t.join();
}
}
}
对此程序编译运行两次,可以看到返回的结果为:
// Time 1
Thread 7执行完毕,当前sum的值为:2485
Thread 10执行完毕,当前sum的值为:4195
Thread 9执行完毕,当前sum的值为:5050
Thread 8执行完毕,当前sum的值为:3240
Thread 6执行完毕,当前sum的值为:1830
Thread 2执行完毕,当前sum的值为:210
Thread 3执行完毕,当前sum的值为:465
Thread 4执行完毕,当前sum的值为:1275
Thread 1执行完毕,当前sum的值为:210
Thread 5执行完毕,当前sum的值为:920
// Time 2
Thread 6执行完毕,当前sum的值为:3240
Thread 1执行完毕,当前sum的值为:55
Thread 2执行完毕,当前sum的值为:210
Thread 9执行完毕,当前sum的值为:5050
Thread 8执行完毕,当前sum的值为:2030
Thread 10执行完毕,当前sum的值为:4195
Thread 4执行完毕,当前sum的值为:820
Thread 7执行完毕,当前sum的值为:2685
Thread 5执行完毕,当前sum的值为:1275
Thread 3执行完毕,当前sum的值为:465
当多个线程同时执行 pi += sum[id]
时,结果错误且每次运行都可能不同。
这是因为非原子操作 pi += sum
在底层需要多条指令:LOAD pi, ADD sum, STORE pi
,线程 A 和线程 B 可能同时加载了相同的 pi
值到各自的寄存器.而线程 A 可能先计算完新值并写回 pi
。随后,线程 B(基于旧的 pi
值)计算完,也将其结果写回 pi
,覆盖了线程 A 的更新。最终结果就是线程 A 的工作成果丢失了。
也就是说,竞态条件会发生在多个线程未经协调地读写共享数据,且最终结果依赖于线程执行的具体时序。这是一种非确定性错误。我们知晓了它的存在后我们也没法复现出来,因为你一旦试图观察它,它的行为就变了.
什么薛定谔的程序(
同步(Synchronization)与锁(Lock)
引入这两个概念是为了解决竞态条件,我们需要一种机制来确保临界区(Critical Section)(访问共享资源的代码段)在任何时刻最多只有一个线程在执行。这种机制就是同步。
而锁(Lock/Mutex)是最常用的同步原语。它像一个令牌,谁持有锁,谁就有权进入临界区。它的具体操作包括:
lock()
(或acquire()
): 尝试获取锁。如果锁已被他人持有,则等待(阻塞);否则获得锁并进入临界区。unlock()
(或release()
): 释放锁,让其他等待的线程可以获取它。
需要注意的是锁的实现本身不能依靠软件!比如下面这份代码:
while (lock != 0); // Wait for lock to be 0
lock = 1; // I set the lock!
// ... Critical Section ...
lock = 0; // Release the lock
这个实现本身就有竞态条件! 两个线程可能同时观察到 lock == 0
,然后都执行 lock = 1
,从而同时进入临界区。所以实现一个正确的锁,需要硬件的特殊支持。
CPU 需要提供一条原子指令,能在一个不可中断的总线周期内完成“读-改-写”操作。常见的原子指令有:
- Test-and-Set: 原子地读取一个内存位置的值并将其设置为1。
- Compare-and-Swap: 原子地比较一个内存位置的值是否等于预期值,如果是,则交换为新值。
而这也正是下一讲(TLP III)的内容。硬件提供的这些原子原语(如 RISC-V 的 lr.d
和 sc.d
)是构建所有高级同步机制(锁、信号量、条件变量等)的基石。
总结
这一讲完成了从并行编程理想到现实的过渡,它告诉我们:
- 底层: 所有这些高级和中级的机制,最终都依赖于硬件提供的原子操作指令。没有硬件的帮助,并行同步无从谈起。
- 工具是方便的: 像 OpenMP 这样的框架可以极大地降低并行编程的门槛,让你轻松利用多核性能。
- 问题是固有的: 只要存在共享数据的写操作,竞态条件就如影随形。并行编程的首要任务就是识别和消除竞态条件。
- 解决方案是分层的:
- 高级: 使用 OpenMP 的
critical
指令或reduction
子句可以自动避免一些竞态条件(课件中计算π的正确方法使用了私有变量和归约,就是一种避免策略)。 - 中级: 使用锁等同步原语来手动保护临界区。
- 高级: 使用 OpenMP 的
以上
发表回复