并发问题 研究报告
问题回答
- 并发问题产生的根源
- 硬件层面:
o 多核处理器:现代计算机通常配备多个处理核心,这使得多个线程可以真正并行执行。然而,这也带来了数据共享和同步的问题。
o 缓存一致性:每个核心有自己的缓存,多核处理器通过缓存一致性协议(如MESI协议)来保持缓存的一致性,但这可能导致数据同步延迟和缓存行“跳跃”(cache line bouncing)。
o 指令重排序:为了提高性能,处理器可能会对指令进行重排序执行,导致程序的执行顺序与代码顺序不一致。 - 编译器层面:
o 优化重排序:编译器为提升性能,可能会对指令进行重排序,优化掉看似冗余的代码,影响多线程程序的执行顺序。
o 寄存器缓存:编译器可能将变量存储在寄存器中,而不是内存,导致其他线程无法及时看到变量的最新值。 - 软件层面:
o 缺少同步机制:线程间共享数据时,如果未能使用适当的同步机制(如锁、原子操作等),可能会导致数据竞争和不一致。
o 死锁与活锁:不当的锁使用可能导致线程之间的互相等待(死锁)或频繁重新尝试获取锁而无法前进(活锁)。 - 并发本质:
o 线程间共享数据访问的时序不确定
o 缺乏统一的"现在"概念
o 多线程程序的执行顺序不可预测 - 示例代码分析
int v;
bool v_ready = false;
void threadA() {
// Write the value
// and set its ready flag.
v = 42;
v_ready = true;
}
void threadB() {
// Await a value change and read it.
if (!v_ready) {
/* wait */
} else {
/*do something else */
}
const int my_v = v;
// Do something with my_v...
}
这段代码可能出现以下问题:
- 可见性问题:
o threadA写入的v值可能不会立即对threadB可见
o 缓存一致性协议可能导致延迟 - 重排序问题:
o 编译器可能重排v = 42和v_ready = true的顺序
o CPU也可能重排这些指令的执行顺序 - 原子性问题:
o v_ready的读写不是原子操作
o 可能出现"撕裂读写" - 影响并发正确性的计算机技术
- 编译器优化:
o 死代码消除
o 指令重排序
o 寄存器分配 - 多级缓存:
o 缓存一致性延迟:多个核心对同一内存地址进行访问时,必须保持缓存一致性,这可能导致数据同步的延迟。
o 假共享:不同线程频繁访问同一缓存行中的不同变量,导致缓存行频繁在核心之间切换,降低性能。 - 处理器架构:
o 很多处理器支持超标量执行、乱序执行
o 非一致性内存访问(NUMA)架构允许多个核心同时访问内存,但不同核心对内存的访问速度可能不同,使得数据在不同核心的缓存中分布不均,可能引发频繁的缓存同步和数据迁移,降低性能 - "no consistent concept of ‘now’"的理解
在多线程程序中,特别是运行在多核CPU上的程序,由于硬件(如多核处理器、缓存一致性)、编译器优化和编程语言的内存模型等多种复杂因素的影响,不同线程对“当前时刻(now)”的理解和看到的数据状态可能是不一致的。这意味着,线程之间没有一个统一的、全局的“现在”概念,导致线程之间的操作顺序和数据可见性难以保证。
揭示了并发系统的本质:时序的相对性。即
o 不同线程看到的操作顺序可能不同
o 没有绝对的全局时钟
需要硬件(内存屏障)、编译器(优化控制)、编程语言(原子操作、同步原语、内存模型)和应用程序共同努力才能建立确定的顺序。 - "保序"和"原子性"
- 保序:
o 确保多线程间的操作按期望顺序执行
o 通过内存屏障、同步原语、原子操作等机制实现
o 可以有不同强度的顺序保证(如sequential_consistent、acquire/release等) - 原子性:
o 操作要么完全执行,要么完全不执行;不能被其他线程观察到中间状态
o 保证了多个线程对同一变量的读写不会引发数据竞争
o 对基本类型有原子变量和原子指令,对高级类型可以用锁实现 - C/C++的原子态运算能力
C11引入stdatomic.h:
#include <stdio.h>
#include <stdatomic.h>
#include <pthread.h>
_Atomic int acnt;
int cnt;
void *inc(void *input)
{
for(int i=0; i<1000; i++) {
acnt++;
cnt++;
}
pthread_exit(NULL);
}
#define N 100
int main(void) {
pthread_t tid[N];
for (int i=0; i<N; i++)
pthread_create(&tid[i], NULL, inc, NULL);
for(int i=0; i<N; i++)
pthread_join(tid[i], NULL);
printf("the final value of acnt with atomic guarantee is %d\n", acnt);
printf("the final value of cnt without atomic guarantee is %d\n", cnt);
return 0;
}
执行结果:
the final value of acnt with atomic guarantee is 100000
the final value of cnt without atomic guarantee is 98327
后者还可能是99481、98851等等。没有原子运算保证。
用compiler explorer可以发现两句自增在x86-64 gcc 14.2 -O2上实现的分别是
lock add DWORD PTR acnt[rip], 1
add DWORD PTR cnt[rip], 1
C++11引入的原子操作支持:
#include <atomic> // C++11
// 原子类型声明
std::atomic<int> counter;
// 原子操作示例
counter.fetch_add(1); // 原子自增
counter.exchange(42); // 原子交换
- 撕裂性读写(torn reads/writes)
定义: 当一个线程正在写入一个大于CPU字长的变量时,另一个线程读取到部分更新的值。
示例:
// 在32位机器上
uint64_t counter; // 64位整数需要两次32位操作才能完成读写
// 可能导致撕裂读写,一个线程看到counter的前32位是新值,后32位还是旧值
危害:
• 数据不一致
• 程序行为不可预测
• 可能破坏程序的安全性假设
8. RMW (Read-Modify-Write) 操作
常见的RMW操作包括:
- Exchange: 原子交换值
- Compare-and-Swap (CAS): 比较并交换
- Fetch-and-Add: 获取并增加
- Test-and-Set: 测试并设置
应用场景
• 无锁数据结构:如无锁队列、栈、哈希表等,通过RMW操作实现线程安全的数据操作。
• 原子计数器:用于统计、引用计数等,需要多个线程同时更新计数器的场景。
• 信号量和条件变量:通过RMW操作实现高效的信号同步机制。 - 无锁并发与锁的比较
无锁并发不一定总是更快,但具有以下优势: - 避免死锁: 无需担心锁的获取顺序
- 容错性: 某个线程崩溃不会导致其他线程阻塞
- 实时性: 适用于对响应时间要求严格的场景
缺点则有 - 实现复杂:
o 无锁算法通常比加锁算法更复杂,难以理解和实现,容易引入新的错误。 - 有限的应用场景:
o 适用于特定的数据结构和操作,如原子计数器、无锁队列等,不适用于所有并发场景。 - ABA问题:
o 无锁算法中,使用CAS操作时可能会遇到ABA问题,即变量先被修改为其他值再修改回原值,导致CAS错误判断。 - 假阳性(False Positives):
o 特别是在使用LL/SC指令时,可能会因为缓存行冲突等原因导致SC指令错误地失败,增加重试开销。
常应用在高并发场景和需要严格控制响应时间的实时系统中 - ARM等弱序系统的顺序一致性
通过内存屏障(Memory Barrier)实现:
// ARM汇编示例
ldr r3, <&foo> // 加载地址
dmb // 数据内存屏障
ldr r0, [r3] // 加载值
dmb // 数据内存屏障 - 内存屏障(dmb)作用
弱序硬件(如ARM架构)允许指令和内存操作进行乱序执行。
串行一致性(Sequential Consistency, SC)是指多线程程序中的操作按照全局一致的顺序执行,就像是所有线程的操作在一个单一的时间线上一样,保证每个线程都按应有的顺序看到其他线程的操作。
弱序硬件上可以使用dmb等指令保证串行一致性。dmb的作用包括 - 防止重排序: 确保屏障前后的内存访问不会重排
- 保证可见性: 确保所有核心看到一致的内存状态
- 同步缓存: 强制更新各级缓存
- LL/SC实现原子RMW
Load-link与store-conditional (LL/SC)是一对用于并发同步访问内存的CPU指令。
LL 指令的功能是从内存中读取一个字,以实现接下来的 RMW(Read-Modify-Write) 操作;SC 指令的功能是向内存中写入一个字,以完成前面的 RMW 操作。
LL/SC 指令的独特之处在于,它们不是单纯地作内存读写。当使用 LL 指令从内存中读取一个字之后,比如 LL d, off(b),处理器会记住 LL 指令的这次操作(会在 CPU 的寄存器中设置一个不可见的 bit 位),同时 LL 指令读取的地址 off(b) 也会保存在处理器的寄存器中。
接下来的 SC 指令,比如 SC t, off(b),会检查上次 LL 指令执行后的 RMW 操作是否是原子操作(即不存在其它对这个地址的操作)。
如果是原子操作,则 t 的值将会被更新至内存中,同时 t 的值也会变为1,表示操作成功;反之,如果 RMW 的操作不是原子操作(即发生异常或者有别的处理器对该地址发了invalid请求),则 t 的值不会被更新至内存中,且 t 的值也会变为0,表示操作失败。
// LL/SC实现RMW
do {
old_val = LL(addr); // 加载并标记监视
new_val = compute(old_val); // 计算新值
} while (!SC(addr, new_val)); // 尝试存储,失败则重试
13. LL/SC指令发生假阳性
LL/SC机制会形成假阳性,即SC指令错误地判定监视的内存地址已被其他处理器或线程修改,即便实际上没有任何修改发生。这会导致SC指令失败,强制线程重试操作,增加了不必要的循环和开销。
假阳性产生的原因
- 缓存行的共享与干扰:
o 当多个处理器或核心频繁访问同一缓存行中的不同变量时,会导致缓存行频繁在处理器之间传输(Cache Coherence Traffic)。
o 即使某些变量并未被实际修改,但由于同一缓存行的其他部分被写入,LL/SC监视的内存地址也会被标记为已修改,导致SC失败。 - 监视器的粒度限制:
o LL/SC机制的监视器通常具有有限的粒度(如缓存行级别)。
o 在较大的缓存行中,如果有局部的修改发生,可能会误认为监视的地址被修改,尽管目标变量未被实际改变。 - 硬件实现的限制:
o 处理器在实现LL/SC指令时,可能会因为其他硬件活动(如上下文切换、中断处理)而错误地标记内存地址为已修改。 - 数据竞争与竞态条件:
o 多线程环境下,若存在实际的数据竞争,SC指令可能会因真实的竞争而失败,但在高争用情况下,SC失败的概率会显著增加。 - 内存模型控制
C++11提供六种内存序:
memory_order_relaxed
memory_order_consume
memory_order_acquire
memory_order_release
memory_order_acq_rel
memory_order_seq_cst // 默认
可以在实践中按需使用。
// 示例1: 最严格的顺序一致性
std::atomic<int> x{0};
x.store(1, std::memory_order_seq_cst);
// 示例2: acquire-release语义
std::atomic<bool> flag{false};
// 生产者
data = prep_data(); // 准备数据
flag.store(true, std::memory_order_release); // 发布
// 消费者
while (!flag.load(std::memory_order_acquire)) {} // 获取
// data现在对消费者可见
use_data(data);
- CAS操作使用不同内存模型的原因
while (!foo.compare_exchange_weak(
expected, expected * by,
memory_order_seq_cst, // 成功时需要全序
memory_order_relaxed)) // 失败时可以放松要求 - 成功路径使用seq_cst:
o 确保CAS操作在所有线程中有一致的顺序
o 保证其他原子操作能观察到这次修改 - 失败路径使用relaxed:
o 失败时只是简单地读取值
o 不需要建立同步关系
o 可以获得更好的性能 - 缓存干扰与读写锁
缓存干扰主要表现为:
• 假共享(False Sharing)
• 缓存行跳动(Cache Line Bouncing)
• 缓存一致性开销
以下面程序段的读写锁为例
struct RWLock {
int readers; // 可能产生缓存行共享
bool hasWriter; // 可能与readers在同一缓存行
};
可能有假共享问题,一种可能的改进是
struct RWLock {
alignas(64) int readers; // 强制在不同缓存行
alignas(64) bool hasWriter;
};
读写锁不一定改进程序的效率,因其可能带来缓存一致性开销:
每次writer获取锁时会使所有reader的缓存失效
频繁的读写切换会导致缓存行颠簸 - volatile修饰符
// 错误使用
volatile int shared_data; // 不能保证线程安全
// 正确使用
volatile int hardware_register; // 用于硬件寄存器访问
std::atomic
要注意,C/C++中的volatile关键字(不同于java的)
• 不保证线程间的内存可见性,不能用作线程同步机制
• 不阻止编译器或处理器重排序
• 不保证读写操作的原子性
• 只保证编译器不优化对该变量的访问,确保每次访问都从内存读取
• 适用于硬件寄存器、信号处理、内存映射I/O等特殊场景
18. Atomic Fusion
指将多个原子操作合并为一个更大的原子操作的期望。
// 不推荐的方式
atomic
int temp = counter.load();
temp++;
counter.store(temp);
// 推荐使用内置的原子操作
counter.fetch_add(1);
最佳实践建议:
- 优先使用标准库提供的原子操作
- 避免自行组合多个原子操作
- 需要复杂原子操作时考虑使用锁
- 仔细考虑性能与复杂度的权衡
文章阅读报告
引言
本报告围绕 Matt Kline 在《What every systems programmer should know about concurrency》中所阐述的关键思想展开。文章重点讨论了在现代硬件架构和编译器优化的环境下,如何正确处理多线程共享数据读写,以确保顺序一致性与原子性,从而编写安全的并发程序。
一、并发与乱序的背景
作者指出,即便在单核场景下,多线程或多进程也可能交替运行;而在多核环境中,各线程更是可能真正并行运行。编译器与 CPU 为了提高执行效率,会对指令及内存访问顺序进行重排或缓存。这样导致一个常见的认知误区:在本线程看来无误的写入顺序,在其他线程却不一定可见。
二、Enforcing Law and Order:保序与原子性 - 顺序一致性(Sequential Consistency)
程序员常假设共享数据的读写在各线程间能保持一致顺序。实际中,编译优化、CPU缓存与乱序执行可能导致读取结果与预期不符,需要使用原子操作或内存屏障来维持可控的访问顺序。通过 C/C++ 中的原子类型(如 std::atomic_bool、std::atomic_int)以及相应的内存序模型,来强制确保多线程间关键变量的访问顺序。 - 原子性(Atomicity)
防止撕裂读写(torn reads/writes)是多线程安全共享数据的前提之一。对于字长无法一次性读写的场景,读写操作可能不是原子的,会导致变量值在“写一半、读一半”时出现不一致。C/C++提供了原子类型与同步原语,以避免这种“撕裂”现象。 - 读-改-写(Read-Modify-Write)操作
常见的读-改-写操作包括 Exchange、Test-and-Set、Compare-and-Swap(CAS)等。它们能在硬件或编译级别确保一次性完成对共享变量的读取并写回。例如,CAS 可在“预期值”匹配时才写入新值,否则返回最新值。这可以实现重试逻辑,广泛应用于无锁结构中。 - 弱序硬件与内存模型
在 ARM 等弱序架构上,CPU 可能会进一步重排加载(Load)与存储(Store)指令。编译器需要插入内存屏障(如 dmb)来维持顺序。此外,C/C++11 提供多种内存模型(如 Acquire、Release、Relaxed、Seq_Cst 等)以灵活控制编译器和硬件的指令重排。 - 无锁并发与缓存效应
无锁并发能减少线程阻塞,但并不总是比加锁更快,因为自旋操作下的竞争冲突与缓存同步(cache coherence)也会产生开销。此外,频繁共享的数据可能引发“假共享”(false sharing),进一步削弱并发性能。 - “volatile”关键字与“Atomic Fusion”
• volatile
“volatile”不具备并发语义,仅阻止编译器优化,不确保线程间顺序一致或原子性,用于并发时可能引发误用。
• Atomic Fusion
指多个原子操作希望一并完成,但单个机器指令往往难以容纳,需注意这些操作并不会被自动合并为单一原子步骤,需要谨慎设计以避免竞态条件。
结语
作者在文中强调:并发程序的正确性不仅依赖代码层面,更依赖硬件缓存、CPU 重排、编译器优化等多重因素的协同管控。对任一层的忽视都可能带来潜在的数据一致性风险。只有结合合适的内存模型使用原子操作或同步原语,才能编写出兼具正确性和可扩展性的并发程序。

浙公网安备 33010602011771号