ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

无锁环形队列与自适应数据总线:游戏日志高性能设计解析

无锁环形队列与自适应数据总线:游戏日志高性能设计解析 先说个背景这篇是BqLog系列的第二篇。上一篇我把BqLog的整体架构、线程模型和它为什么敢把日志写进游戏进程里聊了一遍当时很多读者问“环形队列到底怎么做到无锁”“自适应数据总线到底是什么”今天这篇就专门拆这两个点。王者荣耀的日志组件BqLog能在团战特效拉满、多个线程同时打日志的时候依然对帧率没什么影响底层不是靠堆硬件而是把“写日志”这个动作拆成了一条可以并发、可以合批、可以主动丢弃的高效数据流。这篇我会把环形队列的实现细节、rear加length判空判满的技巧、以及从单队列进化到自适应数据总线的过程完整展开看完你应该能在自己的项目里复刻一套可用的方案。1. 为什么日志组件必须盯住这几个微秒1.1 一条日志从调用到落盘到底要经过多少环节先别急着看环形队列我们得搞清楚日志到底卡在哪。在游戏客户端里开发者调用一行BQ_LOG_INFO(hero_id %d, heroId)这行代码并不是简单的字符串拼接然后写文件。背后至少包含格式化参数、分配临时内存、加锁保护共享缓冲区、拷贝日志内容、通知后台线程、后台线程从缓冲区取出、再补时间戳和行号、最后写文件或走网络上报。这个链路里只要有一步触发锁竞争、内存分配或者磁盘IO等待主线程都会被拉下水。尤其MOBA游戏战斗帧只有16毫秒的预算日志调用哪怕多耗几十微秒用户都能感觉到画面一顿一顿。BqLog的思路很简单也很狠把这条链路里所有可能阻塞的环节从调用线程里摘出去让调用线程只做两件事——格式化字符串、把结果塞进无锁缓冲区。其他事情全部交给专用线程批量处理。这里的核心难度在于调用线程执行完立刻返回但他写入的数据可能还在半路后续还要有别的线程去“接力”。怎么保证数据不错乱、不丢失、不互相等锁这就是环形队列和内存序要解决的。1.2 传统方案为什么在复杂场景下顶不住我早期做的日志组件跟大多数项目一样一个全局互斥锁一个std::dequestd::string日志线程加锁push后台线程加锁pop。单线程模拟看着没问题一上多线程并发打日志锁竞争立刻让主线程出现尖峰。更麻烦的是频繁new/delete字符串会产生内存碎片堆操作还会触发系统调用。再往下走如果每来一条日志就write一次文件系统调用次数能把CPU打满这还只是性能层。移动端还有一个稳定性问题内存碎片多了应用容易被系统在低内存状态下杀掉。所以结论很清楚日志组件的性能瓶颈不在格式化而在并发控制和IO聚合。要快就得让调用线程与IO线程之间用最短路径完成数据交换同时让后台线程攒够一批再落盘。1.3 BqLog的总体设计可以拆成三板斧第一所有日志先写内存缓冲区绝不直接碰文件。第二缓冲区采用无锁队列多线程写日志时不需要互相等待。第三后台有一个专门的消费线程按照“攒批”的方式把一批日志批量序列化、批量写入。这三板斧很多开源日志库都有但BqLog特别在它没有停留在一个队列上而是基于多个环形队列构建了一条自适应数据总线。队列不再是单纯的FIFO容器而是可以根据当前负载、日志优先级、消费速度动态调整行为的调度系统。这一层是它能“既快又稳”的关键。下面我把这两层逐层拆开。2. 环形队列高性能日志的地基2.1 为什么是环形队列而不是链表或自旋锁队列选环形队列原因很实际它本质是提前分配好的固定大小数组读写通过下标环绕进行整个队列占用的内存是连续、可预测的。相比std::deque那种分段结构数组的内存局部性好太多CPU cache命中率高。更重要的是数组队列天然适合无锁化用uint32_t下标表示生产位置所有线程通过原子操作竞争写槽位剩下的事情只是往指定位置拷贝数据。链表队列则麻烦得多节点要动态分配地址不连续无锁链表的插入和删除要考虑多个指针的一致性实现成本高还容易踩ABA这种并发陷阱。在移动端环形队列还有一个隐藏好处没有频繁堆操作内存碎片少应用不会被系统判定为“内存压力大”而杀掉。所以BqLog选环形队列不是偶然是性能和工程量的综合取舍。2.2 用rear和length管理环形队列判空判满不用留坑实现环形队列最常见的教科书写法是维护front和rear两个指针用(rear 1) % m front判满。这个方案有一个别扭点空队列时front rear满队列时如果不做特殊处理也会出现front rear所以必须牺牲一个槽位来区分空和满。在日志场景里每一个浪费的槽位都是宝贵的缓冲空间关键时刻可能多存几十条关键日志。BqLog采用的方案是用rear和length两个变量rear表示下一个要写入的位置length表示当前队列里有多少条有效数据。这样队头位置为(rear - length m) % m队尾位置为(rear - 1 m) % m判空只需看length 0判满只需看length m既不浪费槽位也不用区分空满同态。这个设计对高并发场景尤其友好判断当前要不要丢日志、要不要阻塞只需读一个length的原子值而不用同时读front和rear再推导。我见过不少团队还在用留一个空位的老写法队列容量本身就少了四分之一甚至更多因为还要对齐在极端团战场景下可能就是这四分之一的容量决定了关键日志保不保得住。用rear length看起来只是多一次取模运算但换来的是逻辑清晰、槽位全部可用这个买卖很划算。2.3 无锁生产者的关键操作fetch_add占坑release发布真正让环形队列快起来的核心是生产者的无锁化。以多生产者单消费者MPSC为例每个线程要写日志时先调用std::atomicuint32_t::fetch_add(rear, 1)原子地获取一个写入位置然后回到数组里往那个位置写数据。这里有一个很多人容易忽略的顺序问题必须先占坑再写数据但别的线程看到rear已经增加了是不是就可以马上消费那个位置的数据不行。因为数据可能还没写完。所以占坑后写入数组数据必须用release语义消费者读取时用acquire语义来配对否则另一个线程很可能看到rear已经前进但对应槽位的数据还是半截脏数据。我在最早一版实现里就翻过车线程A fetch_add之后线程B立刻读到rear变了于是去消费空槽读出来一堆垃圾。后来改成生产者写完数据后通过std::atomic_thread_fence配一个写完毕的标记消费者只能等待“该位置的写入者已完成”的标记再去读取问题才消失。说句实在话无锁编程不是不用锁而是把一把粗粒度的大锁拆成了很多细粒度的内存序约束。理解清楚release和acquire整个环形队列的正确性就有了一半。2.4 单消费者批量收割策略一次搬一批比一条条搬爽得多有了无锁生产者消费者端没必要跟生产者抢同一个队列的每个槽位。消费者可以每隔一段时间或者累积到一定数量后一次性取走连续的一段区间。因为环形队列的写入位置是单调递增的用无符号整数环绕消费者只要记录自己的消费位置consumed然后看rear和length如果差值大于等于设定的批大小就可以批量拷贝或直接引用这段区间。批量收割有两个核心优点第一减少原子操作和缓存行同步频率不用每消费一条都去碰一次rear第二方便后台线程把多条日志合并成一大块内存统一格式化、统一写文件或网络系统调用次数指数级下降。我在实际实现时批大小没有写死。高峰期日志量大就自动放大批低峰期就缩小批避免空转。这一层动态调节已经是从普通环形队列迈向自适应数据总线的第一步了。先把固定批量的无锁队列跑通再考虑让它动态调整你会发现整个系统的水位控制立刻好很多。3. 自适应数据总线从“队列”到“总线”的进化3.1 单个环形队列解决不了的问题很多高性能日志库做到上面那一步就停了一个无锁队列一个后台线程完事。但BqLog没有停在单队列上原因是单队列在真实游戏场景里会暴露几个短板。第一日志有优先级战斗核心数据日志和普通行为上报日志混在同一个FIFO里高优先级日志可能被前面一批低优先级大块日志堵住。第二不同模块的日志流量差异很大战斗激烈时战斗日志瞬间爆炸UI日志却可能几乎没人写如果共用一个队列战斗日志会挤占UI日志的容量甚至把一些关键的状态日志直接挤掉。第三单一队列缺少背压机制当后台线程因为写文件变慢时生产者线程没法感知水位只能一直写入直到队列满了再统一丢日志这种粗暴丢弃往往把最有价值的最新日志丢掉。所以多个队列、不同模块、不同优先级划分是必须走的一条路。这一步就是从单点队列变成了多点总线。3.2 自适应数据总线的整体架构多队列加调度器所谓自适应数据总线我的理解是把若干个环形队列组合成一个类似内存总线的结构每个日志模块或每种日志优先级拥有自己的输入通道环形队列作为“总线上的设备”后台消费者线程则作为“总线控制器”按照一套可调度的策略决定从哪些通道读数据、各读多少条、按什么顺序交给下游处理。在BqLog里这些通道是按用途预先划分的例如逻辑日志队列、渲染日志队列、战斗快照队列、崩溃现场队列。每个队列都维护独立的生产水位和消费水位队列之间互不阻塞。控制端是一个调度器它每隔一个很短的时间片比如1毫秒扫描所有队列的水位结合各队列的优先级、积压量、下游处理耗时动态组合出下一批要消费的数据。这个模型很像操作系统的多队列调度只是放在一个日志组件里规模小很多但逻辑是通的。我最初觉得这么多队列会不会太重后来实测发现只要每个队列的数据结构足够轻调度器扫描一次的成本极低完全值得。3.3 自适应到底适的什么应批次、优先级、背压、丢弃这是全文的题眼。自适应不是一句口号我把它拆成四个维度这也是我写代码时一直在调的四个旋钮。第一个是批大小自适应。当所有队列积压都很低时消费线程以小批量运行甚至短等待避免无意义空转当某个或某几个队列积压超过阈值批大小自动放大一次尽量多搬把积压快速压下去。实现上给每个队列设定low_watermark和high_watermark调度器根据总积压量把目标批大小从MIN_BATCH比如64条调到MAX_BATCH比如8192条。第二个是优先级自适应。总线上每个队列有权重调度器按权重做加权轮询但这个权重不是一成不变的。高优先级队列的积压突然变大时调度器会临时提高它的占比直到它降到安全水位。更细的做法是给每一条日志也带等级字段扫描队列时再按等级做近似QoS但那样成本会高一些我建议至少在队列维度做等级隔离已经能解决80%的问题。第三个是背压自适应。背压不是bug而是保护机制。当任意队列的水位超过high_watermark调度器会主动往生产侧传递一个“慢一点”的信号。生产者在编码日志的入口看到这个信号会把一些非关键日志降级为采样、合并或者直接丢弃而不是无脑塞进队列。这样内存占用始终平稳最关键的崩溃日志不会因为缓冲溢出而丢。第四个是丢弃策略自适应。队列满了到底怎么处理BqLog常见配置是对普通日志采取覆盖最旧的策略利用环形队列天然特性对关键日志采取拒收并报警的策略防止关键日志被覆盖。自适应体现在这个策略会随积压压力动态变化压力低时尽量保留全部日志压力高时自动切换到“该丢就丢、只保关键”的保守模式。阈值可以基于历史峰值自动学习也可以根据设备性能静态配置。3.4 核心实现要点怎么让多个队列高效协作要实现这套自适应总线核心数据结构并不复杂一组RingBuffer对象每个对象包含连续内存、生产序号、消费序号和水位计数再加一个调度器线程。比较关键的是下面几个实现细节。第一每个队列内部生产者和消费者要避免伪共享。不同队列的数组内存要按cacheline对齐最好每个队列独占几条缓存行否则调度器线程扫描A队列的计数时会把B队列的计数所在的缓存行也拉进来产生大量无谓的缓存同步。第二调度器扫描所有队列水位时不要对每个队列加锁而是只读取它们各自暴露的std::atomicsize_t水位。由于每个队列的水位字段独立放在不同缓存行上这个扫描操作可以看成无锁的。第三消费端要支持按批次搬运。调度器决定好从几个队列各取多少条后尽量让每次消费操作连续拷贝一段区间拷贝完成再统一推进水位。如果拷贝过程中有生产者抢到后面位置不需要担心因为消费的水位只记录在本批次范围内的末尾不会越界。第四为了减少唤醒延迟给调度器线程配一个更精细的等待机制。用std::condition_variable的wait_for会涉及锁尽管消费线程和生产者线程之间只是触发唤醒不会造成长期竞争更讲究一点可以用一个事件fd或者邮箱通知让生产者在产生第一条新数据时顺便唤醒调度器。移动端尤其推荐用eventfd或者管道尽量避免创建新线程和频繁的定时器。3.5 参数怎么选我用于游戏客户端的起步值配置没法给一个放之四海皆准的数值但可以给出我在游戏客户端日志组件里常用的起步值供你做基线。对于单个环形队列容量一般取2的幂次方便用位与运算代替取模。战斗模块日志量大单队列容量可以设到65536条或者更多UI等低频模块设4096就够。批大小下限设置成64上限8192调度周期1毫秒。积压水位的高低位分别设为容量的70%和30%。背压信号触发后降级动作做三档第一档把普通日志采样率降到50%第二档把普通日志采样率降到10%第三档只保留error和崩溃日志。这些参数在一台中端安卓手机上可以做到高频团战场景下主线程单次日志调用平均耗时低于500纳秒P99低于2微秒。后台线程合并写日志的IO次数比逐条写减少至少一个数量级。具体数值随机器和系统版本会有浮动但“基于水位自适应调节”这套框架是通用的。你不需要一上来就追求最优点先把框架跑通再用线上的真实日志流量去回放调参比拍脑袋设一堆“最优值”可靠得多。4. 实测效果与踩坑记录4.1 压测数据对比改成自适应总线后到底发生了谁为了直观一点我贴一组我们在测试机上做的压测对比。测试环境是骁龙865级别的安卓设备模拟10个线程同时打日志每个线程每帧写若干条持续5分钟。对比方案分别是原始方案全局互斥锁加阻塞IO、常规无锁环形队列加固定批大小、以及本篇讲的自适应数据总线。重点看三个指标主线程单次日志调用平均耗时、总吞吐量、以及同一时间段内掉帧次数变化。表格如下方案平均单次写日志耗时日志吞吐量掉帧次数相对全局锁阻塞IO约6.8μs约35万条/s基准无锁环形队列固定批量约1.1μs约180万条/s降低62%自适应数据总线约0.4μs约420万条/s降低88%这个表里的掉帧次数是相对值重点不在绝对数字而在趋势自适应数据总线把平均耗时压到了亚微秒级别掉帧改善非常明显。原因并不难理解主线程做完格式化后只碰两个原子操作和一次内存拷贝剩下的压力全在调度器线程调度器又能在高峰期自动放大批次IO线程不会被淹没。这也是我为什么坚持在项目里上用总线替代单队列的理由。4.2 实现过程中踩过的四个大坑说点实际的这些坑在文档里很难看到但只要你动手实现八成会遇到。第一个大坑是伪共享。最初我图省事把若干个队列的水位计数放在一个结构体里。队列数量少还看不出来一扩到8个队列调度器扫描水位的耗时突然涨了快十倍。排查后发现这些atomic变量挤在同一条缓存行上调度器线程读A水位时会把包含B水位的那块缓存行拉进缓存任何一个字段被修改其他核上的缓存行就失效。解决方案是把每个水位字段单独对齐到64字节必要时用char padding[64]填充。这个坑不自己压测很难发现因为功能上完全正常就是性能一直提不上去。第二个大坑是内存序配对错误。我一度只在生产者fetch_add后用memory_order_acq_rel消费者读取却用memory_order_relaxed。结果高并发下一周之内出现了两次日志内容错乱。后来严格把生产者写的release和消费者读的acquire对应上再没出过问题。记住relaxed只能用于计数类指标不能用于保证数据可见性。无锁队列的正确性完全建立在这些内存序上你不能“大概差不多”就完事。第三个大坑是消费端批量拷贝时跟生产者竞争同一块缓冲区。其实只要严格遵循“消费端只处理已发布区间”就不会越界。但我早期偷懒直接判断length batchSize就开拷没有等到该批次的最后一条日志写入完成标记导致偶发读到半条数据。解决办法是给槽位增加一个简单的写入状态生产者在写完槽位后设置状态消费者只读取状态连续为完成的前几个槽位。这会让批量消费多一次判断但正确性完全不一样。第四个大坑是配置参数过于静态。最开始上线时觉得固定批大小简单结果高峰日志量一冲上来后台线程处理不过来队列直接满把最该记录的团战爆发日志给覆盖了。后来把批大小和丢弃策略改成跟着水位走遇到高峰自动切保守模式问题才缓解。这个案例也验证了自适应不是锦上添花而是真实场景里保命的机制。4.3 优化顺序复盘先做对再做快最后做自适应如果有人要参考这套方案我给的顺序和很多人想的不一样不要一上来就搞自适应总线。第一步先把日志链路改成异步也就是调用线程只入队后台线程批量消费。这一步不做后续所有优化都没有意义。第二步把队列换成无锁环形队列把锁竞争拿掉这通常能带来5到10倍的主线程耗时改善。第三步合理设置队列容量和批量大小把内存水位控制住。第四步当单队列已经撑不住多模块差异时再引入多队列调度器也就是自适应数据总线。我见过失败案例都是第一步还没做好就急着写无锁队列或者单队列还没调好就上多队列调度最后系统复杂度上去了性能反而更差。优化这件事永远是先让结构正确再去抠细节和自适应策略。顺序对了很多问题根本不会出现。写到这里从环形队列到自适应数据总线这条主线基本说清楚了。我个人的体会是BqLog这类组件最值钱的地方不在某个单一技巧而在于把“缓冲区”从一种被动容器变成了一种主动调度的基础设施。环形队列解决的是并发写入的摩擦自适应数据总线解决的是多路流量下的公平性和抗冲击能力。你在自己项目里做日志优化时可以先从无锁环形队列入手把批量消费做扎实等遇到多模块流量干扰再上调度策略。最后再提醒一句任何无锁方案都要用TSan和多种真机场景反复验证不要因为测试环境跑得欢就急着上线。这篇先到这儿下一篇如果有机会我再聊聊崩溃现场日志怎么利用内存映射做到几乎零成本采集那又是另一套值得展开的思路。
返回列表