结合游戏场景理解,互斥锁,读写锁,自旋锁,CAS / 原子变量,分段锁

核心前言

互斥锁,读写锁,自旋锁,CAS / 原子变量,分段锁都是 C++ 游戏服务器开发中最高频的并发同步手段,所有场景(BOSS 击杀、技能释放、玩家属性修改、排行榜更新)的并发安全,都是靠这些技术解决的;

它们的核心统一目的 :解决多线程操作共享资源 时的竞态条件(Race Condition),保证数据一致性,比如:多玩家同时扣 BOSS 血量、多线程同时改玩家蓝量、并发更新伤害统计,避免出现「血量越扣越多」「技能冷却失效」「伤害统计错误」的 BUG。

它们的核心核心区别获取不到锁 / 执行失败时,线程是「让出 CPU 睡眠」还是「循环重试占着 CPU」 + 是否区分读 / 写操作,这也是选型的唯一标准。

前置基础:临界区

所有同步原语,都是为了保护 「临界区」 :一段操作共享资源的代码片段,多线程同时执行临界区代码,就会产生数据不一致。

游戏中典型临界区:修改玩家蓝量的代码、扣 BOSS 血量的代码、累加玩家对 BOSS 伤害的代码、更新技能冷却的代码。

一、互斥锁 (Mutex,互斥量)

定义

最基础、最常用的同步锁,独占式锁 :同一个临界区,同一时间只能有 1 个线程持有锁并执行 ,其他线程想获取锁时,获取失败 → 立即让出 CPU、进入阻塞睡眠状态,直到持有锁的线程释放锁,阻塞线程被唤醒再竞争锁。

  • C++ 标准库:std::mutex / std::lock_guard(自动加锁解锁)
  • muduo 网络库:muduo::MutexLock / muduo::MutexLockGuard

特性

  • 优点:实现简单、安全稳定,线程阻塞时不占用 CPU 资源,对 CPU 友好;
  • 缺点:存在线程上下文切换开销(睡眠→唤醒),加锁 / 解锁有固定的系统调用开销;
  • 核心规则:读也独占、写也独占 → 哪怕是纯读共享资源,也只能一个线程读。

适用场景

临界区代码执行时间较长 (几行到几十行代码)、线程竞争频率中等 / 低的场景,游戏里 90% 的普通并发场景都能用互斥锁兜底

核心判断:只要你的临界区操作不是「一行代码的简单变量修改」,用互斥锁就没错。

代码示例

cpp 复制代码
// 场景1:修改玩家蓝量、技能冷却(技能释放后扣蓝+加冷却,典型临界区)
// 共享资源:玩家的蓝量mana、冷却时间map
class Player {
public:
    int64_t mana_; // 玩家蓝量
    unordered_map<uint32_t, uint64_t> cd_map_; // 技能ID -> 冷却结束时间
    muduo::MutexLock mutex_; // 玩家专属互斥锁
};

// 技能释放后,扣蓝+更新冷却(临界区操作,必须加互斥锁)
void useSkill(Player& player, uint32_t skill_id, int cost_mana) {
    muduo::MutexLockGuard lock(player.mutex_); // RAII自动加锁,出作用域自动解锁
    if (player.mana_ >= cost_mana) {
        player.mana_ -= cost_mana; // 扣蓝
        player.cd_map_[skill_id] = Timestamp::now().addSeconds(3); // 3秒冷却
    }
}

// 场景2:BOSS被攻击后,记录玩家的单次伤害(临界区:累加玩家伤害值)
void addPlayerDamage(uint64_t boss_id, uint64_t player_id, int64_t damage) {
    muduo::MutexLockGuard lock(boss_mutex_); // 加锁保护共享的伤害map
    boss_damage_map[boss_id][player_id] += damage;
}

二、读写锁 (ReadWriteLock / RWMutex,共享互斥锁)

定义

