跳到主要内容

fmtlog 介绍

fmtlog 是一个基于 fmt 库格式的高性能异步日志库。

特性

  • 速度更快 - 运行时延迟低于 NanoLog,吞吐量高于 spdlog
  • 只含头文件或可编译的库文件。
  • 基于优秀的 fmt 库的丰富特性格式化。
  • 异步多线程日志记录 按时间顺序,也可以在单线程中同步使用。
  • 自定义格式化。
  • 自定义处理 - 用户可以设置回调函数来处理日志消息,除了写入文件之外。
  • 日志过滤 - 可以在运行时和编译时修改日志级别。
  • 日志频率限制 - 可以为特定日志设置最小日志间隔。

支持的平台

  • Linux(已在 GCC 10.2 上测试)
  • Windows(已在 MSVC 2019 上测试)

安装

需要 C++17,fmtlog 依赖于 fmtlib,如果您尚未安装 fmtlib,则需要先行安装。

仅头文件版本

只需将 fmtlog.hfmtlog-inl.h 复制到您的项目中,并:

  • 在包含 fmtlog.h 之前定义宏 FMTLOG_HEADER_ONLY
  • 或者在您的一个源文件中包含 fmtlog-inl.h

使用 CMake 构建的静态/共享库版本

$ git clone https://github.com/MengRao/fmtlog.git
$ cd fmtlog
$ git submodule init
$ git submodule update
$ ./build.sh

然后将生成的 .build 目录中的 fmtlog.hlibfmtlog-static.a/libfmtlog-shared.so 复制出来。

使用方法

#include "fmtlog/fmtlog.h"
int main()
{
FMTLOG(fmtlog::INF, "The answer is {}.", 42);
}

还定义了快捷宏 logd, logi, logwloge 分别用于记录 DBG, INF, WRNERR 消息:

logi("A info msg");
logd("This msg will not be logged as the default log level is INF");
fmtlog::setLogLevel(fmtlog::DBG);
logd("Now debug msg is shown");

注意,fmtlog 本质上是异步的,日志消息并不会在日志语句之后立即写入文件/控制台:它们仅仅被推送到队列中。您需要调用 fmtlog::poll() 来收集日志队列中的数据,格式化并输出它们:

fmtlog::setThreadName("aaa");
logi("Thread name is bbb in this msg");
fmtlog::setThreadName("bbb");
fmtlog::poll();
fmtlog::setThreadName("ccc");
logi("Thread name is ccc in this msg");
fmtlog::poll();
fmtlog::setThreadName("ddd");

fmtlog 支持多线程日志记录,但只能有一个线程调用 fmtlog::poll()。默认情况下,fmtlog 不会内部创建一个轮询线程,它要求用户定期轮询它。这样做的想法是,这允许用户以自己的方式管理线程,并完全控制轮询/刷新行为。然而,您可以通过 fmtlog::startPollingThread(interval) 让 fmtlog 为您创建一个后台轮询线程,并设置轮询间隔,但当该线程运行时您就不能自己调用 fmtlog::poll()

格式化

  • fmtlog 基于 fmtlib,支持几乎所有 fmtlib 特性(颜色除外):
#include "fmt/ranges.h"
using namespace fmt::literals;

logi("I'd rather be {1} than {0}.", "right", "happy");
logi("Hello, {name}! The answer is {number}. Goodbye, {name}.", "name"_a = "World", "number"_a = 42);

std::vector<int> v = {1, 2, 3};
logi("ranges: {}", v);

logi("std::move can be used for objects with non-trivial destructors: {}", std::move(v));
assert(v.size() == 0);

std::tuple<int, char> t = {1, 'a'};
logi("tuples: {}", fmt::join(t, ", "));

enum class color {red, green, blue};
template <> struct fmt::formatter<color>: formatter<string_view> {
// parse is inherited from formatter<string_view>.
template <typename FormatContext>
auto format(color c, FormatContext& ctx) {
string_view name = "unknown";
switch (c) {
case color::red: name = "red"; break;
case color::green: name = "green"; break;
case color::blue: name = "blue"; break;
}
return formatter<string_view>::format(name, ctx);
}
};
logi("user defined type: {:>10}", color::blue);
logi("{:*^30}", "centered");
logi("int: {0:d}; hex: {0:#x}; oct: {0:#o}; bin: {0:#b}", 42);
logi("dynamic precision: {:.{}f}", 3.14, 1);

// This gives a compile-time error because d is an invalid format specifier for a string.
// FMT_STRING() is not needed from C++20 onward
logi(FMT_STRING("{:d}"), "I am not a number");
  • 作为一个异步日志库,fmtlog 提供了额外的支持,用于通过指针传递参数(对于 fmtlib 来说这种需求很少,它只支持 void 和 char 指针)。用户可以传递任何类型的指针作为参数以避免复制开销,如果确保了被引用对象的生命周期(否则轮询线程会引用到悬垂指针!)。例如,对于字符串参数,fmtlog 默认会为类型 std::string 复制字符串内容,但对类型 std::string* 只复制指针:
