王者荣耀日志组件BqLog为什么这么快之2——从环形队列到自适应数据总线
在追求极致性能的系统中,减少一切不必要的计算是优化的核心。以手游为例,帧率和流畅度是其基础体验的关键,而游戏的发行版本往往被一个“不可能三角”所困扰:
Spots 在追求极致性能的系统中,减少一切不必要的计算是优化的核心。以手游为例,帧率和流畅度是其基础体验的关键,而游戏的发行版本往往被一个“不可能三角”所困扰:

国内发行的手游通常优先选择保1和3,放弃2。而HOK作为《王者荣耀》的国际服,由于面向全球的发行背景,面临时差、语言和隐私观念等问题,在遇到疑难杂症时很难直接与用户沟通。这种时候我们就需要一种产品,能帮助我们“既要又要”,打破这个不可能三角。BqLog就是在这样的背景下诞生的。 BqLog不仅适用于客户端,也适用于服务器,能用于多种编程语言,也能兼容多种操作系统,具体请见Github地址: github.com/Tencent/BqL… 本篇是系列第二篇。第一篇定了文件格式;但日志进文件之前,还要从几十个业务线程交到一个后台线程手上。本篇讲这条内存通道怎么建:从普通环形队列出发,走到 BqLog 的自适应数据总线。 为何 BqLog 如此快之二:从环形队列到自适应数据总线 上一篇讨论了压缩文件如何减少格式化和写入量。但日志在进入文件之前,还要从业务线程交给后台线程。几十个线程同时写入时,即使每条记录都很小,共享队列的写游标也可能成为热点。 BqLog 的 log_buffer 为低频线程提供共享队列,为高频线程分配专用队列。下面从普通环形队列讲起,看看多生产者写入、变长记录、线程增删和崩溃恢复分别带来哪些麻烦。
本文对应 BqLog 2.5.0。MISO、SISO 是项目里的命名习惯:MISO 表示多生产者单消费者(Multi In, Single Out,也就是常说的 MPSC),SISO 表示单生产者单消费者(Single In, Single Out,即 SPSC)。代码里的对齐常量 BQ_CACHE_LINE_SIZE 是 128 字节,下文简称 C。它是库自己选的布局单位,不代表所有 CPU 的硬件缓存行都是 128 字节。
参与比较的有 C++ 的 spdlog(异步模式)、glog、fmtlog、quill 和 Java 的 Log4j2,各组件的完整配置见 BENCHMARK_CHS.md。下表是 1–10 个线程的结果:每线程写 200 万条日志,每条带 4 个格式化参数;机器为 PC(AMD Ryzen 9 9950X,16 核 32 线程,96 GB,Windows 11 Pro;Java 用 JBR 21.0.9)。 这里每增加一个线程,总日志量也增加 200 万条。例如 10 线程对应 2000 万条日志,比较吞吐时要用这份总量除以耗时。
10 线程时,BqLog 压缩模式用时 507 毫秒:约为 Log4j2 的 1/14、异步 spdlog 的 1/65,对 fmtlog 和 quill 也快约 13.7 倍和 15.4 倍。加密模式 493 毫秒,与不加密持平(差异在测量噪声内)。这些是整条写入路径的测试结果,不能单独归因于队列。用例和配置见 Benchmark 与 BENCHMARK_CHS.md。 格式化、缓存访问、I/O 和线程间通信都会影响总耗时。本文只讨论最后一项:共享 MISO 队列如何处理多生产者分配,以及高频线程如何转到各自的 SISO 队列。相关方案已在 BqLog 开源前申请专利。 熟悉单生产者环形队列和 Disruptor 的读者可以直接看 第 3 节。这里主要讨论并发控制,不展开内存模型的全部细节。 先记住队列要回答的两个问题:生产者能写哪段空间,消费者能读到哪里。前一个问题要防止覆盖未读数据,后一个问题要防止读到尚未写完的数据。单生产者时两个游标就能协调好;生产者一多,空间预留和写入完成就得分开处理。 kFifo 是 Linux 内核提供的循环队列(FIFO)实现。这里取其单生产者、单消费者用法:一个线程写,一个线程读。 图 1:kFifo 的环形结构——In、Out 两个游标各只有一个主人。
队列用 in 和 out 记录累计写入、读出的进度,只有访问内存时才取模映射到环上。例如容量为 16,out=12、in=20,就有 8 个单位尚未读出,下一处写入的物理位置是 20 % 16 = 4。后文出现 1000、1030 这样的游标,也是在表示累计进度。 图中红色格子是尚未消费的数据,Out 对应最老的位置,In 对应下一处可写位置。容量取 2 的幂时,取模可以写成 cursor & (size - 1)。 用 acquire/release 伪接口写出单生产者、单消费者的基本过程:
写线程独占修改 in,读线程独占修改 out,因此不需要用 CAS 争取游标。写线程先拷贝数据,再以 release 语义发布 in;读线程以 acquire 语义取得这个进度后,也能看到此前写好的数据。读完后的 out 则反向告诉生产者:这些空间已经可以覆盖。 这里的原子读写负责双方可见性,CAS 则是用来协调多个修改者。若两个生产者同时执行上述写入过程,它们可能读到相同的 in,随后向同一位置拷贝数据。要支持多生产者,首先得让每个线程取得不同的位置。 LMAX Disruptor 是 LMAX 开源的并发框架,Log4j2 的异步日志也使用它。代码和文档见 LMAX-Exchange/disruptor。 它也使用环形结构。以下按日志场景示意:槽位(Slot)持有 Log Entry 的引用,实际数据在对象中。 图 2:Disruptor 的环——槽位里放的是 Log Entry 引用,不是数据本身。 多生产者可用 CAS(Compare-And-Swap) 争取写入位置:只有当前值与预期值相同才更新;失败的线程重新读取游标并重试。 我们试着用 CAS 改造 kfifo_in,让它支持并发写入: CAS 成功时,当前线程取得从 old_in 开始的区间;失败说明别人推进了游标,需要重新计算容量并重试。空间分开了,接下来还要解决完成状态的发布。 预留游标不能直接当作可读边界。单生产者可以先写数据、再发布 in;多生产者先各自取得位置,再写数据,完成顺序可能与预留顺序不同。 图 3:先申请后写入的麻烦:A 和 C 的块已经写完(阴影),B 还没写完,In 不再能当可读边界。 图中 A 和 C 已经写完,B 尚未写完。消费者不能越过 B 读取 C,因此需要独立的完成状态。Disruptor 通过槽位序号跟踪发布进度;BqLog 把变长数据直接放在环形内存里,采用后文的块状态。 3.
MISO 队列用 fetch_add 分配空间 多个线程同时用 CAS 更新同一个游标时,失败的一方必须重读再重试。线程一多,重试就频繁到不能忽略。MISO 队列在空间分配这条常用路径上改用 fetch_add。 fetch_add 原子地增加游标,并返回增加前的值。所有调用对这个变量仍有先后次序,但每个线程都能拿到自己的旧值,程序不必再写一轮“比较失败就重试”的循环。 假设 in 初始值是 0,三个线程 A、B、C 同时申请 10、5、15 字节,执行完后它们拿到的起始位置可能是: 线程A:0,线程B:10,线程C:15,in 最终是 30 线程B:0,线程C:5,线程A:20,in 最终还是 30 图 4:fetch_add 为不同线程分配不同区间;容量上限仍需单独检查。
顺序取决于线程的实际执行次序,但拿到的区间互不重叠。两种方式的脾气不一样:CAS 是“确认没人动过才更新,失败就重来”;fetch_add 是“先把号拿到手,有问题再处理”。图中还画出了容量上限:fetch_add 不检查剩余空间,预留之后还要验证拿到的区间是否有效。 fetch_add 只保证游标增加是原子的,不能保证队列有足够容量。 图中容量上限 Limit 为 25,多个线程预留后,In 到了 40。C 的末尾和 D 的全部区间都超出当前可写范围,不能直接写入。 消费者把 Out 推进到 15 时,可写上限也移到 45。先前越界的区间此时可能重新变得有效;回滚判断要考虑这个变化。 CAS 不会有这个问题,因为最后一次更新时必须确认 in 和当初判断时一模一样,中间有人动过就失败重来。 bq::miso_ring_buffer 在预留后检查容量,必要时回滚。下面的伪码省略了环回绕等细节(伪码里的 in_、out_ 就是源码里的写游标 write_cursor_、读游标 read_cursor_,§4 起用源码的名字):
回滚仍然需要 CAS,而且可能反复失败。BqLog 把这份协调工作放在预留越过容量上限时:空间充足的常用路径用一次 fetch_add 取得位置,容量不足时才等待后续预留撤销,或等消费者腾出空间。真实实现里恰好写满也按越界处理(判断用 ≥);游标是 32 位无符号数,源码注释自己交代了边界——所有线程累计分配达到数百 GB 量级才可能出现环绕,离日志缓冲区的实际规模很远。 为什么不能直接执行 fetch_add(&this->in_, -len)?看下面的交错顺序:多个生产者已经预留空间,消费者又在推进 out,直接减去自己的长度可能让后续分配与仍有效的区间重叠。 图 5:A、B、C 预留空间后,B 和 C 的区间越过当前可写上限。 分配前 in 为 1000,可写上限为 1012。A、B、C 先后预留后,in 到了 1030,B 和 C 的区间越界。如果 B 直接减去自己的长度,可能出现以下顺序: B 执行 fetch_add(&in, -5),把 in 减到 1025; B 再申请 5 字节,得到 1025–1030,此时 in 又到了 1030; C 发现自己的 1015–1030 区间已在容量内,也开始写入。 图 6:B 直接减去自己的长度并再次申请,结果与 C 的区间重叠。
在追求极致性能的系统中,减少一切不必要的计算是优化的核心。以手游为例,帧率和流畅度是其基础体验的关键,而游戏的发行版本往往被一个“不可能三角”所困扰:
