Skip to content

异步日志系统设计 ​

自研生产者-消费者异步日志系统:主线程只做快照和投递(< 0.2ms),字符串拼装和文件 I/O 全部挪到后台线程(5-20ms),带背压、削峰和丢日志计数。

主线程投递< 0.2ms
同步日志开销~2ms/帧
帧预算占比12.5% → <1%

背景 ​

XMapEngine 的日志输出在高频场景下严重影响性能。典型场景是手势拖拽:每帧触发约 60 次日志调用,同步写日志每帧开销约 2ms——60fps 的帧预算只有 16ms,日志一项就吃掉 12.5%。

日志又不能一关了之:线上问题排查全靠它。真正的问题是把不该在主线程做的事放在了主线程——字符串格式化、文件 I/O,这些和渲染一帧没有任何关系。

问题 ​

拆一下同步日志每帧的 2ms 花在哪:

  1. 字符串格式化:string.Format / 拼接在调用线程同步执行,参数越多越慢。
  2. 文件 I/O:写盘有系统调用和 flush 开销,撞上 I/O 抖动就是长尾。
  3. 锁竞争:多模块共用一个 logger 时,主线程还要和别的线程抢锁。

也就是说,主线程真正需要做的只有一件事:把「谁在什么时间打了什么」这个事实记下来。其余都是后台的活。

方案 ​

生产者-消费者模型 ​

mermaid
flowchart LR
    A["业务代码<br>Log() 调用"] --> B["主线程快照<br>提取关键字段<br>&lt; 0.2ms"]
    B --> C["无锁环形 buffer<br>1024 条"]
    C --> D{"buffer 已满?"}
    D -->|"否"| E["后台线程消费"]
    D -->|"是"| F["丢弃最旧日志<br>dropped 计数 +1"]
    E --> G["字符串拼装<br>5-20ms"]
    G --> H["写文件 / 上报"]

核心设计:

  • 主线程快照 + 投递:只做字段提取,不做任何字符串格式化;用 Interlocked 无锁投递到环形 buffer,单次 < 0.2ms。
  • 后台线程拼装:独立线程从队列取数据,做格式化 + 文件 I/O,耗时 5-20ms 也无所谓,不阻塞主线程。
  • 背压与削峰:环形 buffer 固定 1024 条;写满时丢弃最旧日志并让丢弃计数器自增——丢没丢、丢多少,事后可查。高频场景再叠加降级策略:关闭详细日志,只保留关键事件。

线程安全 ​

  • 读写分离:主线程只写 tail index,后台线程只读/推进 head index。
  • 用 Interlocked.Increment 保证 index 原子性,整体无锁,避免 mutex 抢占带来的不确定延迟。

这套「主线程轻量采集、后台线程重处理」的纪律,后来也贯穿了整个 Analysis 监控模块的设计:主线程的采集函数只允许做计数、滑窗、固定队列、快照这几件事,明确禁止字符串拼接和堆栈采样;跨线程数据一律走 ConcurrentQueue 或 RingBuffer+lock,主写后台读的 List/Dictionary 直接共享是被明令禁止的——因为那种写法早晚出事。同一个思路在 Native-Unity 信号链路上也验证过一次:高频通道从「推送」改成 Unity 主动 Query 拉取,就是为了避免高频回调把主线程压垮。日志、监控、信号,三个系统的结论是一致的:主线程只负责记录事实,重活一律异步。

关键实现 ​

cpp
class AsyncLogger {
    RingBuffer<LogEntry> buffer_;  // 环形 buffer,1024 条
    std::thread worker_;            // 后台消费线程
    std::atomic<uint64_t> dropped_; // 丢弃计数(削峰可见性)

    void Log(LogLevel lvl, const char* fmt, ...);  // 主线程调用,< 0.2ms
    void WorkerLoop();                              // 后台线程:拼装 + I/O
};

几个落地细节:

  • LogEntry 存的是原始字段(level、时间戳、format 指针、参数快照),不是拼好的字符串——拼装延迟到后台。
  • 后台线程批量取、批量写,摊薄 I/O 次数。
  • 崩溃安全是已知取舍:环形 buffer 在内存里,进程崩溃时未消费的部分会丢。关键路径(crash 前的最后现场)由监控上报链路的异常上报兜底,不依赖日志落盘。

指标 ​

指标同步日志异步日志
主线程开销~2ms/帧< 0.2ms/帧
帧率影响-12.5%< 1%
日志完整性全量削峰时丢少量(有计数)
崩溃安全性高中(环形 buffer 未持久化)

落地情况:

  • XMapEngine 日志后台线程化已落地,支撑线上高频日志场景(手势拖拽每帧 60 次调用也不再掉帧)。
  • 日志量上去了、性能没下来,问题排查效率反而提升——因为敢打更多日志了。
  • 削峰丢弃计数接入监控口径,丢弃量异常升高本身就是信号:说明某个模块在刷日志,顺藤摸瓜又抓到过几处误用。

复盘 ​

  1. 先算帧预算账,再谈优化。~2ms 看起来不多,放进 16ms 的帧预算里就是 12.5%,这个数字一摆出来,改不改就没有争议了。
  2. 异步系统的丢数据不可怕,不可见才可怕。削峰丢弃加计数器,是这个设计里最便宜也最值的一行代码。
  3. 主线程纪律要写进规范,而不是靠自觉。后来在 Analysis 模块里把「采集函数禁止字符串拼接/堆栈采样」写成明文规范,配合新增 Monitor 的接入 SOP 执行,否则框架做得再好也会被业务代码破坏。

罗子初 · AI 座舱专家 · 8年端侧研发

Lv.1