std::string str = "aaa";
logi("str: {}, pstr: {}", str, &str);
str = "bbb";
fmtlog::poll();
// output: str: aaa, pstr: bbb
  • 除了原始指针,fmtlog 也支持 std::shared_ptrstd::unique_ptr,这使得对象生命周期管理更加容易:
int a = 4;
auto sptr = std::make_shared<int>(5);
auto uptr = std::make_unique<int>(6);
logi("void ptr: {}, ptr: {}, sptr: {}, uptr: {}", (void*)&a, &a, sptr, std::move(uptr));
a = 7;
*sptr = 8;
fmtlog::poll();
// output: void ptr: 0x7ffd08ac53ac, ptr: 7, sptr: 8, uptr: 6
  • 日志标题模式也可以通过 fmtlog::setHeaderPattern() 来自定义,参数是带有命名参数的 fmtlib 格式字符串。默认的标题模式是 {HMSf} {s:<16} {l}[{t:<6}] (示例:15:46:19.149844 log_test.cc:43 INF[448050])。标题中所有支持的命名参数如下表所示:
名称含义示例
l日志级别INF
s文件基名和行号log_test.cc:48
g文件路径和行号/home/raomeng/fmtlog/log_test.cc:48
t默认为线程id,可通过 fmt::setThreadName() 重置main
a星期几Mon
b月份名May
Y年份2021
C年份简写21
m月份05
d03
H小时16
M分钟08
S09
e毫秒796
f微秒796341
F纳秒796341126
Ymd年-月-日2021-05-03
HMS时:分:秒16:08:09
HMSe时:分:秒.毫秒16:08:09.796
HMSf时:分:秒.微秒16:08:09.796341
HMSF时:分:秒.纳秒16:08:09.796341126
YmdHMS年-月-日 时:分:秒2021-05-03 16:08:09
YmdHMSe年-月-日 时:分:秒.毫秒2021-05-03 16:08:09.796
YmdHMSf年-月-日 时:分:秒.微秒2021-05-03 16:08:09.796341
YmdHMSF年-月-日 时:分:秒.纳秒2021-05-03 16:08:09.796341126

注意,使用连接的命名参数比分开的更有效率,例如 {YmdHMS}{Y}-{m}-{d} {H}:{M}:{S} 更快。

输出

默认情况下,fmtlog 的输出是标准输出(stdout)。通常,用户希望将日志写入文件,可以通过 fmtlog::setLogFile(filename, truncate) 实现。为了性能考虑,fmtlog 在内部会缓冲数据,在满足特定条件时将缓冲区数据刷新到底层文件。刷新条件包括:

  • 如果底层的 FILE* 不是由 fmtlog 管理的,那么 fmtlog 将不会进行缓冲。例如,默认的标准输出 FILE* 就不会被缓冲。用户也可以传递一个现有的 FILE* 并指定是否由 fmtlog 管理,例如 fmtlog::setLogFile(stderr, false),那么 fmtlog 将会直接写入标准错误(stderr)而不缓冲。
  • 缓冲区大小超过 8 KB,这个数字可以通过 fmtlog::setFlushBufSize(bytes) 重置。
  • 缓冲区中最早的数据已超过指定的持续时间。默认时间是 3 秒,可以通过 fmtlog::setFlushDelay(ns) 设置。
  • 新日志至少达到指定的刷新日志级别。默认的刷新日志级别是任何日志都达不到的,但可以通过 fmtlog::flushOn(logLevel) 设置。
  • 用户可以主动通过 fmtlog::poll(true) 要求 fmtlog 刷新。

另外,用户可以选择通过 fmtlog::closeLogFile() 要求 fmtlog 关闭日志文件,此后的日志消息将不会被输出。

除了写入 FILE*,用户还可以通过 fmtlog::setLogCB(cb, minCBLogLevel) 注册回调函数来处理日志消息。这在需要实时发布警告/错误消息以便警报的情况下非常有用。日志回调不会像日志文件那样被缓冲,并且即使在文件被关闭后也可以触发。 回调函数的签名是:

// callback signature user can register
// ns: nanosecond timestamp
// level: logLevel
// location: full file path with line num, e.g: /home/raomeng/fmtlog/fmtlog.h:45
// basePos: file base index in the location
// threadName: thread id or the name user set with setThreadName
// msg: full log msg with header
// bodyPos: log body index in the msg
// logFilePos: log file position of this msg
typedef void (*LogCBFn)(int64_t ns, LogLevel level
, fmt::string_view location
, size_t basePos
, fmt::string_view threadName
, fmt::string_view msg
, size_t bodyPos
, size_t logFilePos);

