ARM 多核并行编程
从线程模型与 Pthreads 入手,覆盖 Linux 内核同步原语、LDREX/STREX 原子操作,以及 false sharing、死锁、cache thrashing 等常见多核性能陷阱。
1. 为什么操作系统不够用?
在 SMP 系统中,操作系统能够把不同的线程调度到不同 CPU 核心上运行,但它不会自动将一个应用拆分成多个线程。
因此,当系统中只有一个主要应用(dominant application)占据大部分 CPU 时间时,即使拥有多个核心,也可能只有一个核心处于繁忙状态,其余核心大多空闲。这不仅浪费计算资源,也不利于发挥多核处理器的性能优势。
要充分利用多核,开发者需要主动将应用拆分为多个可以并行执行的线程,再由操作系统调度到不同核心运行。
2. Amdahl 定律:并行化的理论上限
Amdahl 定律描述了程序并行化后的理论最大加速比:
1 | Speedup = 1 / ((1 - P) + (P / N)) |
其中,P 为程序中可并行执行的比例,N 为 CPU 核心数。
例如,当 90% 的代码可以并行时,即使使用 4 核 CPU,理论最大加速比也只有 3.08 倍;如果只有 50% 的代码可并行,那么无论增加多少核心,加速比都不会超过 2 倍。
需要注意的是,Amdahl 定律是假设没有任何同步和调度开销的理论上限。实际程序还会受到锁竞争、线程同步、缓存一致性、内存带宽等因素影响,因此实际性能通常会低于理论值。
Amdahl 定律告诉我们:多核带来的性能提升,最终取决于程序中有多少工作能够并行执行,而不是 CPU 核心有多少。
3. 并行任务分解策略:数据、任务与功能分解
并行化的第一步是分解——把程序的工作切分成能并发执行的小块。以下是三种策略,各自适用于不同的程序特征。
3.1 数据分解(Data Decomposition)
适用场景:算法对大量数据执行相同的操作,且每个数据元素的处理独立于其他元素。
典型案例:颜色空间转换(RGB → YUV)
1 | 输入:一个像素数组,每个像素有 R、G、B 三个分量 |
因为像素之间的计算完全独立——不存在数据依赖——你可以把图像切成 N 块(N 为核心数),每个线程处理一块:
1 | 线程 0 → 处理像素 [0, W*H/N) |
实践要点:
- 切分粒度要合理:每个像素开一个线程是愚蠢的(线程创建开销远大于像素转换)
- 尽量让每个线程处理连续的内存块——cache 友好
- 此类任务同时适合 NEON 加速:数据分解 + SIMD 双层并行效果更佳
3.2 任务分解(Task Decomposition)
适用场景:程序中有多个彼此独立的、可并发执行的操作单元。
典型案例:程序启动序列
程序启动时需要做两件事:
- 检查软件许可证是否有效
- 显示包含版权信息的启动横幅
这两个操作互不依赖——许可证检查不需要等横幅画完,横幅也不依赖许可证结果。把它们分到两个线程里,启动时间缩短一半。
任务分解的难点在于识别独立性:你必须分析离散操作之间的交互,找到数据依赖的边界。一旦找到,原代码可以不做修改——只要把它们包装成独立的 pthread_create() 调用即可。
3.3 功能分解 / 软件流水线(Functional Decomposition)
适用场景:算法内部有串行依赖,但依赖是数据的、不是时间的——类似于 CPU 内部的指令流水线。
典型案例:MPEG 视频编码器
MPEG 编码是一个典型的多阶段流水线:
1 | 原始视频帧 |
从一个帧的视角看,各阶段是串行的(前一阶段的输出是后一阶段的输入)。但从多帧的视角看,却是并行的:
1 | 时间轴: |
这本质上是软件流水线:当阶段 1 在处理帧 N+1 时,阶段 2 可以参考帧 N 的数据同时工作。而且在单一阶段内部还可以叠加数据分解——比如运动矢量补偿阶段用多个线程并行处理同一帧的不同区域。
3.4 粒度选择的权衡
无论哪种分解方法,粒度都是关键决策:
| 粒度太细 | 粒度太粗 |
|---|---|
| 线程数爆炸,创建/销毁开销淹没计算 | 并行度不足,核心闲置 |
| 单线程工作量太小,调度开销 > 有效计算 | 负载不均衡——一个慢线程拖累所有人 |
| 数据块太小,cache 局部性差 | 数据块太大,cache 不够用 |
建议是从较大的粒度开始,用 profiler 找到瓶颈后再细化。不要过早优化粒度。
4. 两种线程模型:fork-join 与 workers’ pool
分解完了,下一步是用代码把逻辑块映射为可调度的线程。以下是两种主流模型。
4.1 Fork-Join 模型
注意:这里的 fork 和 UNIX 的
fork()系统调用完全不同。Fork-Join 中的 “fork” 指”派生一个新线程”。
工作方式:需要时创建线程 → 各自执行任务 → 主线程在 join 点等待子线程完成 → 继续执行。
1 | main thread: |
在 Pthreads 中,线程分为两种:
| 类型 | 行为 | 资源回收 |
|---|---|---|
| Detached(分离) | 后台执行,完成后自动终止,不通知父线程 | 自动释放(不含手动分配的数据) |
| Joinable(可汇合) | 执行完成后保留状态,等待父线程调用 pthread_join() |
由 pthread_join() 触发的回收 |
Fork-Join 的开销:
- 线程创建/销毁的系统调用代价
- Join 点的同步等待:一个慢线程拖慢所有人
- 因此,线程必须有足够长的寿命来摊平这些开销
4.2 Workers’ Pool(工作者池)模型
工作方式:应用启动时预先创建一组 worker 线程 → 一个 boss(分发者)将任务分派给空闲的 worker → worker 完成任务后报告 boss、等待新任务。
1 | ┌─────────┐ |
几种变体:
| 变体 | 分发方式 | 特点 |
|---|---|---|
| 推模型 | Boss 主动把任务分配给空闲 Worker | Worker 中断(通知)Boss 表示可用 |
| 拉模型 | Boss 把任务放入队列,Worker 主动来取 | 也称为 Work Queue 模型 |
| 多 Boss | 多个 Boss 共享一个 Worker 池 | 适合多种类型的任务来源 |
Workers’ pool 的优势:
- 消除线程创建/销毁开销——线程只创建一次
- 可动态调节 worker 数量以应对负载峰值
- 适合”持续消费输入数据”的流式处理场景
权衡:即使 worker 处理固定量的数据,数据依赖也可能导致执行时间不同——负载不均衡始终存在。
5. Pthreads 入门
以 Pthreads(POSIX Threads)为示例讲解线程库的使用。Pthreads 是 POSIX 标准(IEEE 1003)的子集,提供一套创建、管理、同步线程的 C API。Linux 的 NPTL(Native POSIX Thread Library)实现了 pthread_create() 与内核任务的一一映射。
5.1 编译与链接
1 |
编译时需要链接 pthread 库:
1 | arm-linux-gcc -o app app.c -lpthread |
5.2 线程的创建与终止
1 | int pthread_create( |
线程终止的两种方式:
- 入口函数正常
return - 调用
pthread_exit(void *retval)
5.3 线程的汇合与分离
1 | int pthread_join(pthread_t thread, void **retval); |
5.4 Mutex(互斥锁)
1 | pthread_mutex_t mutex; |
Pthreads 还支持三种 mutex 类型:NORMAL(默认,不检测死锁/重复加锁)、ERRORCHECK(重复加锁返回错误)和 RECURSIVE(允许同一线程多次加锁,配合计数解锁)。
5.5 Read-Write Lock(读写锁)
读写锁允许多个读者并发持有锁,但写者独占。适合读多写少的场景:
1 | pthread_rwlock_t rwlock; |
5.6 Condition Variable(条件变量)
条件变量是生产者-消费者模型的基础,与 mutex 配合实现”等待某个条件满足”:
1 | pthread_mutex_t mutex; |
要点:pthread_cond_wait() 必须配合 while 循环使用(而非 if),以处理虚假唤醒(spurious wakeup)。
5.7 Barrier(障碍同步)
Barrier 允许多个线程在同步点集体等待,直到所有线程到达后才继续:
1 | pthread_barrier_t barrier; |
典型的应用场景是迭代型并行算法——每次迭代结束后所有线程在 barrier 处同步,确保上一轮结果已全部产出后再开始下一轮。
5.8 Semaphore(信号量)
与 mutex 的关键不同:semaphore 是计数型的,可以从大于 1 的值开始。
1 | sem_t sem; |
5.6 完整示例
1 |
|
6. 多线程性能:你必须注意的三点
多线程编程中有三个非直觉的性能要点:
6.1 线程也有自己的栈
每个线程有独立栈空间。大量线程意味着大量栈内存。默认栈大小通常较大(Linux 上默认 8MB),如果创建数百个线程,栈空间就会吃掉大量虚拟地址。可通过 pthread_attr_setstacksize() 显式设置,但设太小会导致栈溢出。
6.2 Mutex 竞争浪费核心周期
多个线程争抢同一个 mutex/semaphore 时,失败者会阻塞(或 busy-wait)——核心周期被白白浪费。研究领域有大量关于”如何减少锁竞争”的技术,基本思路是:
- 缩短临界区(只锁真正需要保护的最小代码段)
- 用更细粒度的锁(锁数据,而非锁整个数据结构)
- 考虑无锁数据结构
6.3 线程创建本身有开销
pthread_create() 不是免费的——它涉及内核调度、栈分配、上下文初始化。通过预先创建线程池(参见 workers’ pool 模型)可规避这个开销——线程在池中”休眠”,有任务被唤醒执行,完成后返回池中等待再用,而非销毁重建。
7. 同步机制的全景对比
Linux 内核提供了四类同步原语,它们也是 Pthreads 等用户态线程库的底层实现基础。
7.1 Completions(完成量)
Linux 内核特有的轻量级机制,本质是一个”完成信号旗”。
1 | struct completion comp; |
适用场景:两个任务之间”A 等 B 做完某事”的一次性同步。开销极低——等待者可以睡眠。
7.2 Spinlock(自旋锁)
1 | spinlock_t lock; |
核心特征:
- 不睡眠:获取失败后不调用
schedule(),而是循环检查锁状态 - 关抢占:持有 spinlock 期间内核抢占被禁用
- 极短临界区专用(锁持有时间 < 两次上下文切换的时间)
适用场景:中断上下文、极短临界区。绝对不能在持有 spinlock 时睡眠。如果线程和中断共享同一把 Spinlock,线程端应使用 spin_lock_irqsave() / spin_unlock_irqrestore(),避免被中断抢占后再次获取同一把锁而发生死锁。
7.3 Semaphore(信号量)
1 | struct semaphore sem; |
核心特征:
- 会睡眠:获取失败时当前任务让出 CPU,进入等待队列
- 计数型:可以为大于 1 的值(控制同时访问资源的线程数量)
- 适用于临界区执行时间较长的情况
| 维度 | Spinlock | Semaphore |
|---|---|---|
| 失败行为 | Busy-wait(自旋) | 睡眠(让出 CPU) |
| 临界区长度 | 必须极短 | 可以较长 |
| 中断上下文 | 可以 | 不可以(中断上下文不能睡眠) |
| 持有期间抢占 | 禁用 | 允许 |
| CPU 开销 | 浪费核心周期 | 有上下文切换开销 |
7.4 同步原语选择决策树
1 | 需要同步? |
8. ARM 原子操作:LDREX/STREX
无锁数据结构的底层硬件基础是 ARM 的独占式加载/存储指令——LDREX(Load Exclusive)和 STREX(Store Exclusive)。它们配合实现 compare-and-swap(CAS)和 fetch-and-add 等原子操作。
1 | loop: |
STREX 的成功取决于:在 LDREX 和 STREX 之间,是否有其他核心或 DMA 访问了同一独占内存地址。硬件通过本地独占监视器(Local Exclusive Monitor)和全局独占监视器(Global Exclusive Monitor)来追踪独占状态。
GCC 提供了封装这些指令的内置函数:
1 | int old_val, new_val; |
在 ARM 上,__atomic_fetch_add 最终编译为 LDREX / ADD / STREX / CMP / BNE 的重试循环——硬件级的无锁原子操作。
9. 无锁同步:RCU 与 Seqlock
锁不是解决同步问题的唯一手段。在某些场景下,锁本身成为性能瓶颈——尤其是读者远多于写者时。以下是两种 Linux 内核的无锁机制。
9.1 RCU(Read-Copy-Update)
核心思想:写者不直接修改共享数据——而是复制一份、在副本上修改、再原子地发布新版本。读者始终读旧版本或新版本——永远不会读到”正在修改一半”的脏数据。
1 | Reader 视角: |
为什么读者完全无锁? 因为 rcu_dereference() 仅仅是读一个指针——不涉及任何原子操作或锁。写者的 synchronize_rcu() 则昂贵得多——它要等到所有 CPU 都经历一次上下文切换(保证所有已进入临界区的 reader 都已退出)。
适用场景:
- 读多写少(路由表、文件缓冲区、垃圾回收)
- 读者绝不能阻塞
- 能容忍”读到的可能是旧版本”(最终一致性)
不适用场景:
- 写操作频繁
- 临界区很长(延迟旧数据回收)
9.2 Seqlock(顺序锁)
核心思想:读者无锁读取,但读完后验证数据是否被并发修改。如果被改了,重试。
1 | seqlock_t seq; |
关键特征:
- 读者完全无锁、无原子操作
- 读者可能重试(当与写者冲突时)
- 写者排他(同一时刻只有一个写者)
- 适用于”临界区很短、读者极其多”的场景
典型案例:Linux 内核的 jiffies(系统滴答计数器)——被无数读者频繁访问,仅由定时器中断以高优先级写入。用 seqlock 替代 mutex,读者完全不受限。
9.3 无锁 vs 有锁的选择
| 场景 | 推荐机制 |
|---|---|
| 读极多、写极少、数据可复制 | RCU |
| 读极多、临界区短、数据不适合复制 | Seqlock |
| 读写均衡、临界区短 | Spinlock |
| 读写均衡、临界区长 | Mutex/Semaphore |
10. 五大性能陷阱:从带宽到活锁
多核环境下常见的五类性能问题:
10.1 带宽瓶颈(Bandwidth)
根因:集群内所有核心共享同一个外部内存接口,核心运行频率远高于内存。I/O 密集型代码的瓶颈不在计算,在带宽。
解决策略:
- 优化代码本身——减少 cache miss 就是减少带宽消耗(见第 18 章优化技巧)
- 使用线程亲和性(thread affinity)让调度器把线程钉在特定核心上,减少跨核 cache 迁移带来的额外总线流量
10.2 线程依赖与优先级反转
Starvation(饥饿):一个线程反复尝试获取资源,但始终被其他线程抢先,永远得不到执行。
Priority Inversion(优先级反转):经典场景——
1 | 时间线: |
Priority Inheritance(优先级继承)解法:
- 当低优先级线程 L 持有资源 R 而高优先级线程 H 在等 R 时
- L 的优先级临时提升至 H 的级别
- L 可以不受 M 抢占地完成临界区,释放锁
- L 的优先级恢复原值
- H 现在可以获取锁
Linux 内核的 rt_mutex 实现了优先级继承。RTOS 通常内置此功能。
Race Condition:在单核系统上隐式依赖”任务按优先级顺序执行完毕”的代码,迁移到多核后可能失效——低优先级任务和高优先级任务可以同时运行,执行顺序不再有保证。
解法:
- 快速修复:用线程亲和性把所有相关线程绑到一个核心上(恢复单核行为)
- 正确修复:用显式同步机制(completion、semaphore)代替隐式时序依赖
10.3 Cache Thrashing(缓存颠簸)
在 SMP 系统中,一个任务可能被调度器在不同核心之间迁移:
1 | 时刻 T0: 任务在 Core 0 运行 → Core 0 的 L1/L2 缓存填满了任务的数据 |
这种”缓存刚暖就换核心”的模式称为 cache thrashing。内存密集型任务尤甚——它们的数据量足够大,能把每个核心的缓存都”搅乱”一次。
缓解手段:
- 内核调度器尝试保持任务在同一个核心上(CPU affinity hint)
- 开发者显式设置线程亲和性
- CPU 硬件层面的 cache-to-cache migration(在 Cortex-A15/A7 集群中)可以降低迁移开销
10.4 False Sharing(假共享)
这是最隐蔽、也最难排查的性能杀手。它是一种非自愿的内存争用。
场景:
1 | struct counters { |
count_A 和 count_B 在逻辑上完全独立——Core 0 只写 count_A,Core 1 只写 count_B。但物理上它们落在了同一条 cache line(通常 32 或 64 字节)内。
发生了什么:
1 | Core 0 写 count_A → |
解决:
- 用
__attribute__((aligned(64)))确保独立变量不在同一 cache line - 在多线程数据结构的每线程字段之间插入 padding
- 减少内层循环的并行粒度时格外谨慎
10.5 Deadlock 与 Livelock(死锁与活锁)
| Deadlock(死锁) | Livelock(活锁) | |
|---|---|---|
| 现象 | 线程互相等待,永久阻塞 | 线程持续运行,但系统整体没有进展 |
| 原因 | 循环资源依赖 | 不断失败并重试,彼此相互影响 |
| 比喻 | 两辆车在桥上顶牛 | 两人在走廊里不断互相让路,始终过不去 |
活锁举例:
1 | // 线程1 |
死锁的四个必要条件(破坏任一即可预防):
- 互斥:资源不能共享
- 持有并等待:持有锁的同时等待其他锁
- 不可剥夺:锁不能被强制释放
- 循环等待:A 等 B,B 等 C,C 等 A
解决思路:
- 统一锁的获取顺序,避免循环等待。
- 使用
pthread_mutex_trylock(),获取失败时释放已持有的锁并稍后重试,避免长期占有资源。 - 对于无锁算法,可采用指数退避(Exponential Backoff)、随机重试等策略,降低 CAS 冲突,减少活锁发生的概率。
11. 线程亲和性:何时该把线程绑定到指定核心
Thread Affinity(线程亲和性) 是指将线程绑定到指定的 CPU 核心(或核心集合)上运行。
默认情况下,操作系统调度器会根据系统负载在各个核心之间迁移线程,以实现较好的负载均衡。但在线程频繁迁移时,CPU Cache 中的数据可能失效,从而影响性能。因此,在某些场景下,适当设置线程亲和性能够获得更好的性能。
适合设置线程亲和性:
- 线程能够重复利用 Cache 数据,减少 Cache Miss。
- 线程依赖特定核心的本地资源(如 per-CPU 数据结构)。
- 为实时线程保留专用 CPU,减少调度干扰。
不适合设置线程亲和性:
- 大量线程绑定到同一个核心,导致负载不均衡。
- 线程负载变化较大,需要调度器动态迁移线程以充分利用各个核心。
ARM DS-5 Streamline 的 Core Map 视图可以可视化线程亲和性的实际分布——看到哪个核心过载、哪个空闲。
12. 线程安全与可重入性
线程安全(Thread-safe)和可重入(Reentrant)是两个容易混淆的概念,它们关注的问题并不相同。
可重入函数(Reentrant)
可重入函数可以在任意时刻被再次调用(例如被另一个线程调用,或在中断中再次调用),而不会破坏之前调用的执行状态。
通常需要满足:
- 不依赖可修改的全局或静态数据。
- 不在多次调用之间保存状态。
- 不返回指向静态数据的指针。
- 不调用非可重入函数。
线程安全函数(Thread-safe)
线程安全函数允许多个线程同时调用,并始终得到正确的结果。
它可以通过多种方式实现,例如:
- 不共享可变数据(天然线程安全)。
- 使用互斥锁保护共享数据。
- 使用原子操作(Atomic)等无锁同步机制。
两者的关系
1 | 可重入 → 线程安全(必然,因为无需共享数据也就不需要锁) |
典型反例:C 标准库的 ctime() ——每次调用返回一个指向静态缓冲区的指针,新调用会覆盖上次的结果。它不是可重入的,在多线程下会互相覆盖。应使用 ctime_r()(可重入版本)。
在改造单线程程序为多线程时,必须检查所有使用的库函数是否线程安全。如果库没有提供可重入版本,就需要用 mutex 把所有对该库的调用串行化。
13. SMP 系统上的性能剖析
ARM 多核处理器提供了额外的性能计数器,支持以下 SMP 特定事件:
- Coherent linefill missed in all cores:所有核心的缓存中都未命中,必须从外部内存加载
- Coherent linefill hit in other core caches:本核未命中,但数据存在于另一核心的缓存中(cache-to-cache 迁移)
这两个计数器是分析 false sharing 和 cache thrashing 的关键数据来源。ARM DS-5 Streamline 会配置一组默认的硬件性能计数器,针对性地追踪这些 SMP 特有的 cache 一致性事件。
14. 并行化决策框架
将本章知识转化为可操作的工作流:
1 | 1. 识别主导应用:哪个程序占了绝大部分 CPU? |