C语言atomic如何实现无锁并发控制?

99ANYc3cd6
预计阅读时长 22 分钟
位置: 首页 C语言 正文

什么是原子操作?

在并发编程中,当多个线程同时访问和修改同一个共享数据时,可能会发生 竞态条件,一个经典的 "read-modify-write"(读-改-写)操作:

// 假设 value 是一个全局变量,初始值为 0
int value = 0;
// 线程 1
value = value + 1;
// 线程 2
value = value + 1;

如果两个线程几乎同时执行,可能会发生以下情况:

  1. 线程 1 读取 value 的值(得到 0)。
  2. 线程 2 也读取 value 的值(得到 0)。
  3. 线程 1 将 value + 1 的结果(1)写入 value
  4. 线程 2 也将 value + 1 的结果(1)写入 value

value 的值是 1,但我们期望的是 2,这个问题的根源在于 value = value + 1 这个操作在底层不是一步完成的,它包含了“读取”、“计算”、“写入”三个步骤。

原子操作 就是指一个不可中断的操作,它要么完全执行,要么完全不执行,不会被其他线程打断,上面的 value = value + 1 如果是原子操作,那么一个线程在执行时,其他线程必须等待它完成。

为什么需要原子操作?

原子操作是并发编程的基石之一,主要用于:

  1. 避免数据竞争:确保共享数据在并发访问时的一致性。
  2. 实现高效的同步:相比于使用互斥锁,原子操作通常更轻量级,因为它们不涉及线程的上下文切换和内核的介入,性能更高。
  3. 构建高级同步原语:信号量、屏障等高级同步工具都可以用原子操作来实现。

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 定义了六种内存序,通常我们只需要关注最常用的三种:

  1. memory_order_relaxed (宽松内存序)

    • 保证:原子操作的原子性(不会被其他线程打断)。
    • 不保证:任何内存顺序,编译器和处理器可以自由地重排这个原子操作周围的读写指令。
    • 适用场景:当原子操作仅用作计数器,而不作为线程间的同步信号时。
  2. memory_order_acquire (获取内存序)

    • 保证:原子操作的原子性,在该原子操作之后的所有读写操作,都不能被重排到该原子操作之前
    • 适用场景:通常用于“读”操作,当你成功获取一个锁或一个信号量时,使用 memory_order_acquire 来确保后续的操作能看到其他线程在释放锁之前所做的所有修改。
  3. memory_order_release (释放内存序)

    • 保证:原子操作的原子性,在该原子操作之前的所有读写操作,都不能被重排到该原子操作之后
    • 适用场景:通常用于“写”操作,当你释放一个锁或一个信号量时,使用 memory_order_release 来确保所有在释放之前的修改都对其他线程可见。
  4. memory_order_acq_rel (获取-释放内存序)

    • 结合了 acquirerelease 的所有保证
    • 适用场景:用于“读-改-写”操作(如 fetch_add),该操作既需要读取旧值,也需要写入新值。
  5. memory_order_seq_cst (顺序一致性内存序)

    • 最强的内存序,也是默认的内存序。
    • 保证:所有线程看到的原子操作顺序都是一致的,就像所有操作在单个总线上按一个顺序执行一样。
    • 代价:性能开销最大。
    • 适用场景:在不确定使用哪种内存序时,或者需要最强的、最直观的保证时使用。

3 核心函数

原子操作函数通常以 __atomic_atomic_ 开头,这里介绍最常用的 atomic_ 系列函数。

  1. 加载

    • T atomic_load(volatile atomic_T *obj, memory_order order);
    • obj 中读取一个原子值。
  2. 存储

    • void atomic_store(volatile atomic_T *obj, T desired, memory_order order);
    • desired 的值写入 obj
  3. 读-改-写

    • 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 等。
  4. 比较并交换

    • _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) 决定了可见性和顺序,是正确性的关键。
优点 比互斥锁更轻量,性能更高,不涉及线程阻塞。
适用场景 简单的标志位、计数器、无锁数据结构的构建块,对于复杂的临界区,互斥锁可能更易用。

原子操作是现代并发编程的必备技能,理解并正确使用它们,可以让你编写出既高效又正确的并发程序。

-- 展开阅读全文 --
头像
Scratch与C语言,哪个更适合入门编程?
« 上一篇 04-15
dede专题标题颜色如何自定义?
下一篇 » 04-15

相关文章

取消
微信二维码
支付宝二维码

目录[+]