C++ 多线程 std::atomic 工具使用

在多线程编程中,如果共享的数据结构很复杂(比如链表、map、数据库缓存等),不同线程可能同时对它进行插入、删除等操作。

  • 这些操作往往不是一步完成的,而是多个步骤组成的逻辑整体。
  • 为了保证操作的完整性和一致性,必须使用互斥锁(std::mutex)来保护。

总结1:复杂共享数据 → std::mutex 不可或缺。

但如果只是对一个 整数计数器 进行读写,情况就很不同。

  • 每次操作只是一条简单的赋值、加减。
  • 如果还要为此加锁、解锁,显然显得过于“笨重”,既影响性能,又让代码更复杂。

总结2:简单共享数据 → std::mutex 太重了。

为了解决这种 “简单场景却要用锁”的尴尬,C++11 引入了 std::atomic,它比锁更加轻量和高效。

接下来,我们将通过一个小例子来初步认识下 std::atomic 工具。

“启动两个线程,每个线程都对同一个计数器执行一百万次递增操作。”

这是一个简单场景下资源竞争问题。我们接下来使用用两种方式来解决,来观察下哪个代码更简单、性能更好一些:

  • 使用 std::mutex
  • 使用 std::atomic

#include <iostream>
#include <thread>
#include <mutex>

#if 0
std::mutex my_mutex;
int counter = 0;
void worker()
{
	for (int i = 0; i < 1000000; ++i)
	{
		my_mutex.lock();
		++counter;
		my_mutex.unlock();
	}
}
#else
std::atomic<int> counter(0);
void worker()
{
	for (int i = 0; i < 1000000; ++i)
	{
		++counter;
	}
}
#endif

void demo()
{
	auto start = std::chrono::steady_clock::now();

	std::thread t1(worker);
	std::thread t2(worker);
	t1.join();
	t2.join();

	auto end = std::chrono::steady_clock::now();
	// 计算持续时间,单位:毫秒
	auto duration = std::chrono::duration_cast<std::chrono::milliseconds>(end - start);
	std::cout << "counter = " << counter << " time = " << duration.count() << std::endl;
}

int main()
{
	demo();
	return 0;
}

由程序的执行结果,可以得出 2 个小结论:

  1. 对于简单的场景,std::atomic 比 std::mutex 更加高效
  2. 对于简单的场景,std::atomic 比 std::mutex 更加简洁

1. 基本使用

std::atomic 是 C++11 提供的原子类型模板,它保证对单个变量的读写操作具有原子性,提供一系列原子操作方法,从而在多线程环境中能够安全地访问和修改共享数据,而无需显式使用互斥锁。

比如一个普通的 counter = counter + 1 实际上需要三步:

  1. 读取 counter 的值(比如是 5)
  2. 加 1,变成 6
  3. 把 6 写回去

如果没有原子操作,线程 A 执行到第 2 步时,线程 B 刚好也开始执行这三步,它读到的还是旧值 5,结果两个线程都写回了 6,虽然本该变成 7。原子操作能把这三步合成一步,不让别的线程插进来,从而保证结果正确。也就是说:原子操作确保一个线程在操作共享变量时,其他线程必须等它完全执行完,才能继续操作。

注意std::atomic 只能保证一个变量的原子性,如果需要多个变量保持一致,还得用锁。

下面通过几个小例子,快速了解 std::atomic 提供的原子操作方法。

1.1 创建操作

struct Demo
{
	Demo() { std::cout << "Demo 构造函数" << std::endl; }
	int a;
	int b;
};
void demo01()
{
	// 基本类型
	std::atomic<char> ac('H');
	std::atomic<int> ai(0);
	std::atomic<bool> ab(0);
	std::atomic<double> ad(3.14);

	// 指针类型
	std::atomic<char*> ap1(nullptr);
	std::atomic<void*> ap2(nullptr);

	// 自定义类型
	std::atomic<Demo> ao;
}

