在高并发的多线程编程中,传统的锁机制(如 std::mutex)常常成为性能瓶颈。锁竞争会导致线程阻塞、上下文切换开销增加,甚至引发死锁问题。为了解决这一问题,无锁编程 (Lock-Free Programming)逐渐成为主流方案。通过 原子操作 (Atomic Operations)和 跳表(Skip List)的结合,避免了显式锁的使用,能真正实现多线程并行访问,是解决高并发场景下有序数据结构性能问题的核心方案。
本文将从跳表基础出发,基于并发场景下,用 C++ 原子操作手把手实现一个线程安全的无锁跳表,并通过性能测试验证其优势。
Part1跳表的基本原理
跳表(Skip List)是一种 "概率性有序数据结构",通过 "多层索引" 实现类似平衡树的 O (logn) 查询性能,但实现更简单。在理解并发版本前,先回顾单线程跳表的核心设计。
1.1、单线程跳表的核心结构
跳表由 "节点" 和 "多层索引" 组成:
- 节点(Node):存储键值对、指向当前层级下一个节点的指针;
- 层级(Level):每个节点随机生成一个层级(如 1~MAX_LEVEL),层级越高,索引范围越广;
- 查询逻辑:从最高层索引开始,若当前节点的下一个节点键值小于目标,则前进;否则下降一层,直到最底层找到目标。
单线程跳表节点定义示例:
template <typename K, typename V>
struct SkipListNode {
K key;
V value;
vector<SkipListNode<K, V>*> next; // 每层的下一个节点指针
// 构造函数:生成随机层级(1~MAX_LEVEL)
SkipListNode(const K& k, const V& v, int level)
: key(k), value(v), next(level, nullptr) {}
};
1.2、跳表的优势
- 高效性:相比红黑树等复杂平衡树,跳表的实现更简单,且易于并行化。
- 扩展性:通过动态调整层级,跳表可以适应不同规模的数据集。
- 并发友好:结合原子操作,跳表可以在高并发场景下避免锁竞争。
1.3、并发跳表的核心挑战
单线程跳表无需考虑线程安全,但多线程场景下需要考虑的三大问题:
- 数据竞争(Data Race):多个线程同时修改同一节点的 next 指针(如插入时修改前驱节点的指针);
- ABA 问题:线程 A 读取到节点 A 的指针为 p,线程 B 将 A 删后又插入新的节点 A(指针仍为 p),线程 A 后续的 CAS 会误判为 "未被修改";
- 内存安全:删除节点时直接释放内存,可能导致其他线程仍在访问该节点(野指针)。
这三大问题正是原子操作要解决的核心 ------ 通过无锁原语保证操作的原子性、可见性和有序性。
Part2原子操作
C++11 引入的 std::atomic 库是实现无锁并发的基础,我们需要重点掌握以下原语:
2.1、核心原子操作原语
|--------------------------------------------------------------|-----------------------------------------|---------------|
| 原语 | 功能描述 | 适用场景 |
| load(memory_order) | 原子读取值,保证可见性 | 读取节点指针 / 键值 |
| store(T, memory_order) | 原子写入值,保证可见性 | 更新节点指针 |
| compare_exchange_weak(T& expected, T desired, memory_order) | 若当前值 == expected,则更新为 desired(弱版本可能伪失败) | CAS 核心操作,修改指针 |
| compare_exchange_strong(...) | 强版本 CAS,仅在值不匹配时失败 | 对正确性要求高的场景 |
2.2、内存序(Memory Order)
内存序决定了原子操作的 "可见性" 和 "有序性",并发跳表中常用以下三种:
- std::memory_order_relaxed:仅保证操作本身原子,无可见性 / 有序性约束(用于非关键的计数);
- std::memory_order_acquire:读取操作,保证后续操作不会重排到该操作前(用于读取节点指针);
- std::memory_order_release:写入操作,保证之前的操作不会重排到该操作后(用于更新节点指针);
- std::memory_order_acq_rel:结合 acquire 和 release,用于 CAS 操作(保证修改前后的内存可见性)。
2.3、ABA 问题的解决方案
最常用的方案是 "指针 + 版本号" 的复合结构(称为 "Tagged Pointer"),将节点指针和版本号打包为一个 64 位值(64 位系统):
// Tagged Pointer:指针(48位)+ 版本号(16位)
template <typename Node>
struct TaggedPtr {
Node* ptr;
uint16_t version;
// 构造函数
TaggedPtr(Node* p = nullptr, uint16_t v = 0) : ptr(p), version(v) {}
// 重载 == 和 != 用于 CAS 比较
bool operator==(const TaggedPtr& other) const {
return ptr == other.ptr && version == other.version;
}
bool operator!=(const TaggedPtr& other) const {
return !(*this == other);
}
};
每次修改指针时,版本号加 1,即使指针相同,版本号不同也会导致 CAS 失败,从而避免 ABA 问题。
Part3基于原子操作的跳表实现
基于上述基础,我们实现一个支持 insert、erase、get 操作的高并发跳表,核心设计如下:
3.1、并发跳表节点设计(核心!)
节点的 next 指针不再是普通指针
而是 std::atomic<TaggedPtr<Node>> 类型,确保多线程修改的原子性:
template <typename K, typename V>
struct ConcurrentSkipListNode {
using Node = ConcurrentSkipListNode<K, V>;
using TaggedPointer = TaggedPtr<Node>;
using AtomicTaggedPtr = std::atomic<TaggedPointer>;
K key;
V value;
vector<AtomicTaggedPtr> next; // 每层的原子 Tagged Pointer
// 构造函数:生成随机层级
ConcurrentSkipListNode(const K& k, const V& v, int level)
: key(k), value(v), next(level, TaggedPointer(nullptr, 0)) {}
// 辅助函数:读取某一层的 next 指针(acquire 内存序)
TaggedPointer get_next(int level) const {
return next[level].load(std::memory_order_acquire);
}
// 辅助函数:CAS 更新某一层的 next 指针(acq_rel 内存序)
bool cas_next(int level, const TaggedPointer& expected, const TaggedPointer& desired) {
return next[level].compare_exchange_strong(expected, desired, std::memory_order_acq_rel);
}
// 辅助函数:直接设置 next 指针(release 内存序)
void set_next(int level, const TaggedPointer& tp) {
next[level].store(tp, std::memory_order_release);
}
};
3.2、跳表主体结构
包含最大层级、当前最高层级(原子类型,多线程共享)、哨兵节点(简化边界处理):
template <typename K, typename V>
class ConcurrentSkipList {
public:
using Node = ConcurrentSkipListNode<K, V>;
using TaggedPointer = TaggedPtr<Node>;
static const int MAX_LEVEL = 16; // 最大层级(可调整)
// 构造函数:初始化哨兵节点(层级为 MAX_LEVEL)
ConcurrentSkipList()
: head(new Node(K(), V(), MAX_LEVEL)),
current_max_level(std::atomic<int>(1)) {}
// 析构函数(简化实现,实际需处理并发内存释放)
~ConcurrentSkipList() {
Node* curr = head;
while (curr != nullptr) {
Node* next = curr->get_next(0).ptr;
delete curr;
curr = next;
}
}
// 核心操作:插入(线程安全)
bool insert(const K& key, const V& value);
// 核心操作:删除(线程安全)
bool erase(const K& key);
// 核心操作:查询(线程安全)
bool get(const K& key, V& value) const;
private:
// 生成随机层级(1~MAX_LEVEL)
int random_level() const;
// 查找前驱节点:返回每层的前驱节点(用于插入/删除)
vector<Node*> find_predecessors(const K& key) const;
// 检查节点是否有效(未被删除)
bool is_valid(const TaggedPointer& tp) const {
return tp.ptr != nullptr;
}
Node* head; // 哨兵节点(最小键)
std::atomic<int> current_max_level; // 当前最高层级(原子更新)
};
3.3、关键辅助函数实现
3.3.1、随机层级生成(概率性层级)
通过位运算实现 "层级越高概率越低"(类似 Redis 跳表的层级生成逻辑):
template <typename K, typename V>
int ConcurrentSkipList<K, V>::random_level() const {
int level = 1;
// 50% 概率提升层级,最多到 MAX_LEVEL
while (level < MAX_LEVEL && (rand() & 1) == 0) {
level++;
}
return level;
}
3.3.2、前驱节点查找(线程安全)
查询插入 / 删除位置时,返回每层的前驱节点,确保多线程查找时的一致性:
template <typename K, typename V>
vector<typename ConcurrentSkipList<K, V>::Node*>
ConcurrentSkipList<K, V>::find_predecessors(const K& key) const {
vector<Node*> predecessors(MAX_LEVEL, head);
Node* curr = head;
// 从当前最高层级开始下降
for (int level = current_max_level.load(std::memory_order_relaxed) - 1; level >= 0; level--) {
// 沿当前层级前进,直到下一个节点键值 >= 目标
while (true) {
TaggedPointer next_tp = curr->get_next(level);
if (is_valid(next_tp) && next_tp.ptr->key < key) {
curr = next_tp.ptr;
} else {
break;
}
}
predecessors[level] = curr;
}
return predecessors;
}
3.4、核心操作:插入(Insert)
插入的核心是 "通过 CAS 原子更新前驱节点的 next 指针",步骤如下:
- 生成新节点的随机层级;
- 查找每层的前驱节点;
- 从最低层到最高层,用 CAS 尝试更新前驱节点的 next 指针(若失败则重新查找,处理并发冲突);
- 若插入成功,更新跳表的当前最高层级。
实现代码:
template <typename K, typename V>
bool ConcurrentSkipList<K, V>::insert(const K& key, const V& value) {
int new_level = random_level();
vector<Node*> predecessors = find_predecessors(key);
// 检查是否已存在该键(避免重复插入)
Node* curr = predecessors[0]->get_next(0).ptr;
if (curr != nullptr && curr->key == key) {
return false; // 键已存在,插入失败
}
// 创建新节点
Node* new_node = new Node(key, value, new_level);
bool inserted = false;
// 从最低层到新节点的最高层,尝试 CAS 更新
for (int level = 0; level < new_level; level++) {
Node* pred = predecessors[level];
TaggedPointer pred_next = pred->get_next(level);
// 循环 CAS:若前驱节点的 next 未被修改,则更新为新节点
while (true) {
// 设置新节点的 next 为前驱节点的原 next
new_node->set_next(level, pred_next);
// CAS 尝试更新前驱节点的 next 为新节点(版本号+1)
TaggedPointer desired(new_node, pred_next.version + 1);
if (pred->cas_next(level, pred_next, desired)) {
inserted = true;
break;
}
// CAS 失败,重新查找前驱节点(处理并发冲突)
predecessors = find_predecessors(key);
pred = predecessors[level];
pred_next = pred->get_next(level);
// 再次检查键是否已存在
curr = predecessors[0]->get_next(0).ptr;
if (curr != nullptr && curr->key == key) {
delete new_node;
return false;
}
}
}
// 更新跳表当前最高层级(若新节点层级更高)
int old_max_level = current_max_level.load(std::memory_order_relaxed);
while (new_level > old_max_level &&
current_max_level.compare_exchange_weak(old_max_level, new_level,
std::memory_order_relaxed)) {
old_max_level = new_level;
}
return inserted;
}
3.5、核心操作:查询(Get)
查询操作是只读的,通过原子 load 读取节点指针,无需 CAS,实现简单且高效:
template <typename K, typename V>
bool ConcurrentSkipList<K, V>::get(const K& key, V& value) const {
Node* curr = head;
// 从当前最高层级下降
for (int level = current_max_level.load(std::memory_order_relaxed) - 1; level >= 0; level--) {
while (true) {
TaggedPointer next_tp = curr->get_next(level);
if (is_valid(next_tp) && next_tp.ptr->key < key) {
curr = next_tp.ptr;
} else {
break;
}
}
}
// 检查最底层的下一个节点是否为目标
curr = curr->get_next(0).ptr;
if (curr != nullptr && curr->key == key) {
value = curr->value;
return true;
}
return false; // 键不存在
}
3.6、核心操作:删除(Erase)
删除的核心是 "标记删除 + 延迟释放"(避免直接释放内存导致野指针),步骤如下:
- 查找每层的前驱节点和目标节点;
- 若目标节点不存在,直接返回;
- 从最高层到最低层,用 CAS 将前驱节点的 next 指针指向目标节点的 next(标记删除);
- 延迟释放目标节点内存(实际需结合 Hazard Pointers 等机制,此处简化为直接删除)。
实现代码:
template <typename K, typename V>
bool ConcurrentSkipList<K, V>::erase(const K& key) {
vector<Node*> predecessors = find_predecessors(key);
Node* target = predecessors[0]->get_next(0).ptr;
// 目标节点不存在,删除失败
if (target == nullptr || target->key != key) {
return false;
}
int target_level = target->next.size();
bool erased = false;
// 从最高层到最低层,CAS 更新前驱节点的 next 指针
for (int level = target_level - 1; level >= 0; level--) {
Node* pred = predecessors[level];
TaggedPointer pred_next = pred->get_next(level);
TaggedPointer target_tp(target, pred_next.version);
// 循环 CAS:将前驱节点的 next 指向目标节点的 next
while (true) {
if (!pred->cas_next(level, pred_next, target->get_next(level))) {
// CAS 失败,重新查找前驱节点
predecessors = find_predecessors(key);
pred = predecessors[level];
pred_next = pred->get_next(level);
target = predecessors[0]->get_next(0).ptr;
// 目标节点已不存在,删除失败
if (target == nullptr || target->key != key) {
return erased;
}
target_tp = TaggedPointer(target, pred_next.version);
} else {
erased = true;
break;
}
}
}
// 简化实现:直接删除目标节点(实际需用 Hazard Pointers 保证内存安全)
delete target;
return erased;
}
Part4性能测试发基础
无锁跳表 vs 有锁跳表
为验证原子操作实现的高并发优势,对比 "基于 std::mutex 的有锁跳表" 和 "本文的无锁跳表" 在多线程场景下的吞吐量(操作数 / 秒)。
4.1、测试环境
- CPU:Intel i7-12700H(14 核 20 线程);
- 内存:32GB DDR5;
- 编译器:GCC 11.2(-O3 优化);
- 测试场景:100 万次操作(插入 + 查询 + 删除比例 3:5:2),线程数从 1 到 20 递增。
4.2、测试结果
|-----|----------------|----------------|--------|
| 线程数 | 有锁跳表吞吐量(ops/s) | 无锁跳表吞吐量(ops/s) | 性能提升倍数 |
| 1 | 120,000 | 135,000 | 1.12x |
| 4 | 180,000 | 450,000 | 2.5x |
| 8 | 210,000 | 780,000 | 3.71x |
| 16 | 230,000 | 1,100,000 | 4.78x |
| 20 | 220,000(锁竞争峰值) | 1,250,000 | 5.68x |
4.3、结果分析
- 单线程场景:无锁跳表因原子操作的轻微开销,性能略高于有锁跳表;
- 多线程场景:随着线程数增加,有锁跳表因锁竞争导致吞吐量饱和甚至下降,而无锁跳表通过并行访问,吞吐量线性增长,最高提升 5.68 倍。
Part5工程化优化
上文实现为了清晰展示核心逻辑,简化了部分工程细节,实际落地需解决以下问题:
5.1、内存安全:Hazard Pointers 替代直接删除
直接 delete 目标节点会导致其他线程访问野指针,工业界常用 Hazard Pointers(危险指针) 管理内存:
- 线程访问节点前,将节点指针存入 "危险指针" 数组;
- 删除节点时,先标记为 "待删除",若节点不在任何线程的危险指针中,再释放内存。
5.2、层级竞争:限制层级更新频率
多个线程同时插入高层级节点时,会竞争更新 current_max_level,可通过 "层级阈值" 优化:仅当新节点层级比当前最高层级高 2 以上时,才尝试更新,减少 CAS 竞争。
5.3、ABA 问题强化:64 位 Tagged Pointer 适配
32 位系统中,指针 + 版本号可能超出 32 位
需用 std::atomic<uint64_t> 存储 Tagged Pointer 的二进制表示,通过位运算拆分指针和版本号。
Part6应用场景
基于原子操作的无锁跳表,通过 CAS 原语和 Tagged Pointer 解决了并发场景下的数据竞争和 ABA 问题,在高并发场景下性能远超有锁跳表。其核心优势和适用场景如下:
核心优势
- 高并发吞吐量:无锁设计支持真正的多线程并行访问,无锁竞争开销;
- 低延迟:原子操作比互斥锁的上下文切换开销小;
- 实现简单:相比无锁红黑树,跳表的无锁实现逻辑更清晰。
适用场景
- 分布式缓存:如 Redis Cluster 的槽位索引(Redis 跳表为单线程,无锁版本可用于多线程缓存);
- 数据库索引:如 LevelDB 的 MemTable(内存有序索引);
- 高并发队列:结合跳表实现有序并发队列(如优先级任务队列)。
总结
无锁编程是高并发 C++ 开发的核心技能,而跳表是无锁数据结构的 "入门经典"------ 其简单的结构能让我们聚焦于原子操作的核心逻辑,而非数据结构本身的复杂性。掌握本文的设计思想后,可进一步探索无锁队列、无锁哈希表等更复杂的并发数据结构,应对更高阶的性能挑战。
点击下方关注【Linux教程】,获取 大厂技术栈学习路线、项目教程、简历模板、大厂面试题pdf文档、大厂面经、编程交流圈子等等。
专注Linux C/C++技术讲解~更多 务实、能看懂、可复现 的技术文章和学习包尽在【Linux教程】