什么是原子操作?
在并发编程中,当多个线程同时访问和修改同一个共享数据时,可能会发生 竞态条件,一个经典的 "read-modify-write"(读-改-写)操作:
// 假设 value 是一个全局变量,初始值为 0 int value = 0; // 线程 1 value = value + 1; // 线程 2 value = value + 1;
如果两个线程几乎同时执行,可能会发生以下情况:
- 线程 1 读取
value的值(得到 0)。 - 线程 2 也读取
value的值(得到 0)。 - 线程 1 将
value + 1的结果(1)写入value。 - 线程 2 也将
value + 1的结果(1)写入value。
value 的值是 1,但我们期望的是 2,这个问题的根源在于 value = value + 1 这个操作在底层不是一步完成的,它包含了“读取”、“计算”、“写入”三个步骤。
原子操作 就是指一个不可中断的操作,它要么完全执行,要么完全不执行,不会被其他线程打断,上面的 value = value + 1 如果是原子操作,那么一个线程在执行时,其他线程必须等待它完成。
为什么需要原子操作?
原子操作是并发编程的基石之一,主要用于:
- 避免数据竞争:确保共享数据在并发访问时的一致性。
- 实现高效的同步:相比于使用互斥锁,原子操作通常更轻量级,因为它们不涉及线程的上下文切换和内核的介入,性能更高。
- 构建高级同步原语:信号量、屏障等高级同步工具都可以用原子操作来实现。
C11 中的 <stdatomic.h> 标准头文件
C11 标准首次将原子操作纳入标准库,定义在 <stdatomic.h> 头文件中,它提供了一套统一的接口,使得代码具有更好的可移植性。
1 原子类型
<stdatomic.h> 为 C 的基本类型提供了对应的原子类型,命名规则是在基本类型名前加上 atomic_。
| 基本类型 | 原子类型 |
|---|---|
_Bool |
atomic_bool |
char |
atomic_char |
signed char |
atomic_schar |
unsigned char |
atomic_uchar |
short |
atomic_short |
unsigned short |
atomic_ushort |
int |
atomic_int |
unsigned int |
atomic_uint |
long |
atomic_long |
unsigned long |
atomic_ulong |
long long |
atomic_llong |
unsigned long long |
atomic_ullong |
wchar_t |
atomic_wchar_t |
char16_t |
atomic_char16_t |
char32_t |
atomic_char32_t |
(void *) |
atomic_ptr |
2 内存序
这是原子操作中最重要也最复杂的部分,内存序定义了原子操作与内存访问之间的可见性和执行顺序,直接影响程序的正确性和性能。
C11 定义了六种内存序,通常我们只需要关注最常用的三种:
-
memory_order_relaxed (宽松内存序)
- 保证:原子操作的原子性(不会被其他线程打断)。
- 不保证:任何内存顺序,编译器和处理器可以自由地重排这个原子操作周围的读写指令。
- 适用场景:当原子操作仅用作计数器,而不作为线程间的同步信号时。
-
memory_order_acquire (获取内存序)
- 保证:原子操作的原子性,在该原子操作之后的所有读写操作,都不能被重排到该原子操作之前。
- 适用场景:通常用于“读”操作,当你成功获取一个锁或一个信号量时,使用
memory_order_acquire来确保后续的操作能看到其他线程在释放锁之前所做的所有修改。
-
memory_order_release (释放内存序)
- 保证:原子操作的原子性,在该原子操作之前的所有读写操作,都不能被重排到该原子操作之后。
- 适用场景:通常用于“写”操作,当你释放一个锁或一个信号量时,使用
memory_order_release来确保所有在释放之前的修改都对其他线程可见。
-
memory_order_acq_rel (获取-释放内存序)
- 结合了
acquire和release的所有保证。 - 适用场景:用于“读-改-写”操作(如
fetch_add),该操作既需要读取旧值,也需要写入新值。
- 结合了
-
memory_order_seq_cst (顺序一致性内存序)
- 最强的内存序,也是默认的内存序。
- 保证:所有线程看到的原子操作顺序都是一致的,就像所有操作在单个总线上按一个顺序执行一样。
- 代价:性能开销最大。
- 适用场景:在不确定使用哪种内存序时,或者需要最强的、最直观的保证时使用。
3 核心函数
原子操作函数通常以 __atomic_ 或 atomic_ 开头,这里介绍最常用的 atomic_ 系列函数。
-
加载
T atomic_load(volatile atomic_T *obj, memory_order order);- 从
obj中读取一个原子值。
-
存储
void atomic_store(volatile atomic_T *obj, T desired, memory_order order);- 将
desired的值写入obj。
-
读-改-写
T atomic_fetch_add(volatile atomic_T *obj, T operand, memory_order order);- 从
obj读取旧值,将operand加到旧值上,然后将新值写回obj,并返回旧值。 - 类似的还有
atomic_fetch_sub,atomic_fetch_and,atomic_fetch_or等。
-
比较并交换
_Bool atomic_compare_exchange_weak(volatile atomic_T *obj, T *expected, T desired, memory_order success, memory_order failure);_Bool atomic_compare_exchange_strong(volatile atomic_T *obj, T *expected, T desired, memory_order success, memory_order failure);- 这是原子操作的“瑞士军刀”,它检查
obj的当前值是否等于*expected。- 如果相等,就将
desired写入obj,并返回true。 - 如果不相等,就将
obj的当前值更新到*expected中,并返回false。
- 如果相等,就将
weak版本可能会“虚假失败”(即使值相等也返回 false),但通常性能更好。strong版本保证只有在值不相等时才失败,在循环中使用weak版本是常见的高效模式。
代码示例
示例 1:使用 atomic_fetch_add 实现线程安全的计数器
这个例子解决了我们一开始提到的 value = value + 1 的问题。
#include <stdio.h>
#include <stdatomic.h>
#include <threads.h>
// 定义一个原子整型
atomic_int counter = 0;
// 线程函数
int thread_func(void *arg) {
for (int i = 0; i < 1000000; ++i) {
// 使用 fetch_add 实现原子自增
// memory_order_relaxed 足够,因为我们只关心计数,不关心其他内存的顺序
atomic_fetch_add(&counter, 1, memory_order_relaxed);
}
return 0;
}
int main() {
thrd_t t1, t2;
// 创建两个线程
thrd_create(&t1, thread_func, NULL);
thrd_create(&t2, thread_func, NULL);
// 等待两个线程结束
thrd_join(t1, NULL);
thrd_join(t2, NULL);
// 最终结果应该是 2000000
printf("Final counter value: %d\n", counter);
return 0;
}
示例 2:使用 atomic_compare_exchange_strong 实现简单的自旋锁
自旋锁是一种忙等待锁,当一个线程尝试获取锁但锁被占用时,它会不断地“旋转”(循环检查),直到锁被释放。
#include <stdio.h>
#include <stdatomic.h>
#include <threads.h>
// 原子布尔量,0表示锁可用,1表示锁被占用
atomic_bool lock = ATOMIC_VAR_INIT(0);
void acquire_lock() {
// expected 初始为 0,表示期望锁是未占用的状态
int expected = 0;
// 循环直到成功获取锁
while (!atomic_compare_exchange_strong(&lock, &expected, 1)) {
// CAS 失败,说明锁已被占用
// 将 lock 的当前值(仍然是 1)重新赋给 expected,以便下一次比较
expected = 1;
// 可以在这里加入一些短暂的休眠,避免过度占用 CPU
// thrd_yield();
}
// 成功后,lock 的值变为 1,表示锁已被当前线程持有
}
void release_lock() {
// 释放锁,将值设回 0
atomic_store(&lock, 0, memory_order_release);
}
// 共享资源
int shared_data = 0;
int thread_func(void *arg) {
for (int i = 0; i < 100000; ++i) {
acquire_lock();
// 临界区
shared_data++;
release_lock();
}
return 0;
}
int main() {
thrd_t t1, t2;
thrd_create(&t1, thread_func, NULL);
thrd_create(&t2, thread_func, NULL);
thrd_join(t1, NULL);
thrd_join(t2, NULL);
printf("Final shared_data value: %d\n", shared_data); // 应该是 200000
return 0;
}
stdatomic.h 与 GCC/Clang 内建函数的关系
在 C11 标准化之前,GCC 和 Clang 编译器已经提供了自己的原子操作内建函数(如 __sync_fetch_and_add, __atomic_fetch_add),这些函数非常高效,但可移植性较差。
__sync_系列是旧版的,已不推荐使用。__atomic_系列是较新的,与 C11 标准非常接近,很多编译器直接将其作为 C11 标准实现的底层。
<stdatomic.h> 中的函数通常会编译成这些内建函数,使用 C11 标准接口是最佳实践,因为它保证了代码的可移植性,同时又能享受到编译器优化带来的高性能。
| 特性 | 描述 |
|---|---|
| 目的 | 在并发环境中安全地访问共享数据,避免竞态条件。 |
| 标准 | C11 引入 <stdatomic.h>,提供了可移植的接口。 |
| 核心 | 原子类型、内存序、原子操作函数。 |
| 关键函数 | atomic_load, atomic_store, atomic_fetch_add, atomic_compare_exchange_strong。 |
| 关键概念 | 内存序 (relaxed, acquire, release, seq_cst) 决定了可见性和顺序,是正确性的关键。 |
| 优点 | 比互斥锁更轻量,性能更高,不涉及线程阻塞。 |
| 适用场景 | 简单的标志位、计数器、无锁数据结构的构建块,对于复杂的临界区,互斥锁可能更易用。 |
原子操作是现代并发编程的必备技能,理解并正确使用它们,可以让你编写出既高效又正确的并发程序。
