基于原子操作的 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教程】

<think>好的,用户现在想了解如何使用C++实现游戏服务器中的排行榜功能。首先,我需要回忆一下之前和用户的对话历史,看看有没有相关的信息可以参考。之前用户问的是关于Skynet框架实现百万在线的设计,里面提到了协程模型、消息队列、多核利用和数据持久化等。不过这次的问题转向了C++实现排行榜,所以可能需要结合之前的系统设计知识,但重点放在排行榜的具体实现上。 用户提供的引用中提到了C++的可扩展性和面向对象特性,这可能和如何组织排行榜的数据结构有关。比如,使用类来封装排行榜,或者利用STL中的容器来管理数据。但用户真正的需求是排行榜的实现方案,所以需要详细的技术步骤。 首先,我需要考虑排行榜的基本需求:实时更新、高效查询、支持大量玩家数据、高并发下的线程安全以及数据持久化。这些都是游戏服务器中排行榜的关键点。 对于数据结构,通常会想到使用有序结构,比如红黑树或跳表,因为需要快速插入和查询。C++的STL中的map和set是基于红黑树的,但它们在插入和删除时的性能可能不够理想,特别是在高频更新时。而跳表虽然STL中没有现成的实现,但可以自己实现或者使用第三方库,比如Redis中的跳表实现,不过用户可能希望纯C++的方案,所以需要权衡。 另一个选择是使用有序数组,结合二分查找。但插入和删除的时间复杂度是O(n),对于大量数据可能不够高效。所以可能需要分片或分页处理,比如将排行榜分成多个段,每个段维护一个有序结构,这样可以减少的竞争,提高并发性能。 线程安全方面,需要考虑多线程环境下的数据同步。读写可能是一个好的选择,因为排行榜的读操作远多于写操作,读写可以提高读的并发性。或者使用无数据结构,但实现起来比较复杂。 数据持久化方面,需要定期将内存中的数据同步到数据库,比如MySQL或Redis,防止服务器宕机数据丢失。同时,启动时需要从数据库加载历史数据到内存中。 性能优化方面,可以考虑定时批量更新,减少频繁的磁盘操作。或者使用内存数据库作为缓存层,提高访问速度。 然后需要给出具体的实现步骤,比如定义玩家数据类,选择数据结构,实现更新逻辑,处理并发,持久化策略,以及可能的优化手段。同时,用户可能需要示例代码,所以需要提供一个简单的C++代码示例,展示如何用std::map或自定义跳表实现排行榜的基础功能。 还需要注意用户提到的C++面向对象的特性,所以在代码示例中使用类来封装排行榜功能,比如一个RankingList类,包含插入、更新、获取排名等方法,这样符合用户引用的内容。 最后,生成相关问题,帮助用户深入理解可能遇到的扩展问题,比如如何优化性能、分布式环境下的排行榜实现、防止作弊等。这些问题需要与之前的实现方案相关联,引导用户进一步思考。</think>### C++ 游戏服务器排行榜实现方案 在游戏服务器中实现排行榜功能需兼顾**实时性**、**高并发**和**数据一致性**。以下是基于 C++ 的技术实现方案及关键代码示例: --- #### 1. 数据结构选择 - **核心需求**:快速插入、更新和查询排名 - **推荐方案**: - **跳表(Skip List)**:插入/删除时间复杂度为 $O(\log n)$,天然有序,适合高频更新场景[^1] - **红黑树(std::map)**:利用 `std::map` 自动排序特性,但频繁插入时性能略低于跳表[^2] ```cpp // 示例:使用 std::map 存储玩家分数(键为分数,值为玩家ID) std::map<int64_t, std::string, std::greater<>> score_rank_map; // 降序排列 ``` --- #### 2. 数据更新逻辑 - **原子操作**:确保高并发下的数据一致性 - **方法一**:使用读写(`std::shared_mutex`) - **方法二**:无结构(如 CAS 原子操作) ```cpp #include <shared_mutex> std::shared_mutex rank_mutex; // 更新玩家分数 void update_score(const std::string& player_id, int64_t new_score) { std::unique_lock lock(rank_mutex); // 写 // 删除旧记录,插入新记录 auto it = std::find_if(score_rank_map.begin(), score_rank_map.end(), [&](const auto& pair) { return pair.second == player_id; }); if (it != score_rank_map.end()) score_rank_map.erase(it); score_rank_map.emplace(new_score, player_id); } ``` --- #### 3. 排名查询优化 - **缓存机制**:预生成 Top N 排名列表,降低实时查询压力 - **分页加载**:仅计算当前请求范围内的排名 ```cpp // 获取前100名(使用缓存) std::vector<std::pair<int64_t, std::string>> get_top_100() { std::shared_lock lock(rank_mutex); // 读 auto end = score_rank_map.size() > 100 ? std::next(score_rank_map.begin(), 100) : score_rank_map.end(); return {score_rank_map.begin(), end}; } ``` --- #### 4. 分布式环境扩展 - **分片策略**:按玩家ID哈希分片到不同服务器节点 - **最终一致性**:通过定期合并各节点数据生成全局排行榜 ```cpp // 分片示例:哈希分片到10个节点 int shard_index = std::hash<std::string>{}(player_id) % 10; auto& shard = global_rank_shards[shard_index]; shard.update_score(player_id, new_score); ``` --- #### 5. 数据持久化 - **异步落盘**:定期将内存数据写入数据库(如 Redis/MySQL) - **快照备份**:定时保存完整排名数据,防止宕机丢失 ```cpp // Redis 持久化示例(使用 hiredis 库) void save_to_redis() { redisContext* conn = redisConnect("127.0.0.1", 6379); for (const auto& [score, player_id] : score_rank_map) { redisCommand(conn, "ZADD global_rank %lld %s", score, player_id.c_str()); } } ``` --- #### 6. 性能优化 - **批量更新**:累积多次小更新后统一处理 - **内存池**:预分配玩家数据存储空间,减少动态内存分配开销 --- ### 技术难点与解决方案 | 挑战 | 解决方案 | |-----------------------|--------------------------------------------------------------------------| | 高频更新导致竞争 | 采用分片 + 读写组合策略 | | 实时排名计算延迟 | 使用跳表替代红黑树,降低插入时间复杂度 | | 分布式数据同步 | 引入版本号机制,合并时处理冲突 | | 内存占用过高 | 限制排行榜长度(如只保留前10万条记录) | --- ### 扩展问题 1. 如何设计支持多维度(等级、战力、成就)的复合排行榜? 2. 排行榜数据量超过单机内存容量时如何处理? 3. 如何防止玩家通过刷分破坏排行榜公平性? 需要进一步细节可继续探讨特定场景的实现方案。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值