游戏开发中最高频、性价比最高的锁,没有之一 ,是互斥锁的「升级版」,核心设计思想:区分「读操作」和「写操作」 ,游戏里 95% 的场景都是 读多写少(比如:查 BOSS 血量、查玩家战力、查排行榜、查技能冷却都是读;扣血、扣蓝、改战力是写)。

核心规则:读共享,写独占

  • 对共享资源的读操作 :可以多个线程同时加读锁、并发读取,互不阻塞;
  • 对共享资源的写操作 :只能一个线程加写锁、独占执行,此时所有读锁 / 写锁都要阻塞;
  • 写锁优先级 > 读锁:如果有线程在等写锁,新的读请求会被阻塞,避免写请求「饿死」。
  • C++ 标准库:C++17 std::shared_mutex + std::shared_lock(读锁) / std::unique_lock(写锁)
  • muduo 网络库:muduo::ReadWriteLock

特性

  • 优点:极致提升读并发性能,读操作完全无锁竞争,写操作的开销和互斥锁一致;线程写阻塞时不占用 CPU;
  • 缺点:实现比互斥锁复杂一点;如果写操作频繁,读写锁的性能和互斥锁差不多;
  • 核心优势:完美契合游戏业务的「读多写少」特性,是游戏服务器并发优化的第一选择

适用场景

所有读多写少的共享资源操作,这是游戏里的绝对主流场景,优先级远高于互斥锁。

游戏典型场景:BOSS 血量 / 伤害统计的读写、玩家属性查询与修改、排行榜查询与更新、公会数据读写、背包道具查询与使用。

代码示例

cpp 复制代码
// 核心场景:多玩家攻击BOSS的伤害统计(读多写少极致场景)
// 共享资源:BOSS的剩余血量、玩家对BOSS的总伤害map
struct BossData {
    int64_t remain_hp; // BOSS剩余血量
    unordered_map<uint64_t, int64_t> player_damage; // 玩家ID -> 总伤害
    muduo::ReadWriteLock rw_lock; // 读写锁
};
BossData boss;

// 场景1:纯读操作 - 玩家查询BOSS当前血量、自己的总伤害(加读锁,多线程并发执行)
int64_t getBossHp() {
    muduo::ReadLockGuard read_lock(boss.rw_lock); // 读锁
    return boss.remain_hp;
}
int64_t getPlayerDamage(uint64_t player_id) {
    muduo::ReadLockGuard read_lock(boss.rw_lock); // 读锁
    return boss.player_damage[player_id];
}

// 场景2:写操作 - 玩家攻击BOSS,扣血+累加伤害(加写锁,独占执行)
void attackBoss(uint64_t player_id, int64_t damage) {
    muduo::WriteLockGuard write_lock(boss.rw_lock); // 写锁
    boss.remain_hp -= damage;
    boss.player_damage[player_id] += damage;
}

三、自旋锁 (SpinLock)

定义

无阻塞的锁 ,和互斥锁的核心区别只有一个:线程获取锁失败时,不会让出 CPU、不会睡眠,而是一直循环(自旋)重试获取锁,直到拿到锁为止

自旋锁的本质:用CPU 的算力开销线程上下文切换的开销

  • 核心前提:自旋锁的临界区必须是极致短(只有 1~3 行代码),否则自旋的时间越长,CPU 浪费越严重;
  • C++ 标准库:C++20 std::atomic_flag 实现自旋锁
  • muduo 网络库:muduo::SpinLock

特性

临界区代码极致短(一行代码的变量修改)、锁竞争频率极低 的场景,游戏里的使用场景比互斥锁 / 读写锁少,但都是性能敏感的核心路径

游戏典型场景:玩家击杀数 / 助攻数的累加、BOSS 被攻击的次数统计、技能释放次数统计、简单的标记位修改。

代码示例

cpp 复制代码
// 场景:玩家击杀数统计(临界区只有一行代码,极致短)
class Player {
public:
    muduo::SpinLock spin_lock;
    int kill_count = 0; // 击杀数
};
Player player;

