第 1 课:trantor 日志系统
对应源文件:
trantor/utils/LogStream.h— 底层缓冲区 + 流写入trantor/utils/Logger.h— 核心日志类 + 所有宏定义trantor/utils/AsyncFileLogger.h— 异步文件写入
一、整体架构
| |
核心思想:Logger 是一个临时对象,构造写头,析构输出,<< 运算符把数据攒进缓冲区。整个过程利用 C++ 的 RAII 机制自动完成,用户只需一行代码。
二、LogStream:底层流缓冲区
2.1 FixedBuffer — 固定大小缓冲区
| |
FixedBuffer<SIZE> 是一个模板类,内部就是一个栈上的字符数组:
| |
append(buf, len):把数据memcpy到cur_处,然后cur_ += lenavail():返回剩余可写字节数= end() - cur_reset():直接把cur_拨回data_,O(1) 清空(不需要 memset)
为什么用栈内存?
日志是高频操作,每次 new/delete 开销大。栈上分配几乎零成本,且 4000 字节对于单条日志绰绰有余。
2.2 LogStream 的溢出保护
LogStream 内部有两个缓冲:
| |
写入逻辑(append 方法,第 204-218 行):
| |
这是一种懒惰转移策略——正常情况下不分配堆内存,只有真正溢出才付出代价。
2.3 operator<< 重载
LogStream 对所有常用类型都重载了 <<,返回 self& 以支持链式调用:
| |
特别注意 nullptr 安全:
| |
三、Logger:核心日志类
3.1 日志级别
| |
默认级别(静态函数 + 函数内 static 变量,第 282-290 行):
- Debug 构建:
kDebug(TRACE/DEBUG 都输出) - Release 构建(定义了
RELEASE宏):kInfo(只输出 INFO 及以上)
3.2 SourceFile — 编译期提取文件名
| |
__FILE__ 展开是完整路径如 /home/user/project/src/GameServer.cpp,SourceFile 在编译期通过模板参数 N 知道字符串长度,然后找最后一个 / 截取文件名,输出变成 GameServer.cpp。
为什么是编译期? const char (&arr)[N] 模板参数 N 在编译时确定,strrchr 在运行时执行一次,但因为 __FILE__ 是字符串字面量,整个计算实际上是非常快的。
3.3 Logger 的构造函数族
| |
构造时会调用 formatTime() 把当前时间写入 logStream_,析构时才调用输出函数。
3.4 多通道(Channel)设计
| |
index = -1 表示默认通道,正整数表示具名通道。这个设计允许:
- 通道 0 写本地文件
- 通道 1 发送到远端日志服务器
- 通道 2 写到数据库
用法:
| |
当通道 index 超出已有 vector 范围时,自动扩容并用默认输出函数填充——这是一个懒初始化设计。
四、宏展开机制(精华)
4.1 TRANTOR_IF_ 的魔法
| |
这是一个只执行一次的 for 循环,等价于 if (cond),但有一个关键优势:
| |
展开后:
| |
关键点:
Logger(...)是一个临时对象,在 for 循环语句结束(分号处)立即析构- 析构时调用输出函数,日志被发出
- 如果级别不满足,整个
Logger对象根本不构造,零开销 - 用
for而不是if是为了避免宏在if/else中出现悬空 else 问题
4.2 完整展开示例
| |
4.3 TRACE 在 Release 下的零开销
| |
TRANTOR_IF_(0) 展开为 for (int _r = 0; _r == 0 && (0); _r = 1),条件恒为 false,编译器会完全删除这段代码,不产生任何运行时开销。
4.4 宏命名规律
| 前缀 | 含义 |
|---|---|
LOG_xxx | 标准格式(时间 + 文件名:行号 + 级别 + 消息) |
LOG_xxx_TO(n) | 同上,输出到通道 n |
LOG_COMPACT_xxx | 紧凑格式(无文件名行号) |
LOG_xxx_IF(cond) | 条件日志,cond 为真才输出 |
DLOG_xxx | Debug-only 日志,NDEBUG 时全部编译为空 |
LOG_RAW | 原始输出,不附加任何头部信息 |
LOG_SYSERR | 自动附加 strerror(errno) |
五、AsyncFileLogger:生产环境异步写日志
5.1 为什么需要异步?
同步写日志存在问题:
fwrite是同步 I/O,在高并发下会阻塞调用线程- 磁盘 I/O 延迟可能达到毫秒级
- 游戏服务器主线程不能等磁盘
5.2 双缓冲 + 后台线程设计
| |
| |
交换的魔法:std::swap(logBufferPtr_, nextBufferPtr_) 是指针交换,O(1) 完成,主线程几乎不被阻塞。
5.3 LoggerFile — 日志文件管理
| |
文件名示例:trantor.20240101_120000_1.log(基名 + 时间 + 序号 + 扩展名)
文件轮转逻辑:
sizeLimit_默认 20 MB,超过则调用switchLog()切换到新文件maxFiles_默认 0(不限制),设置后会调用deleteOldFiles()删除最旧的文件switchOnLimitOnly_= true 时,只有达到大小限制才换文件(不在析构时换)
5.4 使用方式
| |
六、RawLogger
RawLogger 是 Logger 的简化版,只有一个 LogStream,析构时直接调用 outputFunc_,不附加任何时间戳、级别、文件名。
用于输出需要原始格式的内容,如打印二进制数据的十六进制 dump。
| |
七、关键设计模式总结
| 设计模式 | 体现 |
|---|---|
| RAII | Logger 析构时自动输出,用户不需要手动调用 flush |
| 策略模式 | setOutputFunction() 可以替换输出后端(stdout / 文件 / 网络) |
| 双缓冲 | AsyncFileLogger 用两个缓冲指针交替,降低主线程等待时间 |
| 函数内 static | logLevel_() / outputFunc_() 用函数内 static 实现单例,避免静态初始化顺序问题 |
| 懒初始化 | 多通道 vector 按需扩容,首次使用才分配 |
| 零开销抽象 | NDEBUG 下 LOG_TRACE 编译为空,运行时零开销 |
八、常见使用场景
场景 1:游戏服务器快速接入
| |
场景 2:条件日志避免性能损耗
| |
场景 3:系统错误自动附加 errno
| |
核心收获
LOG_INFO << "msg"展开为临时 Logger 对象,析构时刷出,写入路径无格式化开销FixedBuffer<4000>是栈上固定缓冲,日志写入路径无堆分配AsyncFileLogger双缓冲交换:前台写内存队列,后台线程批量刷盘,I/O 线程不阻塞Logger::setOutputFunction()可对接任意后端(ELK、syslog、自定义),生产环境必须设置
九、思考题
- 为什么
TRANTOR_IF_用for而不是if?(提示:考虑if/else嵌套场景) FixedBuffer::reset()只移动指针,不清空内存,会有安全问题吗?- 多线程环境下多个线程同时调用
LOG_INFO,LogStream是否需要加锁?(提示:Logger 是栈上临时变量) AsyncFileLogger中lostCounter_是用来做什么的?什么情况下日志会丢失?
十、思考题参考答案
1. 为什么 TRANTOR_IF_ 用 for 而不是 if?
核心原因:避免悬空 else(dangling else)问题。
假设用 if 实现:
| |
展开后变成:
| |
C++ 的 else 匹配最近的未配对 if,所以 else 会错误地绑定到 TRANTOR_IF_ 展开的内层 if 上,语义完全改变。
而 for 循环是一个完整的语句,不会和外部 else 产生歧义:
| |
for 循环通过 _r 变量保证只执行一次,语义等价于 if,但没有悬空 else 的隐患。
2. FixedBuffer::reset() 只移动指针,不清空内存,会有安全问题吗?
不会有安全问题,原因如下:
FixedBuffer是LogStream的内部实现,只通过append()写入、通过data()+length()读出。读取范围严格限定在[data_, cur_)之间,reset()把cur_拨回data_后,length()返回 0,外部无法读到旧数据。FixedBuffer分配在栈上(作为Logger临时对象的成员),Logger析构后栈帧回收,旧数据自然不可达。- 日志内容本身不属于敏感数据(密码、密钥不应出现在日志中),即使栈上残留旧日志文本,也没有安全风险。
- 不做
memset是有意为之的性能优化——日志是高频操作,清零 4000 字节的开销完全没有必要。
如果是处理密码、密钥等安全敏感场景的缓冲区,则必须在释放前 memset 或使用 SecureZeroMemory 清零,防止通过内存残留泄露。但日志缓冲区不属于此类场景。
3. 多线程环境下多个线程同时调用 LOG_INFO,LogStream 是否需要加锁?
不需要加锁。关键在于 Logger 是一个栈上的临时对象。
| |
每次 LOG_INFO 展开时,Logger(...) 都是在当前线程栈帧上构造的临时对象,其内部的 LogStream(包含 FixedBuffer)也在该线程的栈上。
- 线程 A 的
Logger对象和线程 B 的Logger对象是完全独立的实例,各自有独立的FixedBuffer <<操作写入的是各自栈上的缓冲区,没有共享数据,天然线程安全- 不加锁意味着日志写入路径是无锁的(lock-free),这对高并发游戏服务器至关重要
需要注意的是:Logger 析构时调用 outputFunc_() 将缓冲区内容输出到目标(stdout 或 AsyncFileLogger)。如果输出目标是 AsyncFileLogger,其 output() 方法内部通过 mutex_ 保护 logBufferPtr_——这个锁是由 AsyncFileLogger 负责的,不是 LogStream 的职责。整体设计遵循单一职责:LogStream 负责无锁攒数据,AsyncFileLogger 负责线程安全地收集和落盘。
4. AsyncFileLogger 中 lostCounter_ 是用来做什么的?什么情况下日志会丢失?
lostCounter_ 是日志丢失计数器,用于记录因缓冲区积压过多而被丢弃的日志条数。
源码逻辑(AsyncFileLogger.cc 第 89-105 行):
| |
日志丢失的场景:
当磁盘 I/O 速度远低于日志产生速度时,后台线程来不及将缓冲区写入磁盘,writeBuffers_ 队列不断堆积。当队列中积压超过 25 个缓冲(每个约 4 MB,总计约 100 MB)时,新日志被直接丢弃,lostCounter_ 递增。
典型触发场景:
- 磁盘满了或 I/O 性能急剧下降(如 NFS 挂载的网络磁盘抖动)
- 瞬时产生大量日志(如循环中误加了
LOG_INFO) - 后台线程被阻塞或调度延迟
当缓冲区恢复正常(低于 25 个)后,下一次 output() 调用会先写入一条 "xxx log information is lost" 的提示,告知运维有日志丢失,然后将 lostCounter_ 清零。
这是一种有损降级(graceful degradation)策略——宁可丢日志,也不能让内存无限增长导致 OOM 崩溃,保护了进程的稳定性。
学习日期:2026-04-01 | 下一课:第02课_消息缓冲区MsgBuffer