当前位置: 首页 > news >正文

基于原子操作的 C++ 高并发跳表实现

在高并发的多线程编程中,传统的锁机制(如 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、并发跳表的核心挑战

单线程跳表无需考虑线程安全,但多线程场景下需要考虑的三大问题:

  1. 数据竞争(Data Race):多个线程同时修改同一节点的 next 指针(如插入时修改前驱节点的指针);
  2. ABA 问题:线程 A 读取到节点 A 的指针为 p,线程 B 将 A 删后又插入新的节点 A(指针仍为 p),线程 A 后续的 CAS 会误判为 “未被修改”;
  3. 内存安全:删除节点时直接释放内存,可能导致其他线程仍在访问该节点(野指针)。

这三大问题正是原子操作要解决的核心 —— 通过无锁原语保证操作的原子性、可见性和有序性。

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_LEVELwhile (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 指针”,步骤如下:

  1. 生成新节点的随机层级;
  2. 查找每层的前驱节点;
  3. 从最低层到最高层,用 CAS 尝试更新前驱节点的 next 指针(若失败则重新查找,处理并发冲突);
  4. 若插入成功,更新跳表的当前最高层级。

实现代码:

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 为前驱节点的原 nextnew_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)

删除的核心是 “标记删除 + 延迟释放”(避免直接释放内存导致野指针),步骤如下:

  1. 查找每层的前驱节点和目标节点;
  2. 若目标节点不存在,直接返回;
  3. 从最高层到最低层,用 CAS 将前驱节点的 next 指针指向目标节点的 next(标记删除);
  4. 延迟释放目标节点内存(实际需结合 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 指向目标节点的 nextwhile (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教程】

http://www.dtcms.com/a/511656.html

相关文章:

  • java 8 lambda表达式对list进行分组
  • 网站建设 有聊天工具的吗网站开发者的设计构想
  • 建网站 北京网站接入支付宝在线交易怎么做
  • scrapy爬取豆瓣电影
  • bisheng 的 MCP服务器添加 或 系统集成
  • 一个完整的 TCP 服务器监听示例(C#)
  • 执行操作后元素的最高频率1 2(LeetCode 3346 3347)
  • Java 大视界 -- Java 大数据在智慧交通停车场智能管理与车位预测中的应用实践
  • 版本设计网站100个关键词
  • 网站前置审批工程建设服务平台
  • 共聚焦显微镜(LSCM)的针孔效应
  • STM32CubeMX
  • 网站实现搜索功能四川建设安全协会网站
  • spark组件-spark core(批处理)-rdd特性-内存计算
  • 算法练习:双指针专题
  • 关于comfyui的triton安装(xformers的需求)
  • 爬虫+Redis:如何实现分布式去重与任务队列?
  • 烘焙食品网站建设需求分析wordpress生成静态地图
  • 区块链——Solidity编程
  • OpenSSH安全升级全指南:从编译安装到中文显示异常完美解决
  • 数据结构的演化:从线性存储到语义关联的未来
  • 爱博精电AcuSys 电力监控系统赋能山东有研艾斯,铸就12英寸大硅片智能配电新标杆
  • 基于AI与云计算的PDF操作工具开发技术探索
  • LeetCode 404:左叶子之和(Sum of Left Leaves)
  • 中小企业网站建设论文高端制作网站技术
  • 电子报 网站开发平面设计培训机构排行
  • 无人系统搭载毫米波雷达的距离测算与策略执行详解
  • Adobe Acrobat软件优化配置,启用字体平滑和默认单页连续滚动
  • 测试题-3
  • win10 win11搜索框空白解决方案