// 玩家击杀怪物后,累加击杀数(临界区只有一行,自旋锁完美适配)
void addKillCount(Player& player) {
    muduo::SpinLockGuard lock(player.spin_lock);
    player.kill_count++;
}

注意:绝对不能用自旋锁保护长临界区(比如修改玩家背包、计算技能伤害),否则游戏服务器的 CPU 会直接拉满,所有玩家卡顿!

四、CAS 自旋(Compare And Swap + 自旋)

1. CAS 是什么?

CAS 是 CPU 硬件级别的原子指令 ,不是锁,是无锁编程 的核心,是所有原子操作的底层实现。全称 Compare And Swap(比较并交换),核心逻辑(伪代码):

cpp 复制代码
bool CAS( 共享变量V, 旧值A, 新值B ) {
    1. 原子性的比较 V 的当前值 和 预期旧值A;
    2. 如果相等 → 把V的值修改为B,返回true(操作成功);
    3. 如果不相等 → 不做任何修改,返回false(操作失败);
}
  • 核心亮点:无锁、无阻塞、原子性,没有任何加锁解锁的开销,是并发同步的「性能天花板」;
  • C++ 标准库:std::atomic<T> 模板类(封装了 CAS 指令,不用手动写 CAS)
2. CAS 自旋是什么?

CAS 是单次指令,执行失败会直接返回 ,而我们的业务逻辑(比如扣 BOSS 血量)必须执行成功,所以会在 CAS 外面套一个 while 循环 :CAS 执行失败 → 循环重试 → 直到 CAS 执行成功为止,这个「CAS + 循环重试」的组合,就是 CAS 自旋

特性

  • 优点:极致性能,无锁、无上下文切换、无系统调用开销;支持多线程并发修改共享变量,是高并发下单一变量修改的最优解;
  • 缺点:
    • 只能保护单一变量,无法保护多变量 / 多行代码的临界区;
    • 存在 ABA 问题(下文讲);
    • 竞争激烈时,自旋会占用 CPU 资源;
  • 核心规则:仅适用于单一共享变量的原子修改,不能替代锁保护复杂临界区。

适用场景

所有单一共享变量的原子修改,游戏里的核心场景,优先级最高,能不用锁就不用锁,优先用 CAS 自旋 / 原子变量。

代码示例

cpp 复制代码
// 玩家金币、BOSS血量、击杀数,用std::atomic封装,直接原子修改,无需手动自旋
std::atomic<int64_t> boss_hp = 100000; // BOSS血量(原子变量)
std::atomic<int> player_kill = 0; // 玩家击杀数(原子变量)

// 扣BOSS血量,原子操作,无锁
boss_hp -= 100;
// 累加击杀数,原子操作
player_kill++;

// 核心场景:多玩家同时扣BOSS血量,必须保证血量扣减的原子性,无锁实现
std::atomic<int64_t> boss_remain_hp = 100000; // BOSS剩余血量(原子变量)
std::atomic<uint64_t> last_hit_player = 0; // 最后一击玩家ID(原子变量)

// 玩家攻击BOSS,CAS自旋扣血,记录最后一击
void attackBossByCAS(uint64_t player_id, int64_t damage) {
    int64_t old_hp = boss_remain_hp.load(); // 读取当前血量,作为预期旧值
    int64_t new_hp = old_hp - damage;      // 计算新血量
    // CAS自旋:失败则循环重试,直到扣血成功
    while (!boss_remain_hp.compare_exchange_weak(old_hp, new_hp)) {
        new_hp = old_hp - damage; // 重新计算新血量(old_hp会被自动更新为当前最新值)
    }
    // 如果扣血后BOSS血量<=0,记录最后一击玩家
    if (new_hp <= 0 && old_hp > 0) {
        last_hit_player.store(player_id);
    }
}

CAS 经典问题:ABA 问题 & 解决办法

什么是 ABA 问题?

线程 1 准备用 CAS 把变量 V 从 A 改成 B,在执行 CAS 前,线程 2 把 V 从 A 改成 C,又改回 A;线程 1 执行 CAS 时,发现 V 还是 A,就认为变量没被修改,执行了修改操作 → 逻辑错误。