性能

  • 基准测试既考虑了前端延迟也考虑了吞吐量,并与 Nanolog 和 spdlog basic_logger_st 进行了比较。测试日志消息使用 NanoLog 基准测试日志消息映射,标题模式使用 spdlog 默认模式(例如:"[2021-05-04 10:36:38.098] [spdlog] [info] [bench.cc:111] "),详见 bench.cc 获取详情。
  • 在一台配备 "Intel(R) Xeon(R) Gold 6144 CPU @ 3.50GHz" 的 Linux 服务器上的结果是:
MessagefmtlogNanologspdlog
staticString6.4 ns, 7.08 M/s6.5 ns, 33.10 M/s156.4 ns, 6.37 M/s
stringConcat6.4 ns, 6.05 M/s7.5 ns, 14.20 M/s209.4 ns, 4.77 M/s
singleInteger6.3 ns, 6.22 M/s6.5 ns, 50.29 M/s202.3 ns, 4.94 M/s
twoIntegers6.4 ns, 4.87 M/s6.6 ns, 39.25 M/s257.2 ns, 3.89 M/s
singleDouble6.2 ns, 5.37 M/s6.5 ns, 39.62 M/s225.0 ns, 4.44 M/s
complexFormat6.4 ns, 2.95 M/s6.7 ns, 24.30 M/s390.9 ns, 2.56 M/s
  • 注意,Nanolog 的吞吐量在此不可比,因为它输出到二进制日志文件而不是人类可读的文本格式,例如,它保存一个 int64 时间戳而不是长格式化的日期时间字符串。
  • fmtlog 如何实现如此低且稳定的延迟?灵感来自 Nanolog,采用了两个关键的优化技术:
  • 一是为每个记录日志的线程分配一个单生产者单消费者队列,让后台线程轮询所有这些队列。这避免了线程竞争,性能在线程数量增加时不会恶化。线程队列在该线程第一次记录日志时自动创建,因此不使用 fmtlog 的线程不会创建队列。线程队列默认大小为 1 MB(可以通过宏 FMTLOG_QUEUE_SIZE 改变),为队列分配内存需要一点时间。推荐用户在创建线程后主动调用 fmt::preallocate() 一次,这样即使是第一条日志也可以保持低延迟。
  • 当队列满了会发生什么?默认情况下,fmtlog 会丢弃额外的日志消息并返回。或者,可以通过定义宏 FMTLOG_BLOCK=1 来阻塞前端日志,以便在队列满时停止记录,这样就不会丢失任何日志。用户可以通过 fmtlog::setLogQFullCB(cb, userData) 注册一个回调函数,当日志队列满时触发,从而得知消费者(轮询线程)是否跟不上。通常,队列满并不是一个问题,但是不小心的用户可能会留下在意外高频率下调用的日志语句,例如,一个 TCP 客户端在没有连接重试延迟的情况下不断报告“连接拒绝”错误。为了优雅地处理这个问题,fmtlog 提供了一个限制日志频率的宏 FMTLOG_LIMIT 和四个快捷方式 logdllogillogwllogel,用户需要传递最小间隔时间(以纳秒为单位)作为第一个参数。
logil(1e9, "this log will be displayed at most once per second").
  • 另一个优化是,日志的静态信息(如格式字符串、日志级别和位置)在第一次调用时保存在一个表中,fmtlog 仅将带有动态参数的静态信息表条目的索引推送到队列中,从而最小化消息大小。此外,fmtlog 为每条日志语句定义了一个解码函数,当日志消息从队列中弹出时在 fmtlog::poll() 中调用。
  • 然而,这些解码函数会增加程序的大小,每个函数大约消耗 50 字节。另外,每条日志语句的静态信息条目也会在运行时内存中消耗大约 50 字节。对于那些不频繁且对延迟不敏感的日志(例如,程序初始化信息),这种内存开销可能不值得,因此 fmtlog 为用户提供了另一个禁用此优化的日志宏:FMTLOG_ONCE,当然还有快捷方式:logdologiologwologeoFMTLOG_ONCE 不会创建静态信息表条目,也不会添加解码函数:它将静态信息连同格式化的消息正文一起推送到队列中。注意,FMTLOG_ONCE 不支持通过指针传递参数。
  • 对于那些更喜欢通过在编译时过滤日志来进一步优化内存使用的用户,可以应用宏 FMTLOG_ACTIVE_LEVEL,其默认值为 FMTLOG_LEVEL_INF,这意味着调试日志会在编译时被简单地丢弃。注意,FMTLOG_ACTIVE_LEVEL 仅适用于日志快捷宏,例如 logi,但不适用于 FMTLOG。类似地,可以通过定义宏 FMTLOG_NO_CHECK_LEVEL 来禁用运行时日志级别过滤,这会稍微提高性能并减少一点生成的代码大小。