基于原子操作的 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_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 指针",步骤如下:

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

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

  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 指向目标节点的 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教程】

相关推荐
_dindong5 小时前
牛客101:链表
数据结构·c++·笔记·学习·算法·链表
蓝创精英团队5 小时前
C++DirectX9坐标系与基本图元之渲染状态(RenderState)_0304
前端·c++·性能优化
筏.k6 小时前
C++ 设计模式系列:生产者-消费者模式完全指南
开发语言·c++·设计模式
LXS_35711 小时前
Day 05 C++ 入门 之 指针
开发语言·c++·笔记·学习方法·改行学it
挂科是不可能出现的12 小时前
最长连续序列
数据结构·c++·算法
mjhcsp13 小时前
C++ int 类型深度解析:从底层实现到实战应用
c++·int
程序员老舅15 小时前
C++参数传递:值、指针与引用的原理与实战
c++·c/c++·值传递·引用传递·指针传递·参数传递机制
liu****15 小时前
8.list的使用
数据结构·c++·算法·list
立志成为大牛的小牛15 小时前
数据结构——二十六、邻接表(王道408)
开发语言·数据结构·c++·学习·程序人生