对于自定义类型,如果类中包含以下成员,则无法作为 std::atomic 模板参数。

  • 类型自定义的拷贝构造、拷贝赋值、移动构造、移动赋值、析构函数
  • 类型包含虚函数
  • 包含引用成员

简单来讲:std::atomic 支持的类型必须是 trivially copyable(即可以通过 memcpy 正确拷贝)

#include <iostream>

struct Demo
{
	// 包含以下内容,该对象不再是简单的对象,atomic 编译会报错
	// Demo(const Demo&) {}
	// Demo(Demo&&) {}
	// Demo& operator=(const Demo&) {}
	// Demo& operator=(Demo&&) {}
	// virtual void func() {}
	// ~Demo() {}
	// int& mem_ref;
};

void test()
{
	std::atomic<Demo> demo;
}

int main()
{
	test();
	return 0;
}

1.2 基本操作

// 1. 基本操作
void demo01()
{
	// atomic 维护了对 int 变量的原子操作
	std::atomic<int> ai(0);

	// 读取值
	// 注意:ai.load() 是原子性操作,但是赋值到 atomic_value 是非原子性的
	int atomic_value = ai.load();
	std::cout << "atomic_value = " << atomic_value << std::endl;

	// 设置值
	// atomic 支持通过 store 函数,也支持通过赋值运算符来为原子变量设置值
	ai.store(20);
	ai = 20;
	// 注意: atomic 并没有重载 << 运算符,这里是先将 ai 转换为 int,再 cout 输出
	std::cout << "atomic_value = " << ai << std::endl;


	// 读取旧值,设置新值,返回旧值
	// 注意:ai.exchange(30) 是原子性操作,返回值赋值到 old_value 是非原子性的
	int old_value = ai.exchange(30);
	std::cout << "old_value = " << old_value << std::endl;
	std::cout << "new_value = " << ai << std::endl;
}


// 2. 不同类型支持的原子操作是不同的
void demo02()
{
	// 2.1 对于 int 类型的原子变量,支持的操作较多
	std::atomic<int> ai(0);
	ai += 10;  // 等价于 ai.fetch_add(10);
	ai -= 10;  // 等价于 ai.fetch_sub(10);
	ai |= 10;  // 等价于 ai.fetch_or(10);
	ai &= 10;  // 等价于 ai.fetch_and(10);
	ai ^= 10;  // 等价于 ai.fetch_xor(10);
	ai++;
	++ai;

	// 2.2 对于 double 类型的原子变量,并不支持上述操作
	std::atomic<double> ad(3.14);
	std::atomic<bool> ab(true);


	// 2.3 指针类型原子操作
	std::atomic<int*> ap(nullptr);
	ap++;
	ap--;
	ap += 2;
	ap -= 2;
	ap.fetch_add(2);
	ap.fetch_sub(2);
}

1.3 CAS 操作

CAS(Compare-And-Swap 或 Compare-And-Exchange)是多线程编程里最核心的 原子操作,用来实现无锁同步。CAS 的操作逻辑是:

原子地执行:
如果 原子变量的当前值 == 预期值(expected)
    则把原子变量更新为新值(desired),并返回 true
否则
    把原子变量当前值写回到 expected,返回 false

函数接口为:

bool compare_exchange_strong(T& expected, T desired);
bool compare_exchange_weak(T& expected, T desired);
  • strong:只要原子变量值和 expected 相等,就一定返回 true,更新成功。
  • weak:即使原子变量值和 expected 相等,也可能因为硬件或优化原因返回 false(虚假失败)。

接下来,通过一个例子,来直观理解 compare_exchange_strong 的用法。

  1. 程序创建了两个线程,每个线程循环 100 次。
  2. 每次循环线程都会尝试对共享原子变量 counter 进行如下操作:
    • 如果 counter == 20,就重置为 0
    • 否则将 counter 自增 1
  3. 为了保证打印输出不混乱,使用 std::mutex 来保护 std::cout
#include <iostream>
#include <thread>
#include <chrono>
#include <mutex>


