C++ 线程安全日志系统:策略模式解耦输出端,RAII 实现 glog 风格流式日志

前言

在 Linux 后端开发和多线程服务端编程中,日志系统几乎是每个项目都会遇到的基础设施。

很多初学者一开始会直接使用 std::cout 打印调试信息。这在单线程、小程序里问题不大,但一旦进入多线程服务场景,就会很快暴露出几个问题:

  • 多个线程同时输出,日志内容可能交错在一起;
  • 输出位置被写死,后续想从控制台切换到文件很麻烦;
  • 没有日志等级,调试信息、普通信息、错误信息混在一起;
  • 缺少时间、文件名、行号等定位信息,排查问题效率很低;
  • 每次手动拼接格式,代码重复且容易写乱。

成熟项目里通常会使用 glogspdlogBoost.Log 这类日志库。但如果我们从零手搓一个简化版线程安全日志系统,就能把 策略模式、RAII、线程互斥、可重入函数、流式接口设计 这些 C++ 后端开发里的核心知识点串起来。

本文会实现一个支持如下调用风格的日志系统:

复制代码
LOG(LogLevel::INFO) << "server start, port: " << 8080;

最终日志格式类似:

复制代码
[2026-04-16 21:33:18] [INFO] [1030871] [Main.cc] [10] - server start, port: 8080

一、日志系统应该解决什么问题

1.1 一条合格日志应该包含什么?

一条真正可用于排查问题的日志,不能只有一句业务文本。它至少应该包含这些字段:

字段 作用
时间戳 判断问题发生的具体时间
日志等级 区分调试、普通、警告、错误、致命问题
进程 ID / 线程 ID 在多进程、多线程环境中定位执行流
文件名和行号 直接定位是哪一行代码打印的日志
日志正文 用户真正想记录的业务内容

所以日志系统本质上做两件事:

  • 把零散的信息组装成统一格式;
  • 把组装好的日志可靠地写到目标位置。

1.2 为什么不能直接用 cout?

std::cout 本身不是一个完整日志系统,它只是一个输出工具。

在多线程场景下,多个线程可能同时执行输出操作:

复制代码
std::cout << "thread-1 message" << std::endl;
std::cout << "thread-2 message" << std::endl;

如果没有额外保护,最终看到的日志可能发生字符级别的交错。更重要的是,cout 无法天然解决日志等级、文件落盘、格式统一、策略切换这些工程问题。

因此我们需要把输出封装成一个独立模块。

二、整体架构:格式化与刷新解耦

日志系统可以拆成两个阶段:

  1. 日志形成阶段

    负责把时间戳、等级、PID、文件名、行号、用户内容拼成完整字符串。

  2. 日志刷新阶段

    负责把完整字符串写到某个地方,比如控制台、文件、数据库、网络服务。

这两个阶段最好解耦。日志内容怎么拼,和日志最终写到哪里,本来就不是同一个问题。

这也是为什么本文选择 策略模式

  • 控制台输出是一种策略;
  • 文件输出是一种策略;
  • 后续写数据库、发网络、写消息队列,也都可以作为新策略加入。

核心类 Logger 不需要关心具体输出细节,只需要持有一个策略对象即可。

三、前置模块:互斥锁、时间戳、日志等级

3.1 RAII 风格互斥锁封装

控制台和日志文件都是临界资源。多线程同时写入时,必须加锁保护。

这里基于 pthread_mutex_t 做一个简单封装:

复制代码
#ifndef MUTEX_HPP
#define MUTEX_HPP

#include <pthread.h>

class Mutex
{
public:
    Mutex()
    {
        pthread_mutex_init(&_lock, nullptr);
    }

    Mutex(const Mutex&) = delete;
    Mutex& operator=(const Mutex&) = delete;

    void Lock()
    {
        pthread_mutex_lock(&_lock);
    }

    void UnLock()
    {
        pthread_mutex_unlock(&_lock);
    }

    pthread_mutex_t* Origin()
    {
        return &_lock;
    }

