UE5 多线程系列写到读写锁这一篇很多朋友一开始会纠结明明已经有FCriticalSection了为什么还要搞一个FRWLock更绕的是在 Unix 平台上它又变成了FPThreadsRWLock名字都不一样代码还能通用吗这篇文章就把读写锁的实验代码和临界区的代码放在一起讲清楚包括它们各自适合什么场景、在 UE5 源码层面是怎么实现的、以及我实际踩过的几个坑。适合正在补多线程基础、或者已经写了几个并行任务但不太确定怎么选锁的同学参考。1. 锁的选型为什么临界区和读写锁总是成对出现1.1 多线程访问共享数据三种同步原语怎么选先想清楚一个前提UE5 的 GameThread、RenderThread、各种工作线程并发访问同一个变量时必须要保证“同一时刻只有一个线程在写或者大家都在允许并发地读”。这个保证不能靠自觉必须靠同步原语。常用的同步原语其实是三个不是两个。FCriticalSection临界区互斥锁同一时刻只有一个线程能进入。这是最常用、也最能“兜底”的锁。FRWLock读写锁允许任意多个线程同时加读锁但写锁会独占某个线程持有写锁时读线程也必须等待。原子变量比如std::atomicint32适用于单个变量或单个计数器的简单情况不要拿锁去保护一个整数那是浪费。一句话选型逻辑多读少写用读写锁互斥要求高用临界区能无锁就用原子变量。大部分玩家数据的变更频率不高但读取频率极高这种场景就是读写锁的典型舞台。1.2 读写锁的优缺点正好补上临界区的短板临界区的逻辑最“硬”——不管你是读还是写统统排队。这样做的好处是正确性非常容易推理坏处是性能可能会浪费如果十个线程都在读同一个榜单数据它们之间根本没有竞争关系却必须一个个排队进去。读写锁的核心理念是把“读”和“写”分开对待读锁之间兼容多个读者可以同时持有读锁互不阻塞。写锁与一切锁互斥写者必须独占。读写之间互斥写锁在等待时新的读锁也不能插队否则写者会被无限饿死。在 UE5 里FRWLock就是对外统一暴露的读写锁接口不同平台内部会切到不同的实现。Windows 上一般基于SRWLOCKUnix/Linux 系则是FPThreadsRWLock底层封装的是pthread_rwlock_t。这个抽象让你在业务层可以只写一套代码不用关心目标平台是 Windows 还是 Linux 还是 Android。经验UE5 源码里FRWLock这个名字虽然统一但它在不同平台上的行为细节并不完全一致跨平台项目不要把“读写锁一定写者优先”这种猜测写进业务逻辑。2. FRWLock 实验代码一个可运行的读写锁 Demo2.1 Demo 设计与线程结构我在一个模拟项目 X 里做过一次读写锁的实验一个全局字典多个读者线程反复读取少数写者线程偶尔写入。目的是验证三件事多个读者能否真的并行进入临界区。写者进入时读者数量是否能被压到 0。读写锁相比纯临界区在高并发读场景下能省多少时间。线程结构是这样4 个读者线程每个循环 500 次。2 个写者线程每个循环 50 次。共享数据用TMapFString, int32模拟真实业务里的热数据。2.2 用并发读者数验证读锁并发性“读锁并发性”怎么验证不能靠肉眼看日志要设计一个计数器读锁内部把活跃读者数加一记录历史最大值离开读锁再减一。如果多个读者真的能并行这个最大值应该大于 1。#include Misc/ScopeLock.h #include HAL/Runnable.h #include HAL/RunnableThread.h #include atomic FRWLock TestRWLock; std::atomicint32 ActiveReaders; std::atomicint32 MaxReaders; class FReaderWorker : public FRunnable { public: virtual uint32 Run() override { for (int32 i 0; i 500; i) { FRWScopeLock ScopeLock(TestRWLock, SLT_ReadOnly); int32 Current ActiveReaders; // 统计历史最大并发读者数 int32 Observed MaxReaders.load(); while (Current Observed !MaxReaders.compare_exchange_weak(Observed, Current)) { } --ActiveReaders; } return 0; } };这里要注意ActiveReaders本身也是多线程共享变量所以必须用原子操作不能直接。我见过有人拿普通int32去统计结果并发高了数据漂移实验结论全是错的。跑完实验MaxReaders的值如果稳定在 1说明读锁并没有真正并发如果大于 1说明多个读者确实同时进入了临界区。我实测在一次运行中看到的典型值是 4因为开了 4 个读者线程这个结果就反向证明了FRWLock的读锁不会互相阻塞。2.3 写锁必须串联执行代码里怎么验证写锁的“独占性”同样需要一个指标写锁内部维护一个ActiveWriters理论上它的值永远只能是 1。如果出现大于 1意味着有两个写者同时进了写保护区那锁就失效了。std::atomicint32 ActiveWriters; class FWriterWorker : public FRunnable { public: virtual uint32 Run() override { for (int32 i 0; i 50; i) { FRWScopeLock ScopeLock(TestRWLock, SLT_Write); int32 Current ActiveWriters; check(Current 1); // 写者必须互斥 // 模拟写入动作 FPlatformProcess::Sleep(0.001f); --ActiveWriters; } return 0; } };这里用check(Current 1)是故意的。如果写锁实现有问题越早崩溃越好总比线上跑着跑着数据错乱要容易排查。实际跑起来还会发现一个现象写者线程进入临界区之前ActiveReaders会先变成 0然后ActiveWriters才变成 1。这个顺序是合理的因为读写锁必须保证“写者进入时没有读者在临界区里”。注意FRWScopeLock是老代码里常见的 RAII 封装第一个参数是锁对象第二个参数用SLT_ReadOnly或SLT_Write区分读写。UE5 里也提供了新写法的FReadScopeLock和FWriteScopeLock功能等价只是命名更明确。两种都行关键是别漏掉解锁路径。如果你在实验中发现写者等待时间特别长甚至出现写者一直拿不到锁的情况大概率不是锁的 bug而是读者线程运行太频繁、持续占着读锁。这种情况就需要考虑给写者更高的优先级或者主动降低读者线程的占用频率。3. FPThreadsRWLockUnix 版读写锁在 UE5 里的真实面貌3.1 UE5 为何要单独拆一个 Unix 版本UE5 的锁实现不是魔法它是一层平台抽象。FRWLock只是对外统一接口内部按平台拆分。搜索引擎源码时你会看到以WindowsPlatform、UnixPlatform、PThread等命名的文件它们分别承载了同一套接口在不同操作系统上的实现。Unix 系平台的读写锁为什么单独叫FPThreadsRWLock主要原因是 Unix/Linux 生态里没有 Windows 那种内置SRWLOCK大家的标准做法是用 POSIX 线程库也就是 pthread 提供的pthread_rwlock_t。UE5 把它包了一层转化成 UE 风格的类方法方便外面统一调用。可以把它理解为“UE5 的跨平台锁接口 统一门面 平台适配器”。你在业务代码里写FRWLock编译到 Linux 时它会落到FPThreadsRWLock编译到 Windows 时落到另一个实现。你不需要在业务代码里写#ifdef PLATFORM_UNIX但要知道背后发生了什么。3.2 pthread_rwlock 封装里的关键点FPThreadsRWLock本质上就是围绕pthread_rwlock_t的初始化、加锁、解锁、销毁的薄封装。概念上可以简化成下面这样// 示意代码非引擎原封源码 #include pthread.h class FPThreadsRWLock { public: FPThreadsRWLock() { int32 Result pthread_rwlock_init(RWLock, nullptr); check(Result 0); } void ReadLock() { pthread_rwlock_rdlock(RWLock); } void WriteLock() { pthread_rwlock_wrlock(RWLock); } void ReadUnlock() { pthread_rwlock_unlock(RWLock); } void WriteUnlock() { pthread_rwlock_unlock(RWLock); } ~FPThreadsRWLock() { pthread_rwlock_destroy(RWLock); } private: pthread_rwlock_t RWLock; };注意一个容易被忽略的细节写锁和解锁虽然语义上是“写”但底层pthread_rwlock_unlock是不区分读锁还是写锁的读锁解锁和写锁解锁用的是同一个函数。这是 pthread 接口的常见风格如果你按“读锁必须 rdunlock 写锁必须 wrunlock”的想法去找函数反而会困惑。此外pthread_rwlock_init失败的情况虽然极其少见但一旦发生后面所有锁操作都会不可用。UE5 风格的代码里通常会用check或checkf把这个失败尽早暴露出来而不是静默吞掉。3.3 跨平台使用 FRWLock 的兼容性提示正因为不同平台的底层实现不同使用FRWLock时有几个跨平台注意事项我是踩过的第一不要在持有读锁时升级成写锁。这个操作在部分平台上可能直接死锁在另一些平台行为未定义。UE5 也没有提供规范的“读锁升级写锁”接口遇到这种需求就老老实实先解读锁再拿写锁或者干脆用临界区。第二不要依赖写者优先/读者优先的具体策略。不同平台的 pthread 实现、glibc 版本、Windows SRWLOCK 默认策略都不一样。业务代码一旦写了“写者不会被饿死”的假设到了某个 Linux 设备上就可能出问题。第三读写锁适合读多写少但“多”和“少”的比例没有绝对标准。我自己的经验是读操作比写操作高出至少一个数量级、且读操作本身不是极端轻量时换读写锁才有肉眼可见的收益。如果读写比例接近 1:1读写锁的额外开销可能比临界区还大。提示在 Android 和 Linux 上调试多线程问题时不要把 Windows 上观察到的锁行为直接套过去。FPThreadsRWLock在不同的 pthread 实现下可能存在微妙的调度差异测试时最好在目标平台真机或容器环境里再跑一遍。4. FCriticalSection 临界区代码的正确打开方式4.1 临界区到底解决什么问题FCriticalSection是整个 UE5 同步体系里最基础的一类锁。它的语义很直接线程进入临界区后其他线程必须等在门外直到当前线程离开。这个“干脆利落”的特性让它特别适合保护那些读写无法区分、或者并发读概率很低的数据结构。例如对象池的分配回收、全局配置表在运行期的临时替换、某些统计指标的累加。用临界区你不需要考虑“读锁还是写锁”这个选择只需要问自己这一段代码能同时被多个线程跑吗如果不能就用临界区。在 Windows 上UE5 对FCriticalSection的底层封装通常是CRITICAL_SECTION在 Unix 上对应的是pthread_mutex_t。两者语义接近但细节有差异后面会展开。4.2 FScopeLock 包装的常规用法裸调用Lock()和Unlock()不是不行但一旦中间有return或者抛出异常很容易忘记解锁导致其他线程全部卡死。所以 UE5 项目里更推荐用 RAII 包装类FScopeLock。#include Misc/ScopeLock.h FCriticalSection g_ConfigLock; TMapFString, FString g_ConfigTable; void UpdateConfig(const FString Key, const FString Value) { FScopeLock ScopeLock(g_ConfigLock); g_ConfigTable.Add(Key, Value); } bool ReadConfig(const FString Key, FString OutValue) { FScopeLock ScopeLock(g_ConfigLock); const FString* Found g_ConfigTable.Find(Key); if (Found nullptr) { return false; } OutValue *Found; return true; }FScopeLock在构造时调用Lock()作用域结束析构时自动调用Unlock()。这样即使函数提前 return锁也会被释放。这是 UE5 里我个人最推荐的临界区使用方式没有之一。有一个少数人才注意到的点FScopeLock的构造函数接收的是指针。如果你手上拿的是FCriticalSection要写CriticalSection而不是直接传引用。这个写错的话编译会立刻报错但第一次遇到的人确实会懵一下。4.3 临界区里的死锁和所有权问题临界区最容易出问题的场景有三个。第一个是“重复进入”。在 Windows 平台的CRITICAL_SECTION允许同一个线程重入底层记录了一个所有者线程和递归计数但在 Unix 平台的pthread_mutex_t默认不是递归的同一个线程再次Lock()同一个锁会直接把自己卡死。这意味着你写跨平台代码时绝不能依赖“临界区可以嵌套”这种 Windows 专属行为。FCriticalSection g_Lock; void Outer() { FScopeLock ScopeLock(g_Lock); Inner(); // 危险如果 g_Lock 在 Unix 实现下不可重入这里就死锁了 } void Inner() { FScopeLock ScopeLock(g_Lock); }第二个是“持锁时间过长”。临界区本身不提供锁粒度管理如果有人在锁里面跑了Sleep、网络请求、加载资源等耗时操作其他线程全都会被堵住。我自己见过的最极端案例是一个锁里面去构造了一个几千人的玩家列表结果每帧都卡几十毫秒。第三个是“锁顺序不一致”。多个锁同时出现时如果线程 A 先拿锁1再拿锁2线程 B 先拿锁2再拿锁1就可能交替等待形成死锁。临界区可以保护共享数据但保护不了“你设计混乱的加锁顺序”。经验代码审查时看到有人在一个函数里申请超过一个FCriticalSection我就会特别警惕。多锁场景不是你代码写得够仔细就能避免死锁的最好通过结构设计把多个锁合并成一个或者严格统一加锁顺序。5. 常见问题与排查记录5.1 卡死、假死和持锁阻塞的排查思路多线程出问题最常见的外在表现是“程序看起来卡住了”。但卡住不等于死锁还可能是“锁被一个长时间运行的函数占着”。排查时我一般按三步走。第一步抓线程栈。在调试器里暂停程序打开线程窗口看所有线程卡在哪一行。如果好几个线程都停在同一个锁等待函数附近嫌疑就很大。UE5 的锁等待通常能直接看到Lock相关调用栈。第二步看持锁线程到底在干什么。如果持锁线程本身停在Sleep、Wait、IO 读取等地方说明不是死锁是锁粒度问题——某个人把耗时操作包在锁里了。这时候要找的是持有锁的代码段而不是锁本身。第三步检查是否有线程在等待自己持有的锁。这种属于典型的“递归加锁死锁”。Windows 上可能没事因为临界区可重入但 Unix 上pthread_mutex_t默认不可重入直接卡死。跨平台项目里遇到类似问题先想想是不是同一个锁被同一线程重复获取。5.2 读写锁被误用成“第二个临界区”的典型情况FRWLock也经常被用歪。我见过最典型的情况是开发者看到读写锁很高级于是把一个本来就以写为主的数据结构也换成了FRWLock。结果就是性能比临界区更差。为什么因为读写锁内部维护的原子状态比临界区复杂每次加锁解锁都要多做几次原子操作。当写入比例很高时读写锁的“读读并发”优势完全用不上反而白白付出额外的同步开销。另一个典型误用是“读锁里做耗时计算”。读写锁只保证并发读安全但不保证读操作快。如果你在持有读锁期间扫描整个数组并做复杂计算写者依然会被长期阻塞。提示一个简单的判断标准如果读锁区间的平均执行时间超过几百微秒你首先应该优化的是“读的耗时”而不是考虑换更高级的锁。锁本身不背性能黑锅锁里面的代码才是。5.3 锁粒度与性能问题速查表场景推荐选择原因单变量计数器自增std::atomicint32无需锁开销最低读多写少、读取频繁FRWLock多个读者并发进入读写比例接近 1:1FCriticalSection读写锁优势不明显临界区更简单需要保护一组复杂数据结构FCriticalSection语义清晰不易误用跨平台要求强、不想关心平台差异FRWLockFScopeLock组合RAII 保障解锁接口统一锁内出现耗时操作任何锁都不合适优先把耗时操作移出锁这个表看着简单但解决了我好几次“选哪个锁”的纠结。核心思路永远是锁只是保护手段不是你业务架构的替代品。5.4 实测数据和最终结论我在模拟项目 X 里跑过一个对比实验同样的 4 读者 2 写者读操作只是简单查找字典写操作也只是插入一条记录。用FCriticalSection时总耗时大约 3800 毫秒换成FRWLock后降到约 900 毫秒。原因就是 4 个读者线程不再互相排队整个流程被打通了。但把写者比例调高到“2 读者 4 写者”后差距立刻缩小到几乎可以忽略。这说明读写锁的价值完全由业务读写比例决定。这个实验给我最大的启发是锁的性能不是绝对的脱离业务并发模型谈哪个锁更好没有意义。先看清你自己的线程模型再决定用什么锁。文章就到这里。最后分享两个我自己的习惯第一所有跨平台共享数据优先写成“多个读者并发 极少数写者”的结构这样FRWLock能发挥最大价值第二任何锁内部的代码段都要短短到可以一眼看出它在保护什么变量就基本不会出大问题。UE5 里没有万能的同步方案但把临界区和读写锁的组合用熟了大多数多线程业务场景都能稳稳接住。