std::atomic<int> counter(0);
std::mutex my_mutex;


void worker()
{
    for (int i = 0; i < 100; ++i)
    {
        std::this_thread::sleep_for(std::chrono::milliseconds(50));

        int old_value = counter.load();
        int new_value;
        do {
            new_value = (old_value == 20) ? 0 : old_value + 1;
        } while (!counter.compare_exchange_strong(old_value, new_value));

        std::lock_guard<std::mutex> lock(my_mutex);
        std::cout << old_value << "\t" << std::this_thread::get_id() << std::endl;
    }
}


void demo()
{
	std::thread t1(worker);
	std::thread t2(worker);

	t1.join();
	t2.join();
}


int main()
{
	demo();
	return 0;
}

2. 细节探讨

std::atomic 的原子性实现主要依赖两种机制:

  1. CPU 硬件级别的原子操作:现代 CPU(如 x86、ARM)提供多种原子操作指令,可以直接对某些大小和对齐要求的类型实现高效的无锁原子操作
  2. 操作系统级别的同步机制:当类型过大、不满足对齐要求,或目标平台/编译器不支持对应硬件原子指令时,std::atomic 可能通过内部互斥锁(如 std::mutex)模拟原子性,以牺牲性能换取正确性

如何判断当前类型使用的是硬件级别的原子操作,还是系统级别的同步机制?

我们可以通过 std::atomic<T>::is_lock_free() 来判断,如果该函数返回 true 表示使用硬件原子指令实现;返回 false 则可能是基于系统级别的同步机制实现。

Windows(MSVC)通常采用类似下面的规则来判断某个类型是否支持 lock-free:

_TypeSize <= 8 && (_TypeSize & (_TypeSize - 1)) == 0;

  • 类型大小小于等于 8 字节(64 位)
  • 类型型大小为 2 的幂次方(1、2、4、8 字节)。

到这里,有些同学可能会想,C++ 中的类型还有奇数字节大小的?

比如下面的程序中,结构体被设置为 1 字节对齐,导致 Demo 的大小为 5 字节,虽然小于 8 字节,但是不支持 std::atomic

#include <iostream>

// 设置 struct 按照 1 字节对齐
#pragma pack(push, 1) 

struct Demo
{
	int a;
	char c;
};

void test()
{
	// 输出:5
	std::cout << sizeof(Demo) << std::endl;

	std::atomic<Demo> demo;
	// 输出:false
	std::cout << std::boolalpha << demo.is_lock_free() << std::endl;
}

int main()
{
	test();
	return 0;
}

注意:is_lock_free() 是与平台、CPU 架构、编译器实现以及实例内存对齐情况紧密相关的动态判断。

从这里,可以看到,std::atomic 主要是针对简单数据场景下资源竞争问题的一种方案,如果对象较为复杂,这就不是 std::atomic 的目标了。

3. 内存顺序

在多线程编程中,std::atomic 提供了对共享变量的原子操作,确保单次读写不会被中断,从而避免“写一半”“读脏数据”等问题。这是多线程安全的基础保障

但是,仅有原子性还不够,还需要注意两个额外的问题:

  1. 指令重排:编译器或 CPU 为了优化性能,可能调整代码执行顺序。在单线程里没问题,但在多线程中,别的线程可能看到的执行顺序和代码写的顺序不一致,从而出错。
  2. 修改顺序一致性:即使所有修改操作都是原子的,不同线程看到的修改先后次序也可能不一样,就像“历史版本”不统一。如果程序依赖于这个顺序,就可能出错。

3.1 指令重排

编译器和 CPU 为了让程序跑得更快,有时候会偷偷调整代码执行顺序,只要在单线程里程序逻辑不变就行。例如:

int a = 1;
int b = 2;

你写的代码顺序是 a = 1 然后 b = 2,但编译器或 CPU 可能会为了效率,先执行 b = 2,再执行 a = 1,只要这不会影响最终的单线程结果就行。