    ~Mutex()
    {
        pthread_mutex_destroy(&_lock);
    }

private:
    pthread_mutex_t _lock;
};

class LockGuard
{
public:
    explicit LockGuard(Mutex* lockptr)
        : _lockptr(lockptr)
    {
        _lockptr->Lock();
    }

    LockGuard(const LockGuard&) = delete;
    LockGuard& operator=(const LockGuard&) = delete;

    ~LockGuard()
    {
        _lockptr->UnLock();
    }

private:
    Mutex* _lockptr;
};

#endif

这里的重点不是把 pthread_mutex_t 包一层,而是 RAII

  • LockGuard 构造时自动加锁;
  • LockGuard 析构时自动解锁;
  • 即使中途发生异常或提前返回,也不会忘记释放锁。

这和标准库里的 std::lock_guard 思想一致。

3.2 线程安全的时间戳

日志必须带时间。常见写法是 time + localtime + snprintf

但要注意:普通 localtime 内部使用静态缓冲区,多线程环境下不安全。这里应该使用可重入版本 localtime_r

复制代码
std::string GetTimeStamp()
{
    time_t current_time = time(nullptr);

    struct tm data_time;
    localtime_r(&current_time, &data_time);

    char buffer[128];
    snprintf(buffer, sizeof(buffer),
             "%4d-%02d-%02d %02d:%02d:%02d",
             data_time.tm_year + 1900,
             data_time.tm_mon + 1,
             data_time.tm_mday,
             data_time.tm_hour,
             data_time.tm_min,
             data_time.tm_sec);

    return buffer;
}

几个容易出错的点:

  • tm_year 表示从 1900 年开始的偏移量,所以要加 1900
  • tm_mon 范围是 [0, 11],所以要加 1
  • %02d 可以保证月、日、时、分、秒不足两位时自动补零;
  • 多线程程序里优先使用 localtime_r,不要使用 localtime

3.3 类型安全的日志等级

日志等级用 enum class 表示,比普通枚举更安全:

复制代码
enum class LogLevel
{
    DEBUG,
    INFO,
    WARNING,
    ERROR,
    FATAL
};

std::string LogLevel2String(LogLevel level)
{
    switch (level)
    {
    case LogLevel::DEBUG:
        return "DEBUG";
    case LogLevel::INFO:
        return "INFO";
    case LogLevel::WARNING:
        return "WARNING";
    case LogLevel::ERROR:
        return "ERROR";
    case LogLevel::FATAL:
        return "FATAL";
    default:
        return "UNKNOWN";
    }
}

使用 enum class 的好处是:

  • 不会污染外层命名空间;
  • 不会随意隐式转换成整数;
  • 调用时必须写成 LogLevel::INFO,语义更清楚。

四、策略模式:控制台输出与文件输出

4.1 抽象策略基类

日志刷新策略只需要对外暴露一个统一接口:把完整日志字符串刷出去。

复制代码
class LogStrategy
{
public:
    virtual ~LogStrategy() = default;
    virtual void SyncLog(const std::string& message) = 0;
};

这里一定要有虚析构函数。因为后面会用基类指针指向子类对象,如果基类析构函数不是虚函数,通过基类指针释放派生类对象时就可能析构不完整。

4.2 控制台日志策略

控制台策略负责把日志打印到屏幕上。

复制代码
class ConsoleLogStrategy : public LogStrategy
{
public:
    void SyncLog(const std::string& message) override
    {
        LockGuard guard(&_mutex);
        std::cerr << message << std::endl;
    }

private:
    Mutex _mutex;
};

这里建议使用 std::cerr,原因是日志通常希望尽快输出,cerr 默认不缓冲,更适合错误和运行状态信息。

4.3 文件日志策略

文件策略负责把日志追加写入磁盘。

