从零手写 C++ 日志库 ------ 一个 spdlog 风格 logger 的完整实现
两周前开始动手,580 行代码,175 万 msg/s。这篇文章记录每一层的设计决策和踩过的坑。
为什么要自己写
spdlog 是 C++ 日志库的事实标准,header-only、性能炸裂、功能齐全。但每次我翻它的源码都想放弃------模板嵌套、工厂模式、fmt 集成,太多高级特性堆在一起,根本看不清架构脉络。
有一天我突然想:与其硬看源码,不如自己从零写一个。目标不是替代 spdlog,是理解它为什么长成那个样子。
从零写的好处是你一个设计点都逃不掉。用 spdlog 的时候 logger->info("hello") 一行就结束了。但你自己实现这一行,背后每个决策都得想清楚------消息怎么存、格式串怎么编、sink 要不要锁、异步队列怎么设计。等你全部亲手做过一遍,再看 spdlog 源码就会发现没什么神秘的了------设计模式你都摸过,剩下的只是工程细节。
这篇文章按我代码的分层结构走,每一层解释"它要解决什么问题、为什么这样设计、最关键的代码是什么"。全部代码都在 GitHub 上,MIT 协议:
my_logger :
https://github.com/ch0sen1pm/my_loggermy_muduo :https://github.com/ch0sen1pm/my_muduo(配套 Reactor 网络库)
项目结构
第一版写了两天------一个 580 行的单头文件,所有类堆在一起。能跑,但回看很累。上周拆成了模块:
makefile
my_logger/
├── logger.h --- 聚合头文件,用户只 include 这一个
└── core/
├── common.h --- 日志级别、消息结构体、跨平台适配
├── blocking_queue.h --- 线程安全阻塞队列
├── formatter.h --- 策略模式:%flag → formatter 链表
├── sink.h --- 多态输出:控制台/文件/滚动文件
├── logger.h --- 日志记录器
├── async_logger.h --- 异步日志(后台线程写 sink)
├── registry.h --- 全局单例注册表
└── macros.h --- LOG_INFO / LOG_ERROR 等宏
跟 muduo 的分层思路一样------每层只做一件事,替换任何一层都不影响其他层。
第一层:common.h --- 消息结构
一条日志长什么样?就是这几个字段:
cpp
enum class level {
trace = 0, debug = 1, info = 2, warn = 3, err = 4, crit = 5, off = 6
};
struct log_msg {
std::string logger_name; // 哪个 logger 产生的
level lvl; // 日志级别
std::string payload; // 用户传进来的消息正文
std::chrono::system_clock::time_point time; // 产生时刻
size_t thread_id; // 哪个线程产生的
};
就是一个结构体------把一次日志调用的所有信息打包,下一步交给 formatter 处理。thread_id 是用 std::hash<std::thread::id> 算出来的简短数字,方便输出。
这里有个坑:localtime 是 C 标准库函数,Windows 和 Linux 的线程安全版本不一样。Windows 用 localtime_s,Linux 用 localtime_r。我写了一个小包装:
cpp
inline void localtime_platform(const std::time_t* t, std::tm* tm) {
#ifdef _WIN32
localtime_s(tm, t);
#else
localtime_r(t, tm);
#endif
}
第二层:formatter.h --- 格式串怎么编译
这是整个库最有意思的设计。用户传一个模式串:
perl
"[%Y-%m-%d %H:%M:%S.%e] [%n] [%l] %v"
最终输出:
css
[2026-07-13 23:15:30.123] [myapp] [info] hello world
问题来了:这段模式串怎么处理?最简单的做法是 format() 里用 if/else 逐个字符解析------遇到 % 就看看后面是什么、拼一段再继续走。能跑,但性能差。每条日志都要重新分析模式串,O(n) 字符串扫描。
spdlog 的思路是把模式串编译成一个对象数组。编译只做一次(构造时),运行时直接遍历对象数组执行虚函数------零解析开销。
flag_formatter 继承体系
每个 %flag 对应一个子类:
cpp
class flag_formatter {
public:
virtual ~flag_formatter() = default;
virtual void format(const log_msg& msg, const std::tm& tm_time,
std::string& dest) = 0;
};
然后 %Y → year_flag,%m → month_flag,%d → day_flag,依此类推。每个子类就干一件小事:
cpp
class year_flag : public flag_formatter {
public:
void format(const log_msg&, const std::tm& tm, std::string& dest) override {
dest += std::to_string(tm.tm_year + 1900);
}
};
class message_flag : public flag_formatter {
public:
void format(const log_msg& msg, const std::tm&, std::string& dest) override {
dest += msg.payload;
}
};
编译 → 执行
cpp
class pattern_formatter {
std::vector<std::unique_ptr<flag_formatter>> formatters_;
void compile_pattern_(const std::string& pattern) {
std::string literal; // 攒着 %flag 之间的普通文本块
for (auto it = pattern.begin(); it != pattern.end(); ++it) {
if (*it == '%' && std::next(it) != pattern.end()) {
// 先把之前攒的文本块输出
if (!literal.empty()) {
formatters_.push_back(make_unique<literal_flag>(move(literal)));
literal.clear();
}
// 读取 flag 字符,创建对应的 formatter
char flag = *++it;
formatters_.push_back(create_flag_(flag));
} else {
literal += *it;
}
}
// 剩的文本块(如果有)
if (!literal.empty())
formatters_.push_back(make_unique<literal_flag>(move(literal)));
}
// 实际执行格式化就是简单遍历
void format(const log_msg& msg, std::string& dest) {
for (auto& f : formatters_) {
f->format(msg, cached_tm_, dest);
}
dest += '\n';
}
};
逻辑很直接:遍历模式串,% 后面跟的字符是一个 flag 指令,其他字符是普通的文字块。literal_flag 直接追加文字,name_flag 追加 logger 名称,level_flag 追加级别名称,等等。
时间缓存
还有一个重要的优化。一条消息可能发给多个 sink(比如同时输出到屏幕和文件),如果每个 sink 都调一次 localtime,浪费时间。实际上同一条消息的时间不可能变------秒级之内都一样。所以我缓冲了一个 cached_tm_,秒变了才重新调 localtime:
cpp
void format(const log_msg& msg, std::string& dest) {
auto secs = duration_cast<seconds>(msg.time.time_since_epoch());
if (secs != last_log_secs_) { // 秒变了才更新
time_t t = system_clock::to_time_t(msg.time);
localtime_platform(&t, &cached_tm_);
last_log_secs_ = secs;
}
for (auto& f : formatters_)
f->format(msg, cached_tm_, dest);
dest += '\n';
}
第三层:sink.h --- 多态输出 + 模板注入锁
日志的输出目标可以是控制台、文件、甚至网络。每种目标行为不同------控制台直接 cout,文件需要 ofstream,滚动文件还需要检测大小、重命名。但它们有统一的接口:
cpp
class sink {
public:
virtual ~sink() = default;
virtual void log(level lvl, const std::string& msg) = 0;
virtual void flush() = 0;
void set_level(level lvl) { level_ = lvl; }
level get_level() const { return level_; }
bool should_log(level msg_level) {
return static_cast<int>(msg_level) >= static_cast<int>(level_);
}
protected:
level level_{ level::trace };
};
sink 只定义接口,具体的输出行为留给子类。同时每个 sink 有自己的 level 阈值------比如 stdout_sink 设 info,file_sink 设 trace,就能做到"屏幕只看重要信息,文件记录全部"。
锁策略------模板注入
多线程下写文件需要加锁,但控制台输出版本如果是单线程就不需要锁------白费 CPU。怎么让一份代码同时支持两种锁策略?答案是模板参数:
cpp
// 空锁------编译后直接消失,零开销
struct null_mutex {
void lock() {}
void unlock() {}
};
// 模板基类:通过 Mutex 参数决定加不加锁
template <typename Mutex = null_mutex>
class base_sink : public sink {
public:
void log(level lvl, const std::string& msg) final {
std::lock_guard<Mutex> lock(mtx_); // 如果是 null_mutex,lock() 是空函数
sink_it_(lvl, msg); // → 编译器内联,直接消失
}
void flush() final {
std::lock_guard<Mutex> lock(mtx_);
flush_();
}
protected:
virtual void sink_it_(level lvl, const std::string& msg) = 0;
virtual void flush_() = 0;
Mutex mtx_;
};
子类继承的时候选模板参数:
cpp
// 控制台------默认单线程,template 用的是 null_mutex
class stdout_sink : public base_sink<> {
void sink_it_(level, const std::string& msg) override { std::cout << msg; }
void flush_() override { std::cout << std::flush; }
};
// 文件------多线程安全,template 用的是 std::mutex
class file_sink : public base_sink<std::mutex> {
std::ofstream file_;
public:
file_sink(const std::string& filename) { file_.open(filename); }
void sink_it_(level, const std::string& msg) override { file_ << msg; }
void flush_() override { file_ << std::flush; }
};
这就是"编译期多态"------template 不是运行时选的,是编译时就确定了。null_mutex 版本在生成的汇编里根本没有锁操作。
滚动文件 sink
生产环境最实用的 sink:文件大到一定尺寸(比如 1MB),它会关掉旧文件、给它改个名(app_20260713_231530.log),然后开一个新 app.log 继续写。核心是 std::rename 这个系统调用,原子操作,跨平台。
cpp
void rotate_() {
auto now = system_clock::now();
auto t = system_clock::to_time_t(now);
tm tm_buf;
localtime_platform(&t, &tm_buf);
char ts[32];
strftime(ts, sizeof(ts), "%Y%m%d-%H%M%S", &tm_buf);
auto old = base_name_ + ".log";
auto rotated = base_name_ + "_" + ts + ".log";
rename(old.c_str(), rotated.c_str());
}
第四层:logger.h --- 组合 formatter + sink
有了 formatter 和 sink,logger 的工作就是把两者串起来:
cpp
class logger {
std::string name_;
level level_{ level::info };
std::unique_ptr<pattern_formatter> formatter_;
std::vector<std::shared_ptr<sink>> sinks_; // 可以有多个 sink
public:
void log(level lvl, const std::string& payload) {
if (!should_log(lvl)) return; // ① 级别过滤
log_msg msg; // ② 打包消息
msg.logger_name = name_;
msg.lvl = lvl;
msg.payload = payload;
msg.time = system_clock::now();
msg.thread_id = hash<thread::id>{}(this_thread::get_id());
std::string formatted;
formatter_->format(msg, formatted); // ③ 格式化
for (auto& s : sinks_) { // ④ 分发给每个 sink
if (s->should_log(lvl))
s->log(lvl, formatted);
}
}
// 加一个 sink
void add_sink(std::shared_ptr<sink> s) { sinks_.push_back(move(s)); }
};
用户实际用的便捷接口:
cpp
void trace(const std::string& msg) { log(level::trace, msg); }
void debug(const std::string& msg) { log(level::debug, msg); }
void info(const std::string& msg) { log(level::info, msg); }
void warn(const std::string& msg) { log(level::warn, msg); }
void error(const std::string& msg) { log(level::err, msg); }
void crit(const std::string& msg) { log(level::crit, msg); }
第五层:async_logger.h --- 异步日志
同步 logger 有一个性能坑:写文件是 I/O 操作,可能慢,会阻塞业务线程。解决方案是把"写 sink"这个慢操作移到后台线程去,业务线程只负责格式化消息和丢队列。
blocking_queue --- 生产者消费者
cpp
template <typename T>
class blocking_queue {
std::queue<T> q_;
std::mutex mtx_;
std::condition_variable cv_;
bool stopped_ = false;
void enqueue(T item) {
{ lock_guard<mutex> lock(mtx_); q_.push(move(item)); }
cv_.notify_one(); // 唤醒一个等待的消费者
}
optional<T> dequeue() {
unique_lock<mutex> lock(mtx_);
cv_.wait(lock, [this] { return !q_.empty() || stopped_; });
if (stopped_ && q_.empty()) return nullopt; // 退出了
T item = move(q_.front());
q_.pop();
return item;
}
void stop() {
{ lock_guard<mutex> lock(mtx_); stopped_ = true; }
cv_.notify_all(); // 唤醒所有消费者
}
};
async_logger 包装
cpp
class async_logger {
shared_ptr<logger> backend_; // 同步 logger(做格式化)
blocking_queue<async_msg> q_; // 消息队列
vector<thread> workers_; // 后台线程池
void log(level lvl, const string& payload) {
// 第一步:格式化(前端线程做,快)
log_msg msg = ...;
string formatted;
backend_->format(msg, formatted);
// 第二步:丢队列(前端线程的活干完了)
async_msg amsg{lvl, move(formatted)};
q_.enqueue(move(amsg));
}
// 后台线程:出队 → 写 sink(慢,不在业务线程上)
void run_() {
while (true) {
auto msg = q_.dequeue();
if (!msg) break;
backend_->log_formatted(msg->lvl, msg->formatted);
}
}
~async_logger() {
q_.stop(); // 停止队列
for (auto& t : workers_) t.join(); // 等所有 worker 刷盘
}
};
析构时 q_.stop() → 所有 worker 被唤醒 → 把队列里的还没处理完的消息刷盘 → join() 等所有线程退出。优雅关闭,不会丢日志。
第六层:registry.h --- 全局管理
照着 spdlog 的 registry 写了一个单例注册表:
cpp
class registry {
public:
static registry& instance() {
static registry r; // C++11 保证线程安全
return r;
}
void register_logger(shared_ptr<logger> log) {
lock_guard<mutex> lock(mtx_);
loggers_[log->name()] = move(log);
}
shared_ptr<logger> get(const string& name) {
lock_guard<mutex> lock(mtx_);
auto it = loggers_.find(name);
return it != loggers_.end() ? it->second : nullptr;
}
};
macros.h --- 级别过滤宏
cpp
#define LOG_TRACE(logger, msg) if ((logger)->should_log(level::trace)) (logger)->trace(msg)
#define LOG_DEBUG(logger, msg) if ((logger)->should_log(level::debug)) (logger)->debug(msg)
#define LOG_INFO(logger, msg) if ((logger)->should_log(level::info)) (logger)->info(msg)
#define LOG_WARN(logger, msg) if ((logger)->should_log(level::warn)) (logger)->warn(msg)
#define LOG_ERROR(logger, msg) if ((logger)->should_log(level::err)) (logger)->error(msg)
#define LOG_CRIT(logger, msg) if ((logger)->should_log(level::crit)) (logger)->crit(msg)
if 在前面------如果级别没达标,连 payload 的字符串拼接都不执行。
Benchmark
cpp
// 同步单线程
auto log = make_shared<logger>("bench", make_shared<file_sink>("bench.log"));
log->set_level(level::info);
for (int i = 0; i < 100000; i++) LOG_INFO(log, "bench message");
bash
同步单线程 100K 条: 57ms → 1,754,385 msg/s
同步 4 线程 100K 条:201ms → 497,512 msg/s
异步单线程 100K 条:250ms → 400,000 msg/s
异步 4 线程 100K 条:1470ms → 68,027 msg/s
同步单线程 175 万/秒------基本是 std::string 拼接 + 内存拷贝的上限。异步 4 线程下的锁竞争是下一步优化点(ring buffer / 无锁队列)。
跟 muduo 合体
我把这个 logger 跟我另一个从零写的 Reactor 网络库串了起来,写了一个多人聊天室:
cpp
auto console = make_shared<stdout_sink>();
auto file = make_shared<file_sink>("chat.log");
auto log = make_shared<logger>("chat", console);
log->add_sink(file);
EventLoop loop;
TcpServer server(&loop, 8080);
server.setThreadNum(4);
vector<TcpConnection*> clients;
server.setConnectionCallback([&](TcpConnection* conn) {
clients.push_back(conn);
LOG_INFO(log, "新客户端连接,在线: " + to_string(clients.size()));
});
server.setMessageCallback([&](TcpConnection* conn, const char* data, size_t len) {
string msg(data, len);
LOG_INFO(log, "收到: " + msg);
for (auto* c : clients)
if (c != conn) c->send(msg + "\r\n"); // 广播
});
server.start();
loop.loop();
多个终端 telnet 到 8080,打字互发消息。chat.log 同步记录每一条:
csharp
[22:56:52] 聊天服务器启动,端口 8080...
[22:57:03] 新客户端连接,在线:1
[22:57:31] 收到:hello from client 1
[22:57:36] 收到:hello from client 2
两个自己造的轮子第一次协同工作,那种感觉很难描述。以前写出来的东西都是用别人的库------这是第一次自己的两个库在同一个进程里配合,"我写的库能给我的另一个库当基础设施"。
踩过的坑
localtime不是线程安全的。 C 标准库返回内部缓冲区指针,多线程同时调会互相覆盖。必须用localtime_r(Linux) 或localtime_s(Windows),或者自己加锁。- pattern 编译和格式化要分离。 最初在
format()里边解析%flag边拼字符串------每条日志都重新扫描模式串。改成"构造函数编译、运行时遍历对象数组"之后,格式化从 O(n) 字符串扫描变成了 O(n) 数组遍历------常数差距很大。 lock_guardvsunique_lock。lock_guard轻量但不可解锁,只用于简单的保护块。unique_lock可以手动 unlock + relock,配合condition_variable::wait必须用它。- 异步析构的顺序不能错。
async_logger的析构里必须先q_.stop()唤醒所有 worker,再join()。如果反过来------join 之前没 stop------worker 永远阻塞在dequeue()上,整个程序 hang 住。
计划
接下来还会更新:
- color_stdout_sink(按级别着色:error 红、warn 黄)
- daily_rolling_sink(按天切文件,不是按大小)
- flush_on_crash(SIGSEGV 时自动刷盘)
- rate limiter(相同日志 N 秒内只输出一次)
- benchmark vs spdlog(性能对比表格)
两个库都在 GitHub 上,欢迎 star 或提 issue:
my_logger :
https://github.com/ch0sen1pm/my_loggermy_muduo :https://github.com/ch0sen1pm/my_muduo