这些重排在单线程环境下不会影响程序逻辑,但在多线程环境中,可能让代码看起来没问题,但实际上存在严重的问题。这是因为重排会破坏 “我们认为的执行顺序”,导致并发程序中产生不可预测的结果。例如:

std::atomic<bool> ready{ false };
int data = 0;

// 线程 A
void thread_writer() 
{
    // 普通写入
    data = 42;
    // 原子标志位
    ready.store(true);
}

// 线程 B
void thread_reader()
{
    // 等待标志位为 true
    while (!ready.load());
    // 可能触发断言失败!
    assert(data == 42);
}

从代码执行来看,线程 B 看到的是 ready 被修改为 true 时,data 肯定已经先被设置为 42 了。但是,由于指令重排的存在,编译器或 CPU 可能会将线程 A 的执行顺序修改:

void thread_writer() 
{
    // 原子标志位
    ready.store(true);
    // 普通写入(重排到 ready 写操作之后)
    data = 42;
}

此时,thread_reader 会读取到 ready == true,但是 data == 0 的情况,导致触发断言。再回到 std::atomic,它只能保证操作的原子性,对于指令重排可能导致的隐藏的问题,需要通过设置内存顺序来解决。

C++ 中提供了以下四种内存顺序:

  • memory_order_relaxed:当前线程里,编译器和 CPU 可以随便重排这条原子操作的前后指令。
  • memory_order_release:保证这条原子写 之前 的所有普通读写操作,都不能被重排到它后面。
  • memory_order_acquire:保证这条原子读 之后 的所有普通读写操作,都不能被重排到它前面。
  • memory_order_acq_rel:既阻止前面的操作被重排到后面,也阻止后面的操作被重排到前面。

A1;
A2;
// 原子操作前后的代码任意重排。
atomic_var.store(x, std::memory_order_relaxed);
B1;
B2;
A1;
A2;
// 原子操作之前的代码不会被重排到 store 之后。但对 B1、B2 没有约束。
atomic_var.store(x, std::memory_order_release);
B1;
B2;
A1;
A2;
// 保证原子操作之后的代码(B1, B2)不会被重排到 load 之前,但对 A1、A2 没有约束。
atomic_var.load(std::memory_order_acquire);
B1;
B2;
A1;
A2;
// 保证 A1、A2 不会被重排到 store 之后,B1、B2 不会被重排到 store 之前
atomic_var.fetch_add(x, std::memory_order_acq_rel); 
B1;
B2;

3.2 修改顺序一致性

原子操作虽然能保证单次读写的原子性,但并不保证所有线程看到的修改顺序是一致的。

来看一个例子:

std::atomic<int> x{0};

// 线程 T1
x.store(1, std::memory_order_release);

// 线程 T2
x.store(2, std::memory_order_release);

// 线程 A
int a1 = x.load();
int a2 = x.load();

// 线程 B
int b1 = x.load();
int b2 = x.load();

可能出现的情况是:

  • 线程 A 读到的结果是 1 → 2
  • 线程 B 读到的结果是 2 → 1

这说明:

  • 在 A 看来,是 T1 先写了 1,然后 T2 写了 2
  • 在 B 看来,却是 T2 先写了 2,然后 T1 写了 1

换句话说,每个线程都能看到变量被修改过,但“修改的先后顺序”并没有全局统一的共识

这就是所谓的 全局修改顺序不一致问题

如果程序逻辑依赖于“到底是谁先写的”,就可能出错。

为了解决这个问题,可以使用更强的内存顺序:

  • memory_order_seq_cst(Sequentially Consistent)
    它不仅保证原子操作本身的原子性,还额外保证所有线程对这些原子修改的观察顺序是一致的。

这样一来,所有线程都会认为:要么 1 → 2,要么 2 → 1,但不会出现 A 和 B 的看法不一样的情况。

未经允许不得转载:一亩三分地 » C++ 多线程 std::atomic 工具使用
评论 (0)

8 + 1 =