复制代码
class FileLogStrategy : public LogStrategy
{
public:
    FileLogStrategy(const std::string& logdir = "./log/",
                    const std::string& logfilename = "log.txt")
        : _logdir(logdir)
        , _logfilename(logfilename)
    {
        LockGuard guard(&_mutex);

        try
        {
            if (!std::filesystem::exists(_logdir))
            {
                std::filesystem::create_directories(_logdir);
            }
        }
        catch (const std::filesystem::filesystem_error& e)
        {
            std::cerr << e.what() << std::endl;
        }
    }

    void SyncLog(const std::string& message) override
    {
        LockGuard guard(&_mutex);

        std::string target = _logdir + _logfilename;
        std::ofstream out(target, std::ios::app);
        if (!out.is_open())
        {
            return;
        }

        out << message << '\n';
    }

private:
    std::string _logdir;
    std::string _logfilename;
    Mutex _mutex;
};

文件策略有几个关键点:

  • 使用 std::filesystem::create_directories 自动创建日志目录;

  • 使用 std::ios::app 追加写入,避免覆盖历史日志;

  • 文件写入前后加锁,保证多线程下单条日志不会被拆开;

  • ofstream 离开作用域会自动关闭文件,不需要手动管理文件句柄。

    classDiagram class LogStrategy { <<interface>> +SyncLog(message) } class ConsoleLogStrategy { -Mutex _mutex +SyncLog(message) } class FileLogStrategy { -string _logdir -string _logfilename -Mutex _mutex +SyncLog(message) } LogStrategy <|-- ConsoleLogStrategy LogStrategy <|-- FileLogStrategy

五、Logger 主体:RAII 自动刷新与流式拼接

5.1 Logger 的职责

Logger 是整个日志系统的入口,它主要做三件事:

  • 保存当前日志刷新策略;
  • 提供接口切换控制台 / 文件输出;
  • 生成单条日志对象 LogMessage

最巧妙的地方在 LogMessage

5.2 LogMessage 为什么要设计成临时对象?

我们想要这样的调用风格:

复制代码
LOG(LogLevel::INFO) << "user id: " << uid << ", login success";

这行代码看起来像 cout,但它什么时候真正输出呢?

答案是:整条语句结束时,临时对象析构,析构函数里自动刷新日志。

复制代码
```mermaid
sequenceDiagram
    participant User as 用户代码
    participant Macro as LOG 宏
    participant Msg as LogMessage 临时对象
    participant Strategy as 输出策略

    User->>Macro: LOG(INFO) << "hello"
    Macro->>Msg: 创建对象并写入前缀
    User->>Msg: operator<< 追加正文
    Msg->>Msg: 语句结束,临时对象析构
    Msg->>Strategy: SyncLog(_loginfo)
```

这就是 RAII 在日志系统里的一个经典用法:对象生命周期结束,就代表这一条日志已经拼接完成,可以刷新了。

5.3 Logger 核心代码

复制代码
class Logger
{
public:
    Logger()
    {
        UseConsoleLogStrategy();
    }

    void UseConsoleLogStrategy()
    {
        _strategy = std::make_unique<ConsoleLogStrategy>();
    }

    void UseFileLogStrategy()
    {
        _strategy = std::make_unique<FileLogStrategy>();
    }

    class LogMessage
    {
    public:
        LogMessage(LogLevel level,
                   const std::string& filename,
                   int line,
                   Logger& self)
            : _logger(self)
        {
            std::stringstream ss;
            ss << "[" << GetTimeStamp() << "] "
               << "[" << LogLevel2String(level) << "] "
               << "[" << getpid() << "] "
               << "[" << filename << "] "
               << "[" << line << "] "
               << "- ";

            _loginfo = ss.str();
        }

        ~LogMessage()
        {
            if (_logger._strategy)
            {
                _logger._strategy->SyncLog(_loginfo);
            }
        }

        template<class T>
        LogMessage& operator<<(const T& info)
        {
            std::stringstream ss;
            ss << info;
            _loginfo += ss.str();
            return *this;
        }

    private:
        std::string _loginfo;
        Logger& _logger;
    };

    LogMessage operator()(LogLevel level, const std::string& filename, int line)
    {
        return LogMessage(level, filename, line, *this);
    }

private:
    std::unique_ptr<LogStrategy> _strategy;
};

