logger_design_blog

从"单例 + 一个输出函数"到专业日志类:一个基于生产者-消费者模型的 C++/Qt 异步日志设计

本文基于 logger.h / logger.cpp 的实现,拆解一个支持日志等级多线程异步写入条件变量唤醒Sink 回调扩展的日志类设计,并对比它相对"只有一个单例 + 一个给其他线程调用的输出函数"的旧实现,到底强在哪。


一、背景:旧实现的问题

早期版本(你提到的"只有一个单例模式 + 一个输出日志函数")通常是这样的:

// 旧版(示意)
class Logger {
public:
    static Logger& instance();
    void log(const QString& msg); // 其他线程直接调它
private:
    Logger();
};

这个版本能用,但有几个硬伤:

  1. 没有日志等级。业务里 qDebug()qInfo()qWarning() 分不清轻重,线上想关掉调试日志做不到,所有日志一股脑全打印。
  2. 所有写入都在调用线程里同步完成。如果某次要把日志写到文件、甚至做网络上报,调用线程(比如 UI 线程、传感器采集线程)会被卡住,业务吞吐直接被日志拖垮。
  3. 输出方式写死。想再加一个"上报到远程服务器"的消费者?只能改类内部代码,没法在外部灵活挂载。
  4. 跨线程调用就是裸调。多个线程同时写,要自己加锁,而且一旦锁住,其他线程调 log() 就一起阻塞。

新版 Logger 把这些问题一次性解决了。下面逐条拆解。


二、整体架构:生产者-消费者模型

这个日志类的核心思想非常经典——线程安全的生产者-消费者队列

  业务线程 A ─┐
  业务线程 B ─┼─(emitLine: 拼好字符串 enqueue)──► [任务队列 m_queue] ──► 后台 writer 线程 (workerLoop)
  业务线程 C ─┘                                          ▲                      │
                                                          │                      │ wakeOne / wakeAll
                                                      条件变量                拷贝 sinks → 解锁 → 执行 sink()
                                                      m_queueNotEmpty           → 写文件
  • 生产者:所有调用 trace/debug/info/... 的业务线程。它们只做一件事——把格式化好的日志字符串塞进队列,立刻返回,不做任何耗时 I/O。
  • 消费者:唯一一个后台 QThreadm_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_levelstd::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 = falsewakeAll(),睡眠中的线程会被唤醒、看到 !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);
}

这样做的好处:

  1. 生产者永远不被 sink 拖慢。哪怕某个 sink 卡 1 秒,也只是后台线程自己慢 1 秒,业务线程 enqueue 早已返回。
  2. 快照一致性sinksCopy 是在锁内拿到的这一刻的版本,执行过程中即使别的线程 addSink(),本次循环用的也是稳定快照,不会出现"遍历到一半列表被改"的迭代器失效问题。
  3. 文件写入同样只在后台线程碰。注意 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

六、可进一步打磨的点(进阶思考)

代码已经很完整,以下是可选的优化方向,不是缺陷:

  1. 让"写文件"也成为一个 Sink。目前文件是单独的 writeToFile 分支,如果想统一模型,可以把文件输出也注册成一个 addSink(内部持有自己的文件流),这样 Logger 对所有输出方式一视同仁。
  2. 批量出队(batch dequeue)workerLoop 目前每次循环只取一行。队列积压时,一次锁内多取几行再统一在锁外处理,能进一步降低锁竞争。
  3. 日志轮转(rotation)。当前文件无限追加,长期运行会变大;可加按大小/日期切分的 RotatingFileSink
  4. Sink 异常隔离。某个 sink 抛异常不应影响其他 sink。可在 for 循环里对每个 sink(line) 包一层异常捕获。
  5. 无锁队列可选m_queueQQueue + 互斥锁足够;高并发极苛刻场景可换无锁环形队列(lock-free ring buffer)。
  6. 本地时间 vs UTC。现在用 UTC,若团队习惯本地时间可加开关。
  7. 头文件里 #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 的异步模式)的核心思路。你这套实现虽然代码量不大,但该有的工程要点都踩对了。

posted on 2026-08-16 13:59  dd_l  阅读(2)  评论(0)    收藏  举报