前言
今天继续推进仿 muduo 的 C++ 高并发服务器项目。
在正式把 EventLoop、Channel、Poller、TcpConnection 等核心模块串起来之前,我先补了一个很基础但很有用的工具:日志模块。
日志模块本身并不直接处理网络 IO,也不负责管理连接,但是它会贯穿整个服务器项目。后面排查事件循环是否正常运行、某个 fd 是否触发读写事件、连接什么时候建立和关闭、错误路径有没有走到,都需要靠日志来观察。
所以今天实现了一个轻量级的日志宏,目标不是做一个复杂的工业级日志库,而是先满足当前项目调试需要:
- 支持日志等级过滤
- 输出当前时间
- 输出当前线程 ID
- 输出源码文件名和行号
- 支持像
printf一样传入格式化参数 - 使用宏封装,调用起来足够简单
一、为什么高并发服务器需要日志
写普通小 demo 的时候,很多问题可以直接打断点调试。但服务器项目不太一样。
一个高并发服务器运行起来以后,内部会同时处理很多事情:
- 监听 socket 是否有新连接到来
- 处理客户端连接上的读事件和写事件
- 管理多个连接的生命周期
- 在事件循环中分发不同 fd 的事件
- 多线程场景下区分任务到底在哪个线程执行
- 出错时判断是系统调用失败、连接关闭,还是业务逻辑问题
这些行为往往不是线性发生的。如果只靠肉眼看代码,很难判断程序运行时到底走到了哪里。
这时候日志就非常重要。
比如后面写到 TcpServer 时,可以在新连接到来时打印一条日志;写到 Channel 时,可以在事件回调触发时打印一条日志;写到 EventLoop 时,可以打印当前 loop 所在线程。这样项目变复杂以后,也能通过日志把运行过程串起来。
二、日志等级设计
这次先定义了三个日志等级:
#define INF 0
#define DBG 1
#define ERR 2
#define LOG_LEVEL DBG
这里的等级数字越大,表示日志越重要:
INF:普通信息日志,用来记录程序正常运行状态DBG:调试日志,用来观察关键变量、事件触发、执行流程ERR:错误日志,用来记录失败路径或异常情况
LOG_LEVEL 表示当前允许输出的最低日志等级。
当前配置是:
#define LOG_LEVEL DBG
也就是说,只有等级大于等于 DBG 的日志才会输出。
所以在当前配置下:
INF_LOG不会输出DBG_LOG会输出ERR_LOG会输出
这个设计的好处是,调试阶段可以输出更多信息;如果后面觉得日志太多,只要调整 LOG_LEVEL,就可以统一控制输出范围。
三、核心 LOG 宏实现
完整的核心宏如下:
#define LOG(level, format, ...) do{\
if (level < LOG_LEVEL) break;\
time_t t = time(NULL);\
struct tm *ltm = localtime(&t);\
char tmp[32] = {0};\
strftime(tmp, 31, "%H:%M:%S", ltm);\
fprintf(stdout, "[%p %s %s:%d] " format "\n", (void*)pthread_self(), tmp, __FILE__, __LINE__, ##__VA_ARGS__);\
}while(0)
这个宏虽然不长,但是里面包含了几个很常用的 C/C++ 宏技巧。
四、日志等级过滤
第一句是日志过滤:
if (level < LOG_LEVEL) break;
这里利用了前面定义的等级数字。
如果当前日志等级小于全局的 LOG_LEVEL,说明这条日志不重要,直接 break 跳出宏内部的 do while。
比如现在:
#define LOG_LEVEL DBG
而 INF 的值是 0,DBG 的值是 1。
当执行:
INF_LOG("server start");
实际上传入的 level 是 INF,也就是 0。
判断条件变成:
0 < 1
条件成立,所以直接 break,这条普通信息日志不会输出。
如果执行:
DBG_LOG("fd = %d", fd);
传入的等级是 DBG,也就是 1。
判断条件变成:
1 < 1
条件不成立,所以会继续往下执行并打印日志。
五、输出当前时间
日志里需要带上时间,否则只能看到某条日志发生过,却不知道它是什么时候发生的。
这里通过 time、localtime 和 strftime 生成当前时间字符串:
time_t t = time(NULL);
struct tm *ltm = localtime(&t);
char tmp[32] = {0};
strftime(tmp, 31, "%H:%M:%S", ltm);
time(NULL) 获取当前时间戳。
localtime(&t) 把时间戳转换成本地时间。
strftime 负责把时间格式化成字符串:
小时:分钟:秒
例如最终输出可能是:
14:25:36
在服务器调试时,时间信息可以帮助判断某个事件是否按预期顺序发生,比如连接建立、数据到达、连接关闭之间的先后关系。
六、输出线程 ID
日志格式里还输出了当前线程 ID:
(void*)pthread_self()
对应的 fprintf 片段是:
[%p ...]
在单线程 Reactor 中,线程 ID 的作用可能暂时不明显。
但是 muduo 的一个重要特点就是 one loop per thread。后续如果实现线程池、多个 EventLoop,就需要知道某段代码到底运行在哪个线程里。
比如:
- 主线程负责监听新连接
- IO 线程负责处理已连接 socket 的读写事件
- 某个回调是否被错误地放到了其他线程执行
这些都可以通过日志里的线程 ID 辅助判断。
七、输出文件名和行号
日志里还加入了两个编译器内置宏:
__FILE__
__LINE__
它们分别表示当前日志所在的源码文件和行号。
最终日志格式中这一段:
__FILE__, __LINE__
可以帮助快速定位日志是从哪里打印出来的。
假设后面在 Channel、Poller、EventLoop 里都打印了类似的调试信息,如果没有文件名和行号,只看日志内容可能很难判断具体是哪一处触发的。
有了源码位置以后,日志就会更像这样:
[0x7f23a5d61740 14:25:36 EventLoop.hpp:85] loop start
看到这条日志,就能知道它来自哪个文件、哪一行。
八、可变参数宏
这个日志宏支持像 printf 一样传入格式化参数:
fprintf(stdout, "[%p %s %s:%d] " format "\n",
(void*)pthread_self(), tmp, __FILE__, __LINE__, ##__VA_ARGS__);
其中 format 是格式化字符串,... 和 __VA_ARGS__ 表示可变参数。
比如:
DBG_LOG("fd = %d", fd);
展开后就相当于:
fprintf(stdout, "... fd = %d\n", fd);
如果没有额外参数,也可以这样写:
ERR_LOG("accept error");
这里的 ##__VA_ARGS__ 是一个常见写法。
当可变参数为空时,它可以帮助去掉前面多余的逗号,避免宏展开后出现语法错误。
这样日志宏既可以打印普通字符串,也可以打印带变量的调试信息,使用起来比较灵活。
九、为什么使用 do while(0)
宏最外层使用了:
do {
...
} while(0)
这个写法在 C/C++ 宏封装中非常常见。
它的作用是把多条语句包成一个整体,让宏在各种语句环境下都能像普通函数调用一样使用。
比如如果直接把多条语句写进宏,后面在 if/else 中使用时可能会出现语义问题。
使用 do while(0) 后,就可以安全地这样写:
if (fd < 0)
ERR_LOG("fd error");
else
DBG_LOG("fd = %d", fd);
宏展开后仍然是一个完整语句,不容易破坏外层的 if/else 结构。
同时,前面日志等级过滤中的 break 也正好可以跳出这个 do while(0)。
十、三个便捷日志宏
为了调用更方便,最后又封装了三个宏:
#define INF_LOG(format, ...) LOG(INF, format, ##__VA_ARGS__)
#define DBG_LOG(format, ...) LOG(DBG, format, ##__VA_ARGS__)
#define ERR_LOG(format, ...) LOG(ERR, format, ##__VA_ARGS__)
这样平时使用时就不需要每次都手动传日志等级。
普通信息日志:
INF_LOG("server start");
调试日志:
DBG_LOG("fd = %d", fd);
错误日志:
ERR_LOG("accept error");
这样的调用方式更接近实际项目里的日志接口,可读性也更好。
十一、简单测试示例
假设写一段简单测试:
int fd = 10;
INF_LOG("server start");
DBG_LOG("fd = %d", fd);
ERR_LOG("accept error");
由于当前日志等级是:
#define LOG_LEVEL DBG
所以第一条 INF_LOG 会被过滤掉,不会输出。
后面两条日志会正常输出,形式大致如下:
[0x7f23a5d61740 14:25:36 test.cpp:12] fd = 10
[0x7f23a5d61740 14:25:36 test.cpp:13] accept error
这里每条日志中都能看到:
- 当前线程 ID
- 当前时间
- 日志所在文件和行号
- 用户传入的具体日志内容
这对于后续调试网络库非常有帮助。
十二、今日总结
今天实现的是一个轻量级日志宏模块。
它的代码量不大,但对后续项目非常关键。因为仿 muduo 这种高并发服务器项目越往后写,模块之间的调用关系越复杂,只靠断点和肉眼看代码会越来越吃力。
这次日志模块主要完成了:
- 日志等级过滤
- 当前时间输出
- 当前线程 ID 输出
- 文件名和行号输出
- 可变参数格式化输出
do while(0)宏安全封装INF_LOG、DBG_LOG、ERR_LOG三个便捷接口
后续实现 EventLoop、Channel、Poller、TcpConnection 时,就可以把这个日志宏穿插进去,用来观察事件分发、连接建立、连接关闭、错误处理等关键流程。
虽然它还不是一个完整的日志系统,比如还没有异步写日志、日志文件滚动、日志级别字符串、线程安全队列等功能,但对于当前阶段的项目调试已经够用了。
先把基础工具打好,后面写 Reactor 主体结构时会顺很多。