游戏场景举例:玩家金币是 100(A),线程 1 扣 50 想改成 50,线程 2 先加 50 成 150(C),又扣 50 成 100(A),线程 1 的 CAS 执行成功,但玩家的金币流水已经被篡改了。

解决办法

  • 版本号 / 时间戳 :给变量绑定一个版本号,每次修改版本号 + 1,CAS 时同时比较「变量值 + 版本号」,比如 std::atomic<std::pair<int64_t, int>>
  • 业务层规避:游戏里的大部分场景(血量、金币、击杀数)都是单调增减,不会出现 ABA 的情况,无需处理。

五、分段锁(哈希锁)

为什么需要分段锁?

在游戏高并发场景中,如果用全局大锁保护一个超大共享资源(比如全服 100 万玩家的伤害统计 map),会存在两个致命问题:

  • 锁竞争激烈:所有线程操作这个 map 时,都要竞争同一把锁,线程排队阻塞,并发性能极低(比如 10 万 QPS 的请求,因为锁竞争降到 1 万 QPS);
  • 性能瓶颈明显:哪怕是修改两个毫无关联的玩家数据(比如玩家 A 和玩家 B 的伤害),也会因为竞争同一把锁而互相阻塞。

分段锁的核心思路

一个全局大共享资源 拆分成多个小分片 ,为每个分片分配一把独立的锁;线程操作资源时,只需要根据资源的标识(如玩家 ID)计算出对应的分片,再获取该分片的锁即可。

本质:将「全局竞争」降级为「分片内竞争」,分片之间的操作完全无锁竞争。

核心原理与实现步骤

1. 确定核心参数

  • 分片数量(N) :通常选择 2 的幂次 (如 16、32、64),方便用取模运算计算分片索引;数量选择依据是并发量和资源大小,并发越高、资源越大,分片数越多(一般游戏服务器选 16~64 即可)。
  • 分片锁类型 :根据业务场景选择基础锁(读写锁、自旋锁、互斥锁),游戏中优先选读写锁 (读多写少)或自旋锁(临界区极短)。
  • 分片依据 :用资源的唯一标识(如玩家 ID、BOSS ID、角色 ID)作为哈希键,保证同一个资源永远落在同一个分片里。

2. 核心实现步骤

  1. 初始化分片资源和分片锁:创建 N 个资源分片容器 + N 把对应锁;
  2. 计算分片索引 :对资源标识(如 player_id)做哈希运算 → 取模 N → 得到分片索引idx = hash(player_id) % N
  3. 加锁操作分片 :获取第idx把锁 → 操作第idx个分片的资源 → 释放锁;
  4. 分片聚合(可选):如果需要全局数据(如全服总伤害),则遍历所有分片,聚合分片内的数据。
特性

|------------------------------------------------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------|
| 优点 | 缺点 |
| 1. 极致降低锁竞争:分片之间无锁竞争,并发性能随分片数线性提升(如 16 个分片,并发提升 16 倍); 2. 兼容性强:可以和任意基础锁(读写锁、自旋锁、互斥锁)结合; 3. 实现简单:核心是哈希取模,代码量少,易维护。 | 1. 分片数量难把控:分片太少 → 竞争仍存在;分片太多 → 内存占用增加,聚合全局数据时遍历开销大; 2. 跨分片操作复杂:如果一个业务需要操作多个分片的资源,需要加多个锁,容易引发死锁; 3. 不适合小资源:资源体量小时,分片的额外开销(哈希计算、锁管理)会超过性能收益。 |

1. 分片数量怎么选?
  • 核心原则2 的幂次优先(如 16、32、64),哈希取模运算更快,且能避免分片不均;
  • 参考依据
    • 并发量:QPS 越高,分片数越多(10 万 QPS 选 32,100 万 QPS 选 64);
    • 资源大小:资源越大,分片数越多(100 万玩家选 64,10 万玩家选 16);
    • 服务器 CPU 核心数:分片数不超过 CPU 核心数的 2 倍(避免锁竞争超过 CPU 调度能力)。
