随着多核处理器的发展,多线程编程成为了一种常见的编程方式。但是,多线程编程必然面临数据同步问题,锁作为常见且易用的同步机制,在多线程编程中扮演着重要的角色。但是,锁的开销大,性能较差。原子变量作为一种低开销的同步机制,在多线程编程中也扮演着重要的角色。
但是原子变量尤其是内存序晦涩难懂,无形中制造了学习障碍。我之前也写过很多关于原子变量的文章:
但是回过头来看,也仅仅停留在了介绍怎么用的基础上,并不能帮助理解。所以,我决定抛砖引玉,写这一篇文章,用于理解性记忆并使用原子变量。
不要想当然的认为代码的书写顺序就是代码的执行顺序,代码的书写顺序和代码的执行顺序是两个不同的概念。 书写的代码到运行中的从程序,避免经过Build和Run两个阶段。而这两个阶段均涉及到代码顺序的调整:
虽然指令重排和乱序执行是为了提高程序性能,不会乱来,但是也带来了困扰——无法确认代码的执行顺序。尤其是在多线程的情况下,这个影响变得更加严重。
在多线程环境下,为了保证变量更改的可见性,避免数据竞态,通常会对共享数据加锁,以保证同一时间只有一个线程可以访问共享数据。
std::mutex mtx;
int value = 0;
void thread_func() {
mtx.lock();
value++;
mtx.unlock();
}
int main() {
std::thread t1(thread_func);
std::thread t2(thread_func);
t1.join();
t2.join();
std::cout << "value: " << value << std::endl;
return0;
}
锁通过加锁lock解锁unlock操作(RAII原理的各种guard类似),来保证同一时间只有一个线程可以访问共享数据。那么它是如何做到的呢?
所得学习成本低,很多同学必定知道如上的两个流程,那么如上的流程是否可以迁移到我们理解原子变量,尤其是原子序呢?答案是可以的。
原子变量只能够保证操作的原子性,如果需要进行多线程同步,则需要借助内存序,接下来将分别介绍原子操作和内存序。
原子变量支持的操作有三类:读、改和读-改-写。原子操作只能保证操作的原子性——不可被打断,同时保证同一线程对同一原子变量的访问顺序不会重排。
锁实现的同步是借助获得——释放语义实现的,原子变量的线程同步则依赖内存序——获得语义和释放语义。C++11标准中定义了六种内存序,分别是:
如上的内存序都与获得和/或释放语义有关,我们又知道如何使用锁,那我们就可以借助锁的获得——释放逻辑来应用原子变量的内存序。
#include <atomic>
#include <thread>
#include <iostream>
std::atomic<int> x{0};
std::atomic<int> y{0};
void write_x_then_y() {
x.store(1, std::memory_order_relaxed); // 先写x
y.store(1, std::memory_order_release); // 再写y,并用release语义发布
}
void read_y_then_x() {
while (y.load(std::memory_order_acquire) != 1) {
// 自旋等待y被写入
}
// 由于acquire语义,读到y==1后,保证此线程能看到x==1
if (x.load(std::memory_order_relaxed) == 1) {
std::cout << "观察到x==1,顺序被保证" << std::endl;
} else {
std::cout << "未观察到x==1,顺序未被保证" << std::endl;
}
}
int main() {
x = 0;
y = 0;
std::thread t1(write_x_then_y);
std::thread t2(read_y_then_x);
t1.join();
t2.join();
return0;
}
//output
// 观察到x==1,顺序被保证
如上代码借助原子变量y的memory_order_release和memory_order_acquire,实现了获得——释放语义,进行保证了变量x的同步。
对于原子变量的使用建议很简单,就2条:
本文从锁的lock和unlock方法开始,引入了获得——释放语义,然后介绍了原子变量的原子操作和内存序,期望能够在理解锁的基础上,理解原子变量的内存序。最后提出了原子变量的使用建议。
参考文献