这里有三处关键设计:

  1. Logger 重载 operator(),让 logger(level, file, line) 像函数一样使用;
  2. LogMessage 重载 operator<<,支持链式拼接任意可输出类型;
  3. LogMessage 析构时调用当前策略的 SyncLog,实现自动刷新。

5.4 glog 风格宏封装

为了让使用方式更简洁,可以定义宏:

复制代码
Logger logger;

#define LOG(level) logger(level, __FILE__, __LINE__)
#define ENABLE_CONSOLE_LOG_STRATEGY() logger.UseConsoleLogStrategy()
#define ENABLE_FILE_LOG_STRATEGY() logger.UseFileLogStrategy()

这样用户就不需要手动传文件名和行号:

复制代码
LOG(LogLevel::ERROR) << "open file failed, path: " << path;

__FILE____LINE__ 会由编译器自动替换成当前源文件和代码行号。

六、完整代码实现

下面给出一个可以直接放入 Logger.hpp 的整合版本。

复制代码
#ifndef LOGGER_HPP
#define LOGGER_HPP

#include <ctime>
#include <filesystem>
#include <fstream>
#include <iostream>
#include <memory>
#include <sstream>
#include <string>
#include <unistd.h>
#include "Mutex.hpp"

namespace LogModule
{
std::string GetTimeStamp()
{
    time_t current_time = time(nullptr);

    struct tm data_time;
    localtime_r(&current_time, &data_time);

    char buffer[128];
    snprintf(buffer, sizeof(buffer),
             "%4d-%02d-%02d %02d:%02d:%02d",
             data_time.tm_year + 1900,
             data_time.tm_mon + 1,
             data_time.tm_mday,
             data_time.tm_hour,
             data_time.tm_min,
             data_time.tm_sec);

    return buffer;
}

enum class LogLevel
{
    DEBUG,
    INFO,
    WARNING,
    ERROR,
    FATAL
};

std::string LogLevel2String(LogLevel level)
{
    switch (level)
    {
    case LogLevel::DEBUG:
        return "DEBUG";
    case LogLevel::INFO:
        return "INFO";
    case LogLevel::WARNING:
        return "WARNING";
    case LogLevel::ERROR:
        return "ERROR";
    case LogLevel::FATAL:
        return "FATAL";
    default:
        return "UNKNOWN";
    }
}

class LogStrategy
{
public:
    virtual ~LogStrategy() = default;
    virtual void SyncLog(const std::string& message) = 0;
};

class ConsoleLogStrategy : public LogStrategy
{
public:
    void SyncLog(const std::string& message) override
    {
        LockGuard guard(&_mutex);
        std::cerr << message << std::endl;
    }

private:
    Mutex _mutex;
};

class FileLogStrategy : public LogStrategy
{
public:
    FileLogStrategy(const std::string& logdir = "./log/",
                    const std::string& logfilename = "log.txt")
        : _logdir(logdir)
        , _logfilename(logfilename)
    {
        LockGuard guard(&_mutex);

        try
        {
            if (!std::filesystem::exists(_logdir))
            {
                std::filesystem::create_directories(_logdir);
            }
        }
        catch (const std::filesystem::filesystem_error& e)
        {
            std::cerr << e.what() << std::endl;
        }
    }

    void SyncLog(const std::string& message) override
    {
        LockGuard guard(&_mutex);

        std::ofstream out(_logdir + _logfilename, std::ios::app);
        if (!out.is_open())
        {
            return;
        }

        out << message << '\n';
    }

private:
    std::string _logdir;
    std::string _logfilename;
    Mutex _mutex;
};

class Logger
{
public:
    Logger()
    {
        UseConsoleLogStrategy();
    }

    void UseConsoleLogStrategy()
    {
        _strategy = std::make_unique<ConsoleLogStrategy>();
    }

    void UseFileLogStrategy()
    {
        _strategy = std::make_unique<FileLogStrategy>();
    }