2. 跨分片操作如何避免死锁?

如果业务需要操作多个分片的资源 (比如玩家同时修改两个分片的伤害数据),必须遵循 「锁的顺序性原则」

  • 分片索引从小到大的顺序加锁,绝对不能反向加锁;
  • 示例:需要操作分片 3 和分片 5 → 先加分片 3 的锁,再加分片 5 的锁,释放时顺序相反。

代码示例

cpp 复制代码
// 玩家A给玩家B送道具,A和B在不同分片
void giveItem(uint64_t pidA, uint64_t pidB, int item_id) {
    int idxA = getShardIdx(pidA);
    int idxB = getShardIdx(pidB);

    // 核心:先加索引小的锁,再加索引大的锁
    if(idxA < idxB) {
        muduo::MutexLockGuard lockA(g_shard_mutex[idxA]);
        muduo::MutexLockGuard lockB(g_shard_mutex[idxB]);
        // 扣A的道具,加B的道具
        g_item_shards[idxA][pidA] -= 1;
        g_item_shards[idxB][pidB] += 1;
    } else {
        muduo::MutexLockGuard lockB(g_shard_mutex[idxB]);
        muduo::MutexLockGuard lockA(g_shard_mutex[idxA]);
        g_item_shards[idxA][pidA] -= 1;
        g_item_shards[idxB][pidB] += 1;
    }
}
3. 分段锁和 CAS / 原子变量的区别?能不能互相替代?

**结论:**不能替代,互补使用,游戏开发中组合使用性能极致!

  • CAS / 原子变量:只能保护单一变量 (如 BOSS 血量、玩家金币),无锁,性能天花板;但无法保护复合操作(如 map 的增删改查);
  • 分段锁:可以保护任意复杂的共享资源(如 map、vector),基于锁实现,性能接近天花板;但无法替代单一变量的原子操作。

最优组合:CAS / 原子变量 处理单一变量 + 分段锁 处理复合资源,比如 BOSS 击杀场景:

  • BOSS 血量用std::atomic<int64_t> CAS 自旋扣减(单一变量);
  • 玩家伤害统计用分段锁 + 读写锁保护 map(复合资源);
4. 分段锁和其他锁的结合策略
  • 分段锁 + 读写锁 :游戏读多写少场景首选(如排行榜查询、伤害统计查询);
  • 分段锁 + 自旋锁 :游戏临界区极短的写场景(如玩家击杀数累加);
  • 分段锁 + 互斥锁 :游戏临界区长的复杂业务场景(如玩家跨分片转移道具)。
5. 分段锁的适用边界?
  • 适合场景:超大粒度共享资源的高并发读写(全服排行榜、跨服 BOSS、千万玩家数据);
  • 不适合场景
    • 小资源(如单个 BOSS 的伤害统计,用全局读写锁即可);
    • 频繁全局聚合的场景(如每秒统计全服最高伤害,聚合开销太大,建议定时异步聚合)。

代码示例

场景一:跨服 BOSS 全服伤害统计(分段锁 + 读写锁)

业务背景:10 万玩家同时攻击跨服 BOSS,需要高并发累加玩家伤害、高并发查询玩家伤害、BOSS 死亡后统计全服最高伤害;读多写少,游戏绝对主流场景。

cpp 复制代码
#include <muduo/base/ReadWriteLock.h>
#include <unordered_map>
#include <vector>
#include <cstdint>

// 1. 固定配置:分片数量16,读写锁组合
const int kShardNum = 16;
using DamageMap = std::unordered_map<uint64_t, int64_t>; // 玩家ID -> 总伤害

// 2. 全局分段资源+分段锁:一一对应
std::vector<DamageMap> g_damage_shards(kShardNum);
std::vector<muduo::ReadWriteLock> g_shard_rw_locks(kShardNum);

