Skip to content

Commit 74035c5

Browse files
chore: new article written
1 parent fe5e57c commit 74035c5

File tree

1 file changed

+114
-0
lines changed

1 file changed

+114
-0
lines changed
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
---
2+
title: "无锁数据结构的设计原理与实现"
3+
author: "杨其臻"
4+
date: "Sep 28, 2025"
5+
description: "探索无锁编程原理与实现"
6+
latex: true
7+
pdf: true
8+
---
9+
10+
## 副标题:告别锁的阻塞,探索高性能并发数据结构的核心奥秘
11+
12+
在现代并发编程中,锁作为传统的同步机制,虽然简单易用,却常常成为系统性能的瓶颈。锁会导致线程串行化,引发上下文切换的开销,并在高并发场景下产生严重的竞争问题。更糟糕的是,锁可能带来死锁、活锁和优先级反转等致命缺陷,随着多核处理器的普及,这些问题的负面影响被放大,限制了系统的可伸缩性。相比之下,无锁数据结构承诺更高的并发度和可伸缩性,能够避免锁相关的经典问题,对于实时系统和低延迟应用具有关键价值。本文旨在帮助读者理解无锁数据结构的核心设计原理,掌握实现无锁结构的关键技术与挑战,并通过经典案例如无锁队列和无锁栈来剖析实现细节,同时探讨无锁编程的适用场景与潜在陷阱。
13+
14+
## 基石:无锁编程的核心概念
15+
16+
无锁编程的核心概念源于对并发控制的重新思考。学术上,无锁被定义为在系统范围的任意延迟或故障下,保证至少有一个线程能够继续执行。通俗地说,无锁编程不使用互斥锁,而是通过原子操作如比较并交换(CAS)来保证并发正确性。关键特性包括无锁、无等待和无阻塞;无锁保证系统整体进度,无等待则更强,确保每个线程的进度都有保障,而无阻塞是上述两者的统称。这些特性使得无锁编程在高性能计算中备受青睐。
17+
18+
硬件基石是支撑无锁编程的关键,原子操作和内存序构成了其基础。比较并交换(CAS)操作是无锁编程的瑞士军刀,它允许原子地比较一个内存位置的值,并在匹配时更新为新值。其他原子指令如取并加(Fetch-And-Add)和加载链接/存储条件(LL/SC)也在不同架构中发挥作用。内存模型和内存屏障则确保指令执行的正确顺序;指令重排可能导致意想不到的并发问题,因此需要内存屏障来强制内存访问顺序。在 C++ 中,std::memory_order 提供了不同级别的内存序,例如宽松(relaxed)、获取(acquire)、释放(release)、获取释放(acq_rel)和顺序一致(seq_cst),这些枚举值帮助开发者精确控制内存可见性。
19+
20+
## 挑战:无锁编程的“三座大山”
21+
22+
无锁编程虽然强大,却面临三大挑战:ABA 问题、内存回收问题以及复杂度与正确性问题。ABA 问题描述了一个值从 A 变为 B 又变回 A,而 CAS 操作无法感知中间变化,这在链表操作中尤为常见,例如节点被释放后重用可能导致数据损坏。解决方案包括标签指针,它利用指针的高位作为版本号来检测变化;风险指针,线程声明自己正在访问的指针以防止其被回收;以及引用计数,通过原子计数管理对象生命周期,但在无锁环境下实现较为复杂。
23+
24+
内存回收问题涉及当一个线程准备释放内存时,另一个线程可能仍持有该内存的指针并准备访问,这会导致悬空指针和未定义行为。解决方案有多种:风险指针通过线程本地存储来标记正在使用的指针;引用计数在无锁环境下需要原子操作来维护;基于时代的回收(Epoch Based Reclamation)适用于读多写少的场景,它将回收延迟到安全时期;基于静默状态的回收(Quiescent State Based Reclamation)常用于读-复制-更新(RCU)模式,它在线程进入静默状态时回收内存。复杂度与正确性问题体现在代码难以设计和验证,测试困难且竞态条件难以复现,同时对平台和编译器的依赖性强,这要求开发者在实现时格外谨慎。
25+
26+
## 实战:从零实现一个无锁队列
27+
28+
在设计无锁队列时,我们通常选择基于链表的实现,因为它能动态扩展并避免固定容量的限制。队列结构包含头指针和尾指针,指向单链表的起始和结束节点。核心操作包括入队(Enqueue)和出队(Dequeue),这些操作通过原子指令确保并发安全。
29+
30+
数据结构定义是基础,我们使用节点结构来存储数据和下一个指针,队列结构则维护头尾指针。例如,在 C++ 中,我们可以定义节点为包含整型数据和节点指针的结构,队列类则封装头尾指针及其操作。
31+
32+
入队操作涉及三个关键步骤:首先创建新节点,然后循环使用 CAS 更新尾节点的 next 指针,最后处理“滞后尾”问题,即通过另一个 CAS 循环更新队列的 tail 指针。出队操作则包括读取 head、tail 和 next 指针,判断队列是否为空,循环 CAS 更新 head 指针,并处理出队数据的内存回收,这里可以集成之前讨论的内存回收方案,例如使用风险指针来安全释放内存。
33+
34+
以下是一个简化的 C++ 代码示例,展示无锁队列的核心部分。我们使用 std::atomic 来实现原子操作,并添加详细注释以解释关键步骤。
35+
36+
```cpp
37+
#include <atomic>
38+
39+
struct Node {
40+
int data;
41+
std::atomic<Node*> next;
42+
Node(int value) : data(value), next(nullptr) {}
43+
};
44+
45+
class LockFreeQueue {
46+
private:
47+
std::atomic<Node*> head;
48+
std::atomic<Node*> tail;
49+
public:
50+
LockFreeQueue() {
51+
Node* dummy = new Node(0);
52+
head.store(dummy);
53+
tail.store(dummy);
54+
}
55+
56+
void enqueue(int value) {
57+
Node* newNode = new Node(value);
58+
while (true) {
59+
Node* currentTail = tail.load(std::memory_order_acquire);
60+
Node* nextTail = currentTail->next.load(std::memory_order_acquire);
61+
if (currentTail == tail.load(std::memory_order_acquire)) {
62+
if (nextTail == nullptr) {
63+
if (currentTail->next.compare_exchange_weak(nextTail, newNode, std::memory_order_release)) {
64+
tail.compare_exchange_weak(currentTail, newNode, std::memory_order_release);
65+
return;
66+
}
67+
} else {
68+
tail.compare_exchange_weak(currentTail, nextTail, std::memory_order_release);
69+
}
70+
}
71+
}
72+
}
73+
74+
int dequeue() {
75+
while (true) {
76+
Node* currentHead = head.load(std::memory_order_acquire);
77+
Node* currentTail = tail.load(std::memory_order_acquire);
78+
Node* nextHead = currentHead->next.load(std::memory_order_acquire);
79+
if (currentHead == head.load(std::memory_order_acquire)) {
80+
if (currentHead == currentTail) {
81+
if (nextHead == nullptr) {
82+
return -1; // 队列为空
83+
}
84+
tail.compare_exchange_weak(currentTail, nextHead, std::memory_order_release);
85+
} else {
86+
int value = nextHead->data;
87+
if (head.compare_exchange_weak(currentHead, nextHead, std::memory_order_release)) {
88+
// 此处需处理内存回收,例如使用风险指针
89+
delete currentHead;
90+
return value;
91+
}
92+
}
93+
}
94+
}
95+
}
96+
};
97+
```
98+
99+
在这段代码中,enqueue 函数通过循环 CAS 确保新节点被正确链接到队列尾部。首先,它加载当前尾指针和其 next 指针,然后检查尾指针是否未被其他线程修改。如果 next 指针为空,则尝试原子地将其设置为新节点,成功后更新尾指针。dequeue 函数类似,它加载头指针和尾指针,检查队列状态,如果队列非空,则原子地更新头指针并返回数据。内存回收部分在出队时删除旧头节点,但实际应用中需集成风险指针等机制以避免 use-after-free 错误。内存序参数如 std::memory_order_acquire 和 std::memory_order_release 确保操作的有序性,防止指令重排带来的问题。
100+
101+
另一种思路是基于数组的无锁环形缓冲区,它通过模运算操作下标实现循环访问。优点是内存连续、缓存友好且实现简单,但缺点在于容量固定,适用于生产者-消费者场景。相比之下,链表实现更灵活,但复杂度更高。
102+
103+
## 进阶:更多无锁数据结构掠影
104+
105+
除了无锁队列,无锁栈是理解无锁编程的理想起点,它通过 CAS 原子地更新栈顶指针来实现推送和弹出操作。无锁哈希表通常采用锁分段思想,将哈希桶划分为独立的无锁结构,如无锁链表,以减少竞争。全无锁哈希表实现更为复杂,但能提供更高的并发度。无锁链表是所有无锁结构的基础,支持完整的增删改查操作,但实现难度大,需要处理 ABA 问题和内存回收等挑战。
106+
107+
## 现实考量:何时使用以及如何正确使用
108+
109+
无锁编程并非银弹,它适用于高并发场景,当锁竞争成为主要瓶颈时,无锁结构能显著提升性能。在需要极低延迟和确定性响应的系统,如实时计算或金融交易中,无锁方案尤为关键。然而,在并发度低、业务逻辑复杂或团队经验不足的情况下,无锁实现可能得不偿失,反而引入难以调试的错误。
110+
111+
最佳实践包括优先使用成熟库如 Intel TBB、Boost.Lockfree 或 Java 的 JUC,以减少自行实现的風險。充分测试是必须的,使用线程检查工具如 TSAN 进行压力测试,以捕捉竞态条件。保持代码简单明了,避免过度优化,同时通过代码审查让多人参与,以发现潜在的并发问题。
112+
113+
114+
无锁编程通过原子操作和 CAS 循环替代锁,以换取更好的可伸缩性,但带来了 ABA 问题、内存回收等新挑战。随着硬件发展,如硬件事务内存(HTM)的兴起,并发编程的未来将更加多元化。最终建议是,在性能瓶颈确实存在且由锁引起时,再谨慎考虑无锁方案,理解原理比盲目使用更为重要。

0 commit comments

Comments
 (0)