    class LogMessage
    {
    public:
        LogMessage(LogLevel level,
                   const std::string& filename,
                   int line,
                   Logger& self)
            : _logger(self)
        {
            std::stringstream ss;
            ss << "[" << GetTimeStamp() << "] "
               << "[" << LogLevel2String(level) << "] "
               << "[" << getpid() << "] "
               << "[" << filename << "] "
               << "[" << line << "] "
               << "- ";

            _loginfo = ss.str();
        }

        ~LogMessage()
        {
            if (_logger._strategy)
            {
                _logger._strategy->SyncLog(_loginfo);
            }
        }

        template<class T>
        LogMessage& operator<<(const T& info)
        {
            std::stringstream ss;
            ss << info;
            _loginfo += ss.str();
            return *this;
        }

    private:
        std::string _loginfo;
        Logger& _logger;
    };

    LogMessage operator()(LogLevel level, const std::string& filename, int line)
    {
        return LogMessage(level, filename, line, *this);
    }

private:
    std::unique_ptr<LogStrategy> _strategy;
};

Logger logger;

#define LOG(level) logger(level, __FILE__, __LINE__)
#define ENABLE_CONSOLE_LOG_STRATEGY() logger.UseConsoleLogStrategy()
#define ENABLE_FILE_LOG_STRATEGY() logger.UseFileLogStrategy()
}

#endif

七、多线程测试与关键问题总结

7.1 测试代码

复制代码
#include <iostream>
#include <pthread.h>
#include <unistd.h>
#include "Logger.hpp"

using namespace LogModule;

void* ThreadLogTest(void* arg)
{
    const char* thread_name = static_cast<const char*>(arg);

    for (int i = 0; i < 5; ++i)
    {
        LOG(LogLevel::INFO) << thread_name << " print log, count: " << i;
        usleep(1000);
    }

    return nullptr;
}

int main()
{
    ENABLE_CONSOLE_LOG_STRATEGY();

    LOG(LogLevel::DEBUG) << "debug message, value: " << 3.14;
    LOG(LogLevel::INFO) << "server start success";
    LOG(LogLevel::WARNING) << "config missing, use default value";
    LOG(LogLevel::ERROR) << "open file failed";

    ENABLE_FILE_LOG_STRATEGY();
    LOG(LogLevel::INFO) << "switch to file log strategy";

    pthread_t t1, t2, t3, t4;
    pthread_create(&t1, nullptr, ThreadLogTest, (void*)"thread-1");
    pthread_create(&t2, nullptr, ThreadLogTest, (void*)"thread-2");
    pthread_create(&t3, nullptr, ThreadLogTest, (void*)"thread-3");
    pthread_create(&t4, nullptr, ThreadLogTest, (void*)"thread-4");

    pthread_join(t1, nullptr);
    pthread_join(t2, nullptr);
    pthread_join(t3, nullptr);
    pthread_join(t4, nullptr);

    LOG(LogLevel::INFO) << "multi-thread log test finish";
    return 0;
}

编译时注意链接 pthread,并开启 C++17:

复制代码
g++ main.cc -o main -std=c++17 -lpthread

7.2 为什么这套设计是线程安全的?

复制代码
```mermaid
flowchart TD
    A["多个线程同时调用 LOG"] --> B["各自创建 LogMessage 临时对象"]
    B --> C["各自在线程栈上拼接日志内容"]
    C --> D["析构时进入 SyncLog"]
    D --> E["对控制台/文件加锁"]
    E --> F["完整写入一条日志"]
    F --> G["释放锁"]
```

线程安全来自两个层面:

  • 日志格式化阶段:每个线程都有自己的 LogMessage 临时对象,不共享 _loginfo,所以不需要加锁;
  • 日志刷新阶段:控制台和文件是共享资源,必须在 SyncLog 内部加锁。

这样做的好处是锁的粒度很小,只有真正写控制台或写文件时才加锁,日志拼接过程不会阻塞其他线程。

7.3 为什么析构函数里可以刷新日志?

表达式:

复制代码
LOG(LogLevel::INFO) << "hello" << 123;