// 3. 哈希分片计算:固定写法,玩家ID哈希取模
inline int getShardIdx(uint64_t player_id) {
    uint64_t hash_val = (player_id >> 32) ^ player_id; // 简单高效的哈希函数,避免分片倾斜
    return hash_val % kShardNum;
}

// 核心写操作:玩家攻击BOSS,累加伤害(高并发无压力)
void addBossDamage(uint64_t player_id, int64_t damage) {
    int idx = getShardIdx(player_id);
    muduo::WriteLockGuard write_lock(g_shard_rw_locks[idx]); // 加当前分片的写锁
    g_damage_shards[idx][player_id] += damage; // 只操作当前分片的资源
}

// 核心读操作:查询玩家对BOSS的总伤害(多线程并发读,无阻塞)
int64_t getPlayerDamage(uint64_t player_id) {
    int idx = getShardIdx(player_id);
    muduo::ReadLockGuard read_lock(g_shard_rw_locks[idx]); // 加当前分片的读锁
    auto& map = g_damage_shards[idx];
    return map.count(player_id) ? map[player_id] : 0;
}

// 全局聚合操作:BOSS死亡,统计全服最高伤害玩家(低频操作,遍历所有分片)
uint64_t getMaxDamagePlayer() {
    int64_t max_dmg = 0;
    uint64_t max_pid = 0;
    for(int i=0; i<kShardNum; ++i) {
        muduo::ReadLockGuard read_lock(g_shard_rw_locks[i]);
        auto& map = g_damage_shards[i];
        for(auto& [pid, dmg] : map) {
            if(dmg > max_dmg) {
                max_dmg = dmg;
                max_pid = pid;
            }
        }
    }
    return max_pid;
}
场景二:全服战力排行榜(分段锁 + 自旋锁)

业务背景:千万玩家的战力值实时更新,排行榜查询高频;临界区只有一行代码(战力值修改),极致短,用自旋锁替代读写锁,性能拉满。

cpp 复制代码
#include <muduo/base/SpinLock.h>
#include <unordered_map>
#include <vector>
#include <cstdint>

const int kShardNum = 16;
using PowerMap = std::unordered_map<uint64_t, int64_t>; // 玩家ID -> 战力值

// 全局分段资源+分段自旋锁
std::vector<PowerMap> g_power_shards(kShardNum);
std::vector<muduo::SpinLock> g_shard_spin_locks(kShardNum);

// 哈希分片计算
inline int getShardIdx(uint64_t player_id) {
    return (player_id >> 32) ^ player_id % kShardNum;
}

// 更新玩家战力(临界区1行代码,自旋锁最优)
void updatePlayerPower(uint64_t player_id, int64_t new_power) {
    int idx = getShardIdx(player_id);
    muduo::SpinLockGuard lock(g_shard_spin_locks[idx]);
    g_power_shards[idx][player_id] = new_power;
}

// 查询玩家战力
int64_t getPlayerPower(uint64_t player_id) {
    int idx = getShardIdx(player_id);
    muduo::SpinLockGuard lock(g_shard_spin_locks[idx]);
    return g_power_shards[idx][player_id];
}
相关推荐
绀目澄清2 小时前
unity3d AI Navigation 中文文档
游戏·unity
hugerat2 小时前
在AI的帮助下,用C++构造微型http server
linux·c++·人工智能·http·嵌入式·嵌入式linux
阿里嘎多学长2 小时前
2026-01-11 GitHub 热点项目精选
开发语言·程序员·github·代码托管
yuanyikangkang2 小时前
STM32 lin控制盒
开发语言
-森屿安年-2 小时前
unordered_map 和 unordered_set 的实现
数据结构·c++·散列表
九久。2 小时前
手动实现std:iterator/std:string/std::vector/std::list/std::map/std:set
c++·stl
小羊羊Python2 小时前
Sound Maze - 基于 SFML+C++14 的音效迷宫开源游戏 | MIT 协议
c++·游戏·开源
txinyu的博客2 小时前
HTTP服务实现用户级窗口限流
开发语言·c++·分布式·网络协议·http