logger_design_blog
从"单例 + 一个输出函数"到专业日志类:一个基于生产者-消费者模型的 C++/Qt 异步日志设计
本文基于
logger.h/logger.cpp的实现,拆解一个支持日志等级、多线程异步写入、条件变量唤醒、Sink 回调扩展的日志类设计,并对比它相对"只有一个单例 + 一个给其他线程调用的输出函数"的旧实现,到底强在哪。
一、背景:旧实现的问题
早期版本(你提到的"只有一个单例模式 + 一个输出日志函数")通常是这样的:
// 旧版(示意)
class Logger {
public:
static Logger& instance();
void log(const QString& msg); // 其他线程直接调它
private:
Logger();
};
这个版本能用,但有几个硬伤:
- 没有日志等级。业务里
qDebug()、qInfo()、qWarning()分不清轻重,线上想关掉调试日志做不到,所有日志一股脑全打印。 - 所有写入都在调用线程里同步完成。如果某次要把日志写到文件、甚至做网络上报,调用线程(比如 UI 线程、传感器采集线程)会被卡住,业务吞吐直接被日志拖垮。
- 输出方式写死。想再加一个"上报到远程服务器"的消费者?只能改类内部代码,没法在外部灵活挂载。
- 跨线程调用就是裸调。多个线程同时写,要自己加锁,而且一旦锁住,其他线程调
log()就一起阻塞。
新版 Logger 把这些问题一次性解决了。下面逐条拆解。
二、整体架构:生产者-消费者模型
这个日志类的核心思想非常经典——线程安全的生产者-消费者队列:
业务线程 A ─┐
业务线程 B ─┼─(emitLine: 拼好字符串 enqueue)──► [任务队列 m_queue] ──► 后台 writer 线程 (workerLoop)
业务线程 C ─┘ ▲ │
│ │ wakeOne / wakeAll
条件变量 拷贝 sinks → 解锁 → 执行 sink()
m_queueNotEmpty → 写文件
- 生产者:所有调用
trace/debug/info/...的业务线程。它们只做一件事——把格式化好的日志字符串塞进队列,立刻返回,不做任何耗时 I/O。 - 消费者:唯一一个后台
QThread(m_writerThread),跑workerLoop()。它负责从队列里取日志、调用所有Sink、写文件。 - 队列 + 条件变量:
QQueue<QString> m_queue是被m_mutex保护的任务队列;QWaitCondition m_queueNotEmpty让消费者在队列空时睡眠、有数据时立刻被唤醒。
这样一个设计带来的最大好处:业务线程几乎零阻塞——它们永远只在"加锁 enqueue"这一步短暂持锁(基本是 O(1) 的内存拷贝),之后立刻走人。
三、核心设计拆解
1. 日志等级(Level)
enum class Level : int {
Trace=0, Debug, Info, Warn, Error, Fatal
};
std::atomic<Level> m_level{Level::Info};
- 等级从低到高,用
enum class强类型,避免被误当成整数乱用。 - 当前生效等级
m_level用std::atomic<Level>保护,因为setLevel()可能在任意线程调用(比如运行时切换等级),原子变量保证读写的线程安全,且读的时候不用加锁。 - 在
emitLine()里一行过滤掉低等级日志:
void Logger::emitLine(Level level, const QString &msg) {
if (level < m_level) // 等级不够,直接丢弃
return;
const auto line = format(level, msg);
...
}
注意:等级判断发生在加锁之前,而且是基于原子变量,所以这是无锁、低成本的前置过滤。线上把等级调到
Error,海量Debug/Info甚至都不会进队列,连内存拷贝都省了。
format() 把时间戳 + 等级字符串 + 消息拼成一行,时间戳用的是 UTC(QDateTime::currentDateTimeUtc()),跨时区排障时比本地时间更可靠:
QString Logger::format(Level level, const QString &msg) const {
const auto ts = QDateTime::currentDateTimeUtc().toString(Qt::ISODate);
return QStringLiteral("[%1] [%2] %3").arg(ts, levelToString(level), msg);
}
2. 任务队列:生产者只负责"投递"
emitLine() 是被业务线程调用的入口,它做完两件事就返回:
{
QMutexLocker locker(&m_mutex);
this->m_queue.enqueue(std::move(line)); // ① 加锁入队(极短)
}
this->m_queueNotEmpty.wakeOne(); // ② 唤醒后台线程
关键点:
- 持锁时间极短:只包住
enqueue这一行,做完立刻QMutexLocker析构解锁。 - 用
std::move(line)把格式化好的字符串所有权转移进队列,省一次深拷贝。 wakeOne()只唤醒一个等待中的消费者(本设计里也只有一个),比wakeAll()更精准、更高效。
对比旧实现:旧版里同样的 log() 可能直接在调用线程里做 QTextStream << ... 写文件,而新版里写文件被完全挪到了后台线程。
3. 条件变量:无忙等的高效睡眠
后台线程的 workerLoop() 用 QWaitCondition 实现"队列空就睡,有活就醒":
while (this->m_running) {
QList<Sink> sinksCopy;
bool fileOpen = false;
QString line;
{
QMutexLocker lock(&this->m_mutex);
while (this->m_queue.isEmpty() && m_running) { // 必须用 while,防虚假唤醒
this->m_queueNotEmpty.wait(&this->m_mutex);
}
if (!m_running) break;
line = this->m_queue.dequeue();
sinksCopy = this->m_sinks; // 关键:把 sinks 拷出来
fileOpen = m_file.isOpen();
}
// ... 解锁后执行(见下节)
}
几个细节值得强调:
while而非if检查队列空:条件变量的wait()可能被"虚假唤醒"(spurious wakeup),用while重新检查才是正确写法,你的代码写对了。wait()会原子地释放m_mutex:线程睡下去的同时把锁交出去,所以生产者此刻才能顺利enqueue。这是条件变量和裸sleep的本质区别。- 退出信号:析构函数把
m_running = false并wakeAll(),睡眠中的线程会被唤醒、看到!m_running后干净退出。
4. Sink 回调:用 std::function 解耦"日志产生"和"日志消费"
using Sink = std::function<void(const QString&)>;
QList<Sink> m_sinks;
void Logger::addSink(Sink sink) {
QMutexLocker locker(&m_mutex);
this->m_sinks.append(std::move(sink));
}
- 每个
Sink就是一个"收到一行日志字符串、爱怎么处理怎么处理"的回调。构造函数里默认挂了一个控制台 Sink:
this->addSink([](const QString& msg) { qInfo().noquote() << msg; });
- 这样设计后,输出方式的扩展完全对
Logger类封闭、对使用方开放。想加"写文件""上报 HTTP""发到消息队列",调用方自己addSink(...)即可,不用改类一行代码。旧实现里这是做不到的。 - 回调可以是 lambda、函数指针、甚至捕获了状态的 functor,非常灵活。
5. 拷贝 Sink + 解锁后再执行:避免锁住生产者(本设计最巧妙的一点)
这是你重点提到的、也是新手最容易写错的地方。先看错误写法(你代码里已注释掉的旧思路):
// ❌ 错误写法:在锁里直接跑 sink
// for (auto sink : this->m_sinks) {
// sink(line); // 如果某个 sink 很慢/会阻塞,锁一直不释放
// }
如果 sink 在持锁状态下执行,而某个 sink(比如控制台在调试器里、或者一个慢的网络 sink)很慢,锁就长时间不释放——此时任何生产者线程调用 emitLine() 都会卡在 QMutexLocker locker(&m_mutex) 上,异步日志反而把业务线程拖死了,违背初衷。
你的正确做法是:在锁内只做"拷贝 sinks + 出队",然后立刻解锁,在锁外执行 sink:
{
QMutexLocker lock(&this->m_mutex);
while (this->m_queue.isEmpty() && m_running) {
this->m_queueNotEmpty.wait(&this->m_mutex);
}
if (!m_running) break;
line = this->m_queue.dequeue();
sinksCopy = this->m_sinks; // ① 锁内:把当前 sinks 拷贝一份快照
fileOpen = m_file.isOpen();
} // ② 锁在这里释放
// ③ 锁外:执行 sink,慢也只拖累后台线程自己,不阻塞生产者
for (auto &sink : sinksCopy) {
sink(line);
}
这样做的好处:
- 生产者永远不被 sink 拖慢。哪怕某个 sink 卡 1 秒,也只是后台线程自己慢 1 秒,业务线程
enqueue早已返回。 - 快照一致性:
sinksCopy是在锁内拿到的这一刻的版本,执行过程中即使别的线程addSink(),本次循环用的也是稳定快照,不会出现"遍历到一半列表被改"的迭代器失效问题。 - 文件写入同样只在后台线程碰。注意
writeToFile()在锁内调用,但此时早已不在 sink 循环里,且QFile/QTextStream只在 worker 线程访问(Qt 对象本就不跨线程安全),规避了跨线程访问 Qt IO 对象的坑:
if (fileOpen) {
QMutexLocker lock(&this->m_mutex);
writeToFile(line); // 只碰文件,不碰 sink
}
一句话总结这节:把"保护共享状态(队列/sink 列表)"和"执行业务逻辑(输出日志)"这两件事在时间上彻底分开——前者短暂持锁,后者彻底无锁。这是整个异步日志线程安全的核心心法。
6. 后台线程与优雅退出(RAII 风格的析构)
构造函数里起线程,析构函数里负责收尾:
Logger::Logger() : m_stream(&this->m_file) {
m_stream.setEncoding(QStringConverter::Utf8);
this->addSink([](const QString& msg) { qInfo().noquote() << msg; });
this->m_running = true;
this->m_writerThread = QThread::create([this] { workerLoop(); });
this->m_writerThread->start();
}
Logger::~Logger() {
m_running = false;
this->m_queueNotEmpty.wakeAll(); // 叫醒可能睡着的线程
if (this->m_writerThread) {
this->m_writerThread->wait(); // 等它把剩余日志处理完再退出
delete this->m_writerThread;
}
disableFileSink();
}
QThread::create+ lambda 是 Qt 5.10+ 的写法,比继承QThread重写run()简洁。- 析构时先
m_running=false+wakeAll(),再wait(),确保队列里还没来得及处理的日志被全部消费完才退出——这正是workerLoop()末尾这段"退出前排空队列"的用武之地:
// workerLoop 在 while 退出后,把残留日志一次性排空
{
QMutexLocker lock(&m_mutex);
while (!this->m_queue.isEmpty()) {
auto line = this->m_queue.dequeue();
for (auto &sink : m_sinks) sink(line);
writeToFile(line);
}
}
- 这一步保证了程序退出时不会丢日志,对一个"记录现场"的工具来说非常关键。
7. 文件 Sink 与定时刷新
enableFileSink() 负责打开文件并创建目录,disableFileSink() 负责关闭:
void Logger::enableFileSink(const QString &path) {
QMutexLocker locker(&m_mutex);
if (m_file.isOpen()) m_file.close();
QDir().mkpath(QFileInfo(path).absolutePath()); // 自动建目录
m_file.setFileName(path);
m_file.open(QIODevice::Append | QIODevice::Text); // 追加模式
m_stream.setDevice(&m_file);
}
为保证崩溃前数据能落盘,workerLoop() 里有个 500ms 的定时 flush() 缓冲:
QElapsedTimer flushTimer;
flushTimer.start();
const qint64 FLUSH_INTERVAL = 500;
// ...每处理一行后:
if (flushTimer.elapsed() >= FLUSH_INTERVAL) {
QMutexLocker locker(&m_mutex);
m_stream.flush();
flushTimer.restart();
}
既避免每条日志都刷盘(慢),又不至于缓存太久丢了数据。
四、关键线程安全点小结
| 共享资源 | 保护方式 | 访问线程 |
|---|---|---|
m_queue(任务队列) |
m_mutex + QWaitCondition |
生产者 enqueue;消费者 dequeue |
m_sinks(Sink 列表) |
m_mutex(addSink 加锁,worker 锁内拷贝快照) |
任何线程 addSink;worker 锁外执行快照 |
m_level(当前等级) |
std::atomic |
任何线程 setLevel / emitLine 读取 |
m_running(运行标志) |
std::atomic |
析构设 false;worker 循环判断 |
m_file / m_stream |
仅 worker 线程访问(单线程天然安全) | 仅后台 writer 线程 |
设计上让所有耗时/IO 操作都收敛到唯一一个后台线程,调用方线程只在"入队"这一瞬短暂持锁,是这套代码线程安全且不拖慢业务的关键。
五、与旧实现对比
| 维度 | 旧实现(单例 + 单输出函数) | 新版 Logger |
|---|---|---|
| 日志等级 | 无,全量输出 | 6 级 + 运行时可切换,低级别直接丢弃 |
| 写入是否阻塞调用线程 | 同步写,会卡业务线程 | 异步入队,调用线程 O(1) 返回 |
| 输出方式 | 写死在类里 | std::function Sink,外部任意扩展 |
| 多线程安全 | 需自行加锁,且锁会拖慢其他调用方 | 生产者-消费者 + 锁内拷贝/锁外执行,生产者不阻塞 |
| 空闲时 CPU | 可能忙等或无谓轮询 | 条件变量睡眠,零空转 |
| 退出时日志 | 易丢失 | 析构排空队列,保证不丢 |
| 文件输出 | 通常直接同步写 | 后台线程写 + 定时 flush |
六、可进一步打磨的点(进阶思考)
代码已经很完整,以下是可选的优化方向,不是缺陷:
- 让"写文件"也成为一个 Sink。目前文件是单独的
writeToFile分支,如果想统一模型,可以把文件输出也注册成一个addSink(内部持有自己的文件流),这样Logger对所有输出方式一视同仁。 - 批量出队(batch dequeue)。
workerLoop目前每次循环只取一行。队列积压时,一次锁内多取几行再统一在锁外处理,能进一步降低锁竞争。 - 日志轮转(rotation)。当前文件无限追加,长期运行会变大;可加按大小/日期切分的
RotatingFileSink。 - Sink 异常隔离。某个 sink 抛异常不应影响其他 sink。可在
for循环里对每个sink(line)包一层异常捕获。 - 无锁队列可选。
m_queue用QQueue+ 互斥锁足够;高并发极苛刻场景可换无锁环形队列(lock-free ring buffer)。 - 本地时间 vs UTC。现在用 UTC,若团队习惯本地时间可加开关。
- 头文件里
#include <QTimer>未使用,可清理;代码注释里的旧实现残留也可删掉,保持整洁。
七、使用示例
#include "logger.h"
int main(int argc, char *argv[]) {
// 1. 打开文件输出
Logger::instance().enableFileSink("logs/app.log");
// 2. 自定义一个 sink:把 Error 级以上再额外上报
Logger::instance().addSink([](const QString& line) {
if (line.contains("[ERROR]") || line.contains("[FATAL]"))
qWarning() << "需要告警:" << line;
});
// 3. 业务里随便打,全是非阻塞的
Logger::instance().debug("系统启动");
Logger::instance().info("用户登录: 段磊");
Logger::instance().warn("缓存命中率偏低");
Logger::instance().error("数据库连接失败");
// 4. 运行时调等级(任意线程安全)
Logger::instance().setLevel(Level::Warn); // 之后 Debug/Info 不再进队列
return 0;
} // 析构自动等待后台线程把剩余日志写完、关文件
八、小结
这个 Logger 把"日志等级过滤 → 生产者-消费者队列 → 条件变量无忙等 → Sink 回调解耦输出 → 锁内拷贝快照 / 锁外执行避免阻塞生产者 → 析构优雅排空"串成了一条清晰的主线。它相比旧的单例 + 单函数实现,不只是"多了几个功能",而是在架构上把"日志产生"和"日志消费"彻底解耦,让业务线程几乎感觉不到日志的存在——这正是工业级日志库(spdlog、glog 的异步模式)的核心思路。你这套实现虽然代码量不大,但该有的工程要点都踩对了。
浙公网安备 33010602011771号