本质上会生成一个 LogMessage 临时对象。整条语句执行完毕后,临时对象生命周期结束,于是自动调用析构函数。

这时用户内容已经通过多个 operator<< 拼接完成,所以析构函数正好是刷新日志的最佳时机。

7.4 为什么要用策略模式?

如果不用策略模式,Logger 里可能会写很多判断:

复制代码
if (type == CONSOLE) {
    // 写控制台
} else if (type == FILE) {
    // 写文件
} else if (type == NETWORK) {
    // 写网络
}

这种写法后续每增加一种输出方式,都要修改 Logger 本身。

策略模式把"怎么输出"交给子类:

  • ConsoleLogStrategy 只负责控制台;
  • FileLogStrategy 只负责文件;
  • 以后新增 NetworkLogStrategy,不需要动现有逻辑。

这就符合开闭原则:对扩展开放,对修改关闭。

八、后续优化方向

当前版本已经具备一个基础日志系统的核心能力,但如果要继续向工业级靠近,还可以扩展这些功能:

优化方向 说明
异步日志 业务线程只写内存队列,后台线程批量刷盘,降低 I/O 阻塞
日志等级过滤 例如生产环境只输出 INFO 及以上等级
日志滚动 按文件大小或日期切分日志,避免单文件过大
线程 ID 在日志中加入 pthread_self() 或系统线程 ID
单例封装 避免头文件中定义全局对象导致多翻译单元重复定义问题
宏扩展 提供 LOG_INFOLOG_ERROR 等更短的使用方式
异常安全 文件打开失败、磁盘满、目录创建失败时做更完善的兜底

其中最值得继续实现的是 异步日志。同步日志每次都可能触发磁盘 I/O,而磁盘 I/O 相比内存操作非常慢。在高并发服务器中,通常会让业务线程把日志写入内存缓冲区,再由后台线程统一刷盘。

结尾

这套日志系统虽然代码量不大,但里面串起了很多后端开发的关键能力:

  • 用 RAII 管理锁和日志刷新时机;
  • 用策略模式解耦日志形成和日志输出;
  • enum class 表达类型安全的日志等级;
  • localtime_r 保障多线程时间转换安全;
  • 用宏封装 __FILE____LINE__,实现接近 glog 的调用体验;
  • 用互斥锁保护控制台和文件这类临界资源。

真正理解这套设计之后,再去看 glogspdlog 这类成熟日志库,就不会只觉得它们"好用",而是能看懂它们背后的设计取舍。

日志系统不是简单的 cout 替代品,它是工程可观测性的入口。服务出了问题时,日志往往就是我们和现场之间最重要的一条线索。

相关推荐
酱学编程3 小时前
【从零到一实现一个 AI Agent 框架 · 第四篇】04. 任务规划:拆解复杂目标 -
服务器·网络·数据库·人工智能
SkyWalking中文站4 小时前
认识 Horizon UI · AI Assistant:用日常语言查询你的可观测性数据
运维·监控·自动化运维
艾莉丝努力练剑5 小时前
OpenCode AI 编程:Ubuntu 24.04 环境安装与使用指南
linux·服务器·网络·人工智能·tcp/ip·ubuntu
崇山峻岭之间5 小时前
Keil5输出hex转换为bin的设置
linux·运维·服务器
Hoxy.R6 小时前
KingbaseES读写分离高可用集群扩容、备库重建与故障切换实战
运维·数据库
谷雪_6586 小时前
Linux 网络命名空间:从内核原理到企业级容器网络架构全景实战
linux·网络·架构
Tian_Hang6 小时前
Eclipse Ditto 的权限策略
java·服务器·前端·网络·ide·ubuntu·eclipse
qq_163135757 小时前
Linux 【03- chgrp命令超详细教程】
linux
CodeStats7 小时前
【编程语言】深度梳理C/C++、Java、Python、Go、Rust的区别
java·linux·c语言·c++·python·rust·go
考虑考虑7 小时前
nginx配置ssl
运维·后端·nginx