多线程之原子操作与内存序:高性能无锁编程入门
上篇聊了条件变量。今天聊多线程的最后一个基础话题——原子操作。
前面讲的互斥锁、条件变量,都是通过"锁"来保证线程安全。但锁是有开销的——加锁解锁涉及系统调用、线程调度、上下文切换。如果临界区很小,比如只是一个计数器加一,锁的开销可能比实际操作还大。
原子操作就是来解决这个问题的:不用锁,也能保证线程安全。
什么是原子操作
原子操作就是不可分割的操作——要么完全做完,要么完全没做,不存在做到一半被其他线程看到中间状态的情况。
// 非原子操作:多线程下不安全
int counter = 0;
void increment() {
counter++; // 读-改-写三步,不是原子的
// 实际执行:1.读counter 2.+1 3.写回counter
// 两个线程同时做,可能丢失一次更新
}
// 原子操作:线程安全
atomic<int> atomicCounter{0};
void atomicIncrement() {
atomicCounter++; // 一条CPU指令完成,不可分割
}
atomic++在底层通常对应一条CPU指令——x86上的lock inc指令。这条指令执行期间,其他核心无法访问这个内存地址。所以不需要操作系统层面的锁,性能比mutex快得多。
atomic的基本用法
atomic<int> count{0};
atomic<bool> running{true};
atomic<double> temperature{0.0}; // C++20起支持浮点原子
// 基本操作
count.store(42); // 写入
int val = count.load(); // 读取
count.fetch_add(1); // 原子加,返回旧值
count++; // 同上
count += 5; // 原子加5
// 比较并交换(CAS)——无锁编程的核心
int expected = 10;
bool success = count.compare_exchange_strong(expected, 20);
// 如果count==10,把count改成20,返回true
// 如果count!=10,把expected更新为count的当前值,返回false
CAS(Compare-And-Swap)是无锁编程的核心操作。它的逻辑是:如果内存位置的值等于预期值,就更新为新值;否则什么都不做。整个过程是原子的。
很多无锁数据结构都是基于CAS实现的。比如无锁栈的push操作:
template<typename T>
class LockFreeStack {
struct Node {
T data;
Node* next;
};
atomic<Node*> head_{nullptr};
public:
void push(const T& data) {
Node* newNode = new Node{data, head_.load()};
while (!head_.compare_exchange_weak(newNode->next, newNode)) {
// 如果head变了,newNode->next被更新为最新的head
// 然后重试
}
}
};
内存序:原子操作的隐藏考点
原子操作有一个容易被忽略的参数——内存序(memory order)。它决定了原子操作和其他内存操作之间的可见性顺序。
C++定义了六种内存序:
// 最严格:顺序一致性,默认值
count.store(42, memory_order_seq_cst);
// 最宽松:不保证和其他操作的顺序
count.store(42, memory_order_relaxed);
// 释放-获取配对:写端用release,读端用acquire
count.store(42, memory_order_release); // 写端
int val = count.load(memory_order_acquire); // 读端
// release之前的写操作,对acquire之后的读操作可见
默认的seq_cst最安全但也最慢。relaxed最快但不能保证操作顺序。release-acquire配对是中间地带——保证配对点前后的可见性,但不保证全局顺序。
说实话,内存序这个话题非常复杂,涉及到CPU的指令重排、缓存一致性协议、内存屏障这些底层概念。面试里一般不会考得太深,但你要知道有这回事,以及默认的seq_cst在大部分情况下就够用了。
在机器人开发中的应用
在机器人系统里,原子操作最常用于状态标志和计数器:
class RobotController {
atomic<bool> emergencyStop_{false};
atomic<int> frameCount_{0};
public:
void onEmergencySignal() {
emergencyStop_.store(true, memory_order_release);
}
bool shouldStop() {
return emergencyStop_.load(memory_order_acquire);
}
void onNewFrame() {
frameCount_.fetch_add(1, memory_order_relaxed);
}
int getFrameCount() {
return frameCount_.load(memory_order_relaxed);
}
};
emergencyStop用release-acquire配对,确保设置之后所有线程都能立即看到。frameCount用relaxed就够了——帧计数不需要严格的可见性保证,大概准确就行。
顺便提一句,C++20引入了atomic_shared_ptr,让shared_ptr的引用计数操作可以用原子方式完成。之前shared_ptr的引用计数虽然本身是原子的,但如果你想原子地替换整个shared_ptr指向的对象,就得自己加锁。现在有了atomic_shared_ptr,这个操作也是无锁的了。
面试中的关键考点
"原子操作能完全替代mutex吗?"不能。原子操作只适合简单的操作(读写一个变量、加减)。如果临界区涉及多个变量的复杂操作,还是得用mutex。
"atomic的++是线程安全的吗?"是的。atomic的所有操作都是原子的。但注意,如果是atomic<自定义类型>,默认不提供原子操作,需要自己实现。
"无锁编程一定比加锁快吗?"不一定。无锁编程在竞争不激烈的时候很快,但在高竞争场景下,CAS反复重试的开销可能比mutex还大。另外无锁代码更难写、更难调试。
原子操作的性能对比
实际测一下原子操作和mutex的性能差距。在一个简单的计数器场景下(10个线程同时对计数器加一,各加100万次),mutex版本耗时约85毫秒,atomic版本耗时约12毫秒,快了七倍左右。但如果把操作换成"读-判断-写"的复合逻辑(比如只有计数器为偶数时才加一),atomic版本需要用CAS循环重试,性能优势就缩小到两倍左右。
所以选择的时候不要盲目追求无锁——简单的高频操作用atomic,复杂的临界区还是老老实实用mutex。面试的时候能给出这种有数据支撑的分析,比空谈理论有说服力得多。
再补充一个面试加分点:std::atomic_flag的用法。它是C++中最轻量的原子类型,保证lock-free,常用于实现自旋锁。在机器人控制系统里,有些临界区特别小(比如翻转一个bool标志位),用mutex太重了,用atomic_flag实现的自旋锁反而更合适。写法是用test_and_set尝试获取锁,用clear释放锁。不过要注意自旋锁会占用CPU,只适合临界区极短的场景。面试时如果你能对比mutex、atomic、atomic_flag三种方案的适用场景,面试官会觉得你对无锁编程的理解很全面。
给正在准备面试的你
原子操作在面试里不算高频考点,但经常作为多线程话题的延伸被问到。知道atomic的基本用法,了解CAS操作,能讲清楚什么时候用原子操作、什么时候用mutex,面试就足够了。内存序如果面试官不追问,不用主动展开——太深了,大部分人讲不清楚。
下篇聊线程池设计——机器人系统中的标配组件。前面讲的都是手动创建线程,但实际项目里线程的创建和销毁是有开销的,线程池就是用来复用线程的。
如果这篇文章对你有帮助,欢迎点赞、在看、转发三连。 你的支持是我持续更新的最大动力。
「机器人软件开发面试·从入门到精通」连载系列 上一篇:第56篇 多线程之条件变量——生产者消费者模型在传感器数据中的应用 下一篇预告:第58篇 多线程之线程池设计——机器人系统中的标配组件
有任何问题欢迎评论区留言,我会尽量回复。
DAMO开发者矩阵,由阿里巴巴达摩院和中国互联网协会联合发起,致力于探讨最前沿的技术趋势与应用成果,搭建高质量的交流与分享平台,推动技术创新与产业应用链接,围绕“人工智能与新型计算”构建开放共享的开发者生态。
更多推荐

所有评论(0)