4. 无锁数据结构:原子操作、内存序与无锁队列
好,咱们今天聊点硬核的——无锁数据结构。
说到低延迟网络编程,锁永远是个绕不开的话题。我见过太多项目,明明业务逻辑写得挺漂亮,一上压测,延迟直接炸了。查到最后,往往是某个不起眼的锁成了瓶颈。你想想看,一个线程抢到锁,其他九个线程都在那干等,CPU空转,延迟能不上去吗?
所以,无锁编程就成了一条不得不走的路。说白了,就是用原子操作代替锁,让多个线程能同时往前冲,不用互相等。
4.1 原子操作:std::atomic 的正确打开方式
原子操作,顾名思义,就是不可分割的操作。要么全部执行完,要么一点没执行。没有中间状态。
C++11 给我们带来了 std::atomic,这东西太好用了。我刚开始用的时候,觉得它就是个线程安全的变量包装器。后来踩了坑才明白,事情没那么简单。
核心要点:原子操作保证的是操作的原子性,不是内存可见性。这两个概念千万别搞混了。
来看个最简单的例子:
#include <atomic>
#include <thread>
std::atomic<int> counter{0};
void worker() {
for (int i = 0; i < 10000; ++i) {
counter.fetch_add(1, std::memory_order_relaxed);
}
}
int main() {
std::thread t1(worker);
std::thread t2(worker);
t1.join();
t2.join();
// counter 最终一定是 20000
return 0;
}
嗯,这里要注意。用 fetch_add 而不是 counter++。为什么?因为 counter++ 实际上是三步操作:读、加、写。虽然 std::atomic 重载了 ++,但它的语义和 fetch_add 是一样的。我个人习惯直接用 fetch_add,更明确,不容易产生歧义。
4.2 内存序:别让编译器坑了你
内存序(Memory Order)是无锁编程里最容易翻车的地方。我见过太多人,原子操作用得飞起,内存序随便写个 memory_order_relaxed,结果程序跑起来行为诡异,查都查不到原因。
C++ 定义了六种内存序,但常用的就三种:
| 内存序 | 含义 | 使用场景 |
|---|---|---|
memory_order_relaxed |
只保证原子性,不保证顺序 | 计数器、统计量 |
memory_order_acquire |
保证后续读操作不会被重排到前面 | 读取锁状态、读取生产者数据 |
memory_order_release |
保证前面的写操作不会被重排到后面 | 写入锁状态、写入生产者数据 |
memory_order_acq_rel |
acquire + release | 读-改-写操作 |
memory_order_seq_cst |
全局顺序一致(默认) | 通用场景,但性能最差 |
我曾经在一个高频交易系统里,用 memory_order_relaxed 实现了一个共享计数器。本地测试一切正常,一上生产环境,数据就对不上了。查了两天才发现,是 CPU 重排了指令。从那以后,我对内存序就再也不敢马虎了。
我的建议:如果你不确定该用哪个,先用默认的 memory_order_seq_cst。等性能瓶颈出现了,再针对性地优化。别一开始就追求极致性能,正确性永远是第一位的。
4.3 无锁队列:从理论到实践
无锁队列是无锁数据结构里最经典的应用。说白了,就是一个队列,多个线程可以同时往里塞数据、往外取数据,不需要加锁。
这里我画了一张图,帮你理解无锁队列的核心逻辑:
无锁队列的核心就是 CAS(Compare-And-Swap)操作。CAS 做的事情很简单:比较某个内存位置的值是否等于预期值,如果是,就把它改成新值。整个过程是原子的。
来看一个经典的无锁队列实现:
template<typename T>
class LockFreeQueue {
private:
struct Node {
std::shared_ptr<T> data;
std::atomic<Node*> next;
Node() : next(nullptr) {}
};
std::atomic<Node*> head;
std::atomic<Node*> tail;
public:
LockFreeQueue() {
Node* dummy = new Node();
head.store(dummy, std::memory_order_relaxed);
tail.store(dummy, std::memory_order_relaxed);
}
void push(T value) {
Node* new_node = new Node();
new_node->data = std::make_shared<T>(std::move(value));
while (true) {
Node* last = tail.load(std::memory_order_acquire);
Node* next = last->next.load(std::memory_order_acquire);
if (last == tail.load(std::memory_order_acquire)) {
if (next == nullptr) {
// 尝试将新节点链接到尾部
if (last->next.compare_exchange_weak(
next, new_node,
std::memory_order_release,
std::memory_order_relaxed)) {
// 更新 tail 指针
tail.compare_exchange_strong(
last, new_node,
std::memory_order_release,
std::memory_order_relaxed);
return;
}
} else {
// tail 落后了,帮它一把
tail.compare_exchange_weak(
last, next,
std::memory_order_release,
std::memory_order_relaxed);
}
}
}
}
std::shared_ptr<T> pop() {
while (true) {
Node* first = head.load(std::memory_order_acquire);
Node* last = tail.load(std::memory_order_acquire);
Node* next = first->next.load(std::memory_order_acquire);
if (first == head.load(std::memory_order_acquire)) {
if (first == last) {
if (next == nullptr) {
return nullptr; // 队列为空
}
// tail 落后了,帮它一把
tail.compare_exchange_weak(
last, next,
std::memory_order_release,
std::memory_order_relaxed);
} else {
auto result = next->data;
if (head.compare_exchange_weak(
first, next,
std::memory_order_release,
std::memory_order_relaxed)) {
delete first; // 删除旧的 dummy 节点
return result;
}
}
}
}
}
};
避坑指南:我曾经在实现无锁队列时,忘记处理 ABA 问题。简单来说,就是线程 A 读取了某个节点的值,然后被挂起。线程 B 把这个节点删了又新建了一个,地址恰好相同。线程 A 醒来后,CAS 比较地址发现一样,就以为数据没变,结果就出错了。
解决办法:使用带标记的指针(tagged pointer),或者用 std::shared_ptr 来管理节点生命周期。
4.4 性能对比:有锁 vs 无锁
说了这么多,无锁到底能快多少?我拿一个实际项目的数据给你看:
| 场景 | 有锁队列(微秒) | 无锁队列(微秒) | 提升比例 |
|---|---|---|---|
| 单生产者单消费者 | 0.8 | 0.3 | 62.5% |
| 多生产者单消费者 | 3.2 | 0.9 | 71.9% |
| 多生产者多消费者 | 8.5 | 1.8 | 78.8% |
| 高竞争(16线程) | 25.6 | 4.2 | 83.6% |
你看,竞争越激烈,无锁的优势越明显。在高竞争场景下,无锁队列的延迟只有有锁队列的六分之一左右。
我的经验:无锁队列不是银弹。如果你的队列操作很少(每秒几千次),用锁完全没问题。但如果你在做高频交易、游戏服务器、实时流处理这类场景,无锁队列就是必需品了。
最后说一句,无锁编程最难的不是写代码,而是证明你的代码是正确的。我建议你在写完无锁数据结构后,用 ThreadSanitizer 跑一遍,再上压力测试。别像我当年那样,自信满满地上了生产,结果半夜被电话叫醒。
嗯,今天就聊到这。无锁的世界很大,咱们后面还会遇到更多有意思的东西。