核心前言
互斥锁,读写锁,自旋锁,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. 核心实现步骤
- 初始化分片资源和分片锁:创建 N 个资源分片容器 + N 把对应锁;
- 计算分片索引 :对资源标识(如 player_id)做哈希运算 → 取模 N → 得到分片索引
idx = hash(player_id) % N; - 加锁操作分片 :获取第
idx把锁 → 操作第idx个分片的资源 → 释放锁; - 分片聚合(可选):如果需要全局数据(如全服总伤害),则遍历所有分片,聚合分片内的数据。
特性
|------------------------------------------------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------|
| 优点 | 缺点 |
| 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];
}