上篇热文:Linux 线程同步硬核解析:从条件变量、阻塞队列到信号量环形队列

目录

源码

1.线程模块必须先引入日志系统

2.策略模式在日志系统中的作用

3.日志等级

4.时间戳:localtime_r

5.Mutex和LockGuard:日志输出必须互斥

6.RAII日志对象

7.宏封装:获取文件名和行号的关键

8. 文件日志策略:从 C++ 代码到 Linux 内核写入路径

9. 控制台日志策略:终端也是临界资源

10.线程安全不只在策略内部,还包括策略切换

11.日志系统和线程池的关系

12.日志输出性能问题

13.内核视角:日志写入可能拖慢多线程程序

14.总结


源码

Logger.hpp:

#ifndef __LOGGER_HPP
#define __LOGGER_HPP

#include <iostream>
#include <cstdio>
#include <string>
#include <ctime>
#include <filesystem>
#include <fstream>
#include <sstream>
#include <unistd.h>
#include <memory>

#include "Mutex.hpp"

namespace LogModule
{
    // 获取时间
    std::string GetTimeStamp()
    {
        time_t timestamp = time(nullptr);
        struct tm data_time;
        localtime_r(&timestamp, &data_time);

        char data_time_str[128];
        snprintf(data_time_str, sizeof(data_time_str), "%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 data_time_str;
    }

    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 &logmessage) = 0;
    };

    // 子类:继承纯虚接口类
    // 策略1
    class ConsoleLogStrategy : public LogStrategy
    {
    public:
        ConsoleLogStrategy() {}
        ~ConsoleLogStrategy() {}
        void SyncLog(const std::string &logmessage) override
        {
            LockGuard lockguard(&_mutex);
            std::cout << logmessage << std::endl;
        }

    private:
        Mutex _mutex;
    };

    static const std::string glogdir = "./log/";
    static const std::string glogfilename = "log.log";
    // 子类:继承纯虚接口类
    // 策略2
    class FileLogStrategy : public LogStrategy
    {
    public:
        FileLogStrategy(const std::string &dir = glogdir, const std::string &filename = glogfilename)
            : _logdir(dir), _logfilename(filename)
        {
            // 创建目录
            LockGuard lockguard(&_mutex);
            if (std::filesystem::exists(_logdir))
            {
                return;
            }
            else
            {
                try
                {
                    std::filesystem::create_directories(_logdir);
                }
                catch (const std::filesystem::filesystem_error &e)
                {
                    std::cerr << e.what() << '\n';
                }
            }
        }
        ~FileLogStrategy()
        {
        }
        void SyncLog(const std::string &logmessage) override
        {
            LockGuard lockguard(&_mutex);
            std::string logfilename = _logdir + _logfilename;
            std::ofstream out(logfilename, std::ios::app); // 追加写入文件
            if (!out.is_open())
            {
                return;
            }
            out << logmessage << "\n";

            out.close();
        }

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

    // 日志类
    class Logger
    {
    public:
        Logger()
        {
            UseConsoleLogStrategy();
        }
        ~Logger()
        {
        }
        void UseConsoleLogStrategy()
        {
            _strategy = std::make_unique<ConsoleLogStrategy>();
        }
        void UseFileLogStrategy()
        {
            _strategy = std::make_unique<FileLogStrategy>();
        }

        // 内部类
        // 将类变为string
        class LogMessage
        {
        public:
            LogMessage(LogLevel level, std::string &filename, int line, Logger&self)
                : _level(level), _curr_time(GetTimeStamp()), _pid(getpid()), _filename(filename), _line(line), _logger(self)
            {
                std::stringstream ss;
                ss << "[" << _curr_time << "]"
                   << "[" << LogLevel2String(_level) << "]"
                   << "[" << _pid << "]"
                   << "[" << _filename << "]"
                   << "[" << _line << "]"
                   << "-";
                _loginfo = ss.str();
            }

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

            ~LogMessage() // RAII风格的日志刷新
            {
                if(_logger._strategy)
                {
                    _logger._strategy->SyncLog(_loginfo);
                }
            }

        private:
            LogLevel _level;        // 日志等级
            std::string _curr_time; // 当前时间
            pid_t _pid;             // 进程pid
            std::string _filename;  // 文件名
            int _line;              // 行号
            std::string _loginfo;   // 一条完整日志

            Logger &_logger; // 外部类的引用
        };

        // Logger 对象打印日志的时候,故意返回一个临时的LogMessage对象
        LogMessage operator()(LogLevel level, 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

main.cc:

#include "Logger.hpp"
#include <iostream>
#include <thread>
#include <chrono>

using namespace LogModule;

int main()
{
    ENABLE_CONSOLE_LOG_STRATEGY();
     LOG(LogLevel::DEBUG) << "CONSOLE hello world" << " xxxx, " << 3.14 << " C";
    LOG(LogLevel::INFO) << " CONSOLE hello world" << " xxxx, " << 3.14 << " C";
    LOG(LogLevel::WARNING) << " CONSOLE hello world" << " xxxx, " << 3.14 << " C";
    LOG(LogLevel::ERROR) << "CONSOLE hello world" << " xxxx, " << 3.14 << " C";
    LOG(LogLevel::FATAL) << "CONSOLE hello world" << " xxxx, " << 3.14 << " C";


    ENABLE_FILE_LOG_STRATEGY();
    LOG(LogLevel::DEBUG) << "FILE hello world" << " xxxx, " << 3.14 << " C";
    LOG(LogLevel::INFO) << " FILE hello world" << " xxxx, " << 3.14 << " C";
    LOG(LogLevel::WARNING) << " FILE hello world" << " xxxx, " << 3.14 << " C";
    LOG(LogLevel::ERROR) << "FILE hello world" << " xxxx, " << 3.14 << " C";
    LOG(LogLevel::FATAL) << "FILE hello world" << " xxxx, " << 3.14 << " C";


    // std::string message = "consule hello log, hello world!";
    // std::unique_ptr<LogStrategy> strategy = std::make_unique<ConsoleLogStrategy>();

    // strategy->SyncLog(message);
    // strategy->SyncLog(message);
    // strategy->SyncLog(message);
    // strategy->SyncLog(message);
    // strategy->SyncLog(message);
    // strategy->SyncLog(message);

    // std::string message1 = "file hello log, hello world!";
    // strategy = std::make_unique<FileLogStrategy>();
    // strategy->SyncLog(message1);
    // strategy->SyncLog(message1);
    // strategy->SyncLog(message1);
    // strategy->SyncLog(message1);
    // strategy->SyncLog(message1);

    // std::cout << LogLevel2String(LogLevel::DEBUG) << std::endl;
    // std::cout << LogLevel2String(LogLevel::WARNING) << std::endl;
    // std::cout << LogLevel2String(LogLevel::INFO) << std::endl;
    // std::cout << LogLevel2String(LogLevel::ERROR) << std::endl;
    // std::cout << LogLevel2String(LogLevel::FATAL) << std::endl;


    // std::cout << "Testing Logger Module" << std::endl;
    // std::cout << "=====================" << std::endl;

    // // Test GetTimeStamp
    // std::cout << "\n1. GetTimeStamp:" << std::endl;
    // std::cout << "Timestamp: " << LogModule::GetTimeStamp() << std::endl;

    // // Test multiple timestamps
    // std::cout << "\n2. Multiple Timestamps (1s apart):" << std::endl;
    // for (int i = 0; i < 3; ++i)
    // {
    //     std::cout << "Timestamp " << i << ": " << LogModule::GetTimeStamp() << std::endl;
    //     std::this_thread::sleep_for(std::chrono::seconds(1));
    // }

    return 0;
}

Makefile:

CXX = g++
CXXFLAGS = -std=c++17 -Wall -g
TARGET = main
SRCS = main.cc
OBJS = $(SRCS:.cc=.o)

$(TARGET): $(OBJS)
	$(CXX) $(CXXFLAGS) -o $@ $^

%.o: %.cc Logger.hpp
	$(CXX) $(CXXFLAGS) -c -o $@ $<

clean:
	rm -f $(OBJS) $(TARGET)

.PHONY: clean

Mutex.hpp:

#ifndef __MUTEX_HPP
#define __MUTEX_HPP

#include <iostream>
#include <pthread.h>

class Mutex
{
public:
    Mutex()
    {
        pthread_mutex_init(&_lock, nullptr);
    }
    void Lock()
    {
        pthread_mutex_lock(&_lock);
    }
    pthread_mutex_t *Orgin()
    {
        return &_lock;
    }
    void Unlock()
    {
        pthread_mutex_unlock(&_lock);
    }
    ~Mutex()
    {
        pthread_mutex_destroy(&_lock);
    }
private:
    pthread_mutex_t _lock;
};

class LockGuard
{
public:
    LockGuard(Mutex *lockp): _lockp(lockp)
    {
        _lockp->Lock();
    }
    ~LockGuard()
    {
        _lockp->Unlock();
    }
private:
    Mutex *_lockp;
};

#endif

1.线程模块必须先引入日志系统

在多线程程序中,日志不是附属功能,而是基础设施。

单线程程序出现问题时,通常还能通过断点、输出变量、单步调试定位。但多线程程序的问题往往和时序有关:线程什么时候启动、什么时候阻塞、哪个线程取到了任务、线程池是否退出、条件变量是否被唤醒、任务是否被正确消费,这些状态很难靠调试器稳定复现。

因此,线程系统里必须有日志。

线程日志至少要回答几个问题:

什么时候发生的?
是什么级别的问题?
哪个进程发生的?
哪个线程发生的?
在哪个源文件、哪一行发生的?
具体发生了什么?
日志应该输出到哪里?

我们目标写出的日志格式为:

[2024-08-04 12:27:03] [DEBUG] [202938] [main.cc] [16] - hello world

这是一个基础但完整的日志结构。它包含时间戳、日志等级、进程PID、文件名、行号和日志正文。

在多线程场景中,可以再补充线程信息:

[时间] [等级] [pid] [tid/lwp] [文件] [行号] - 内容

因为多个线程共享同一个进程PID,仅有PID无法定位到底是哪个线程打印的日志。在Linux上,线程本质是轻量级进程,内核为每个线程分配一个task_struct,也有自己的内核线程ID。可以通过:

syscall(SYS_gettid);获取当前线程在Linux内核中的LWP/TID。

2.策略模式在日志系统中的作用

设计模式是针对常见场景总结出来的一套可复用解决方案。日志系统很适合使用策略模式。

原因:日志内容的构建逻辑基本固定,但日志的输出方式可能变化。

常见输出策略:

输出到控制台
输出到文件
输出到网络
输出到消息队列
输出到 syslog
输出到异步后台线程

如果把这些输出方式全部写进一个 Logger 类里,代码会变成大量 if-else:

if (mode == CONSOLE) {
    ...
} else if (mode == FILE) {
    ...
} else if (mode == NETWORK) {
    ...
}

这种设计扩展性差,后续新增日志输出目标时,必须修改核心类。

策略模式的做法是:定义一个统一的策略接口,不同输出方式实现这个接口。

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

这个类的意义:它不干活,只定规矩:控制台日志,文件日志,网络日志,数据库日志都必须继承它,并且必须实现 SyncLog

控制台日志策略用于实现它:

class ConsoleLogStrategy : public LogStrategy
{
public:
    void SyncLog(const std::string& logmessage) override
    {
        LockGuard lockguard(&_mutex);
        std::cout << logmessage << std::endl;
    }

private:
    Mutex _mutex;
};

文件日志策略也实现它:

class FileLogStrategy : public LogStrategy
{
public:
    void SyncLog(const std::string& logmessage) override
    {
        LockGuard lockguard(&_mutex);

        std::string logfilename = _logdir + _logfilename;
        std::ofstream out(logfilename, std::ios::app);

        if (!out.is_open()) {
            return;
        }

        out << logmessage << "\n";
    }

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

这样 Logger 不需要关心日志最终写到哪里。它只需要持有一个策略对象:

std::unique_ptr<LogStrategy> _strategy;

切换策略时只需要替换_stratrgy:

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

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

这就是策略模式在日志系统中的核心价值:

日志构建逻辑稳定
日志输出策略可替换
新增输出方式不破坏 Logger 主体

3.日志等级

日志等级定义如下:

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

不同等级表达含义:

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";
    }
}

工程化日志系统通常还会做“日志等级过滤”。例如:生产环境只打印INFO以上,调试环境打印DEBUG。这可以避免大量低价值日志拖慢程序。

可以设计一个全局阙值:

LogLevel g_level = LogLevel::INFO;

只有当前日志等级大于等于阙值时才真正输出。

4.时间戳:localtime_r

日志时间戳获取代码如下:

std::string GetTimeStamp()
{
    time_t timestamp = time(nullptr);
    struct tm data_time;

    localtime_r(&timestamp, &data_time);

    char data_time_str[128];
    snprintf(data_time_str, sizeof(data_time_str),
             "%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 data_time_str;
}

这里有两个关键点。

第一,tm_year表示从 1900 年开始的偏移量,所以必须加 1900。

第二,tm_mon 范围是 0 到 11,所以必须加 1。

第三,多线程程序中应该使用 localtime_r,不要使用 localtime。

  • localtime 返回的是指向静态内部缓冲区的指针,多线程同时调用时可能发生数据竞争。localtime_r 是线程安全版本,由调用者传入 struct tm 存储结果。
struct tm data_time;
localtime_r(&timestamp, &data_time);

线程日志中这种细节很重要。日志系统本身必须线程安全,否则它会成为排查问题时的新问题来源。

5.Mutex和LockGuard:日志输出必须互斥

代码中封装了 pthread mutex:

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

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

    pthread_mutex_t* Orgin()
    {
        return &_lock;
    }

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

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

private:
    pthread_mutex_t _lock;
};

它的作用:封装 Linux 原生互斥锁,提供加锁、解锁接口。

以及 RAII 风格的锁守卫:

class LockGuard
{
public:
    LockGuard(Mutex* lockp) : _lockp(lockp)
    {
        _lockp->Lock();
    }

    ~LockGuard()
    {
        _lockp->Unlock();
    }

private:
    Mutex* _lockp;
};

它的作用:拿到一把锁,构造自动加锁,析构自动解锁。

日志输出为什么需要加锁?

因为控制台和文件都是共享资源。多个线程同时执行:

std::cout << logmessage << std::endl;

可能导致输出交错:

[2024-08-04 12:00:00] [INFO] threa[2024-08-04 12:00:00] [DEBUG] ...

文件输出也一样。多个线程同时打开同一个文件、追加写入,如果没有进程内互斥,日志行可能交错,甚至因为缓冲区刷新时机不同导致顺序混乱。

因此每一种日志策略内部都应该保护自己的输出临界区。

控制台策略:

void SyncLog(const std::string& logmessage) override
{
    LockGuard lockguard(&_mutex);
    std::cout << logmessage << std::endl;
}

文件策略:

void SyncLog(const std::string& logmessage) override
{
    LockGuard lockguard(&_mutex);

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

    out << logmessage << "\n";
}

RAII 的价值在这里很明确:只要离开作用域,就自动释放锁。即使中间出现 return,也不会忘记解锁。

6.RAII日志对象

为什么LOG(INFO) << "xxx"能工作?

因为我的Logger内部定义了一个LogMessage类:

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

        _loginfo = ss.str();
    }

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

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

private:
    LogLevel _level;
    std::string _curr_time;
    pid_t _pid;
    std::string _filename;
    int _line;
    std::string _loginfo;
    Logger& _logger;
};

核心设计是:构造时生成日志头,operator<< 不断追加日志正文,析构时自动刷新。

外部调用:

LOG(LogLevel::INFO) << "thread start, id=" << id;

宏展开后类似:

logger(LogLevel::INFO, __FILE__, __LINE__) << "thread start, id=" << id;

logger(...) 返回一个临时 LogMessage 对象。后续的 << 操作都在这个临时对象上追加内容。当整条语句结束时,临时对象生命周期结束,析构函数自动执行,最终调用策略对象输出日志。

这是一种典型的 RAII 日志写法:

构造:生成日志头
流式写入:追加日志正文
析构:自动刷新日志

它的好处是调用端非常简洁:

LOG(LogLevel::DEBUG) << "get task";
LOG(LogLevel::ERROR) << "open file failed: " << filename;

不需要显式调用 Flush() 或 Submit()。

7.宏封装:获取文件名和行号的关键

日志系统通常会使用宏,而不是普通函数,原因是宏可以在调用点展开 __FILE__ 和 __LINE__。

#define LOG(level) logger(level, __FILE__, __LINE__)

调用:

LOG(LogLevel::WARNING) << "queue is empty";

会自动携带当前源文件名和行号。如果不用宏,而是写成函数:

Log(LogLevel::WARNING, "queue is empty");

函数内部拿到的是日志函数所在文件和行号,而不是真实调用日志的位置。这样定位问题就失去了价值。

策略切换也可以用宏封装:

#define ENABLE_CONSOLE_LOG_STRATEGY() logger.UseConsoleLogStrategy()
#define ENABLE_FILE_LOG_STRATEGY() logger.UseFileLogStrategy()

调用端:

ENABLE_CONSOLE_LOG_STRATEGY();

LOG(LogLevel::DEBUG) << "CONSOLE hello world";
LOG(LogLevel::INFO) << "CONSOLE hello world";

ENABLE_FILE_LOG_STRATEGY();

LOG(LogLevel::DEBUG) << "FILE hello world";
LOG(LogLevel::INFO) << "FILE hello world";

提供选择使用何种日志策略的方法。

8. 文件日志策略:从 C++ 代码到 Linux 内核写入路径

文件日志策略看起来只是:

std::ofstream out(logfilename, std::ios::app);
out << logmessage << "\n";
out.close();

但它背后的执行路径很长。在 Linux 上,大致流程是:

std::ofstream 构造
        |
        v
C++ 标准库打开文件
        |
        v
open/openat 系统调用
        |
        v
VFS 层解析路径
        |
        v
文件系统查找 inode/dentry
        |
        v
获得 file 对象和文件描述符
        |
        v
write 系统调用写入数据
        |
        v
数据进入 page cache
        |
        v
内核异步回写到磁盘
        |
        v
close 关闭文件描述符

注意:out << logmessage 并不等于数据立刻落盘。大多数情况下,数据先进入用户态缓冲区,再通过系统调用进入内核 page cache。进入 page cache 后,也不代表已经写入物理磁盘。内核通常会在合适时机进行回写。

如果日志需要强持久化,例如崩溃前最后一条错误必须落盘,需要考虑:fsync(fd);

或者至少 flush 文件流。但这会明显降低性能。高性能日志系统通常会在可靠性和吞吐之间做权衡。

std::ios::app 表示追加写入,底层通常对应追加模式。对于多进程同时写同一个日志文件,单靠进程内 mutex 不够,因为 mutex 只能保护当前进程内的线程,不能保护其他进程。

如果是多进程日志,需要考虑:

O_APPEND
文件锁
单独日志进程
按进程拆分日志文件
集中式日志服务

9. 控制台日志策略:终端也是临界资源

控制台输出不是“天然线程安全”的业务抽象。多个线程同时写标准输出或标准错误,可能造成行级交错。

控制台日志策略中加锁:

class ConsoleLogStrategy : public LogStrategy
{
public:
    void SyncLog(const std::string& logmessage) override
    {
        LockGuard lockguard(&_mutex);
        std::cout << logmessage << std::endl;
    }

private:
    Mutex _mutex;
};

这保证在当前进程内,一条日志从开始输出到结束换行期间不会被其他线程插入。

如果输出到终端,最终也会走系统调用,例如 write,再进入 tty 驱动或管道/伪终端实现。std::cout 本身还有 C++ iostream 缓冲和同步机制,但业务上不应该依赖它来保证整条日志完整。显式加锁是更明确的设计。

实际工程中,日志一般更推荐输出到 std::cerr,因为错误流通常不做完全缓冲,更适合调试场景。但文件日志仍然是服务端程序的主路径。

10.线程安全不只在策略内部,还包括策略切换

当前设计中,每个策略对象内部有自己的 mutex:

ConsoleLogStrategy::_mutex
FileLogStrategy::_mutex

这能保证同一种策略内部输出安全。但还存在一个需要明确的工程问题:Logger::_strategy 本身的切换没有加锁。

例如一个线程正在打印日志:

_logger._strategy->SyncLog(_loginfo);

另一个线程同时执行:

logger.UseFileLogStrategy();

这会修改:

_strategy = std::make_unique<FileLogStrategy>();

如果两个操作并发发生,就可能出现数据竞争:一个线程正在使用旧策略对象,另一个线程释放并替换它。

因此当前版本有一个隐含前提:

日志策略只在程序启动阶段配置,不在多线程运行过程中动态切换。

如果需要运行时动态切换策略,必须保护 _strategy。

一种简单方案是在 Logger 内部增加一把锁:

class Logger
{
public:
    void UseFileLogStrategy()
    {
        LockGuard lock(&_mutex);
        _strategy = std::make_unique<FileLogStrategy>();
    }

    void Flush(const std::string& msg)
    {
        LockGuard lock(&_mutex);
        if (_strategy) {
            _strategy->SyncLog(msg);
        }
    }

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

但是这样会导致日志构建和输出串行化,性能较低。

更好的工程方案可以使用:

std::shared_ptr<LogStrategy>
读写锁
原子 shared_ptr
启动期固定策略

11.日志系统和线程池的关系

下篇文章我们将进入线程池。

线程池运行时需要记录:

线程池构造
线程初始化
线程启动
worker 进入运行循环
任务入队
线程取到任务
线程池停止
线程退出

例如线程池日志场景:

LOG(LogLevel::INFO) << "ThreadPool Construct()";
LOG(LogLevel::INFO) << "init thread " << thread.Name() << " done";
LOG(LogLevel::INFO) << "start thread " << thread.Name() << " done";
LOG(LogLevel::DEBUG) << name << " get a task";
LOG(LogLevel::DEBUG) << "任务入队列成功";
LOG(LogLevel::DEBUG) << "线程池退出中...";

这些日志直接帮助定位线程池状态。如果没有日志,线程池出问题时只能猜:

任务有没有入队?
线程有没有启动?
线程是不是阻塞在条件变量?
Stop 后有没有 NotifyAll?
任务队列是否已经消费完?
Join 卡住是哪个线程没有退出?

有日志后,这些问题可以通过时间线分析。线程日志的价值不是“输出点信息”,而是构建并发程序的可观测性。

12.日志输出性能问题

当前文件策略每写一条日志都会:

打开文件 写入内容 关闭文件

对应代码:

std::ofstream out(logfilename, std::ios::app);
out << logmessage << "\n";
out.close();

优点是简单,且每次写入后文件句柄立即释放。缺点是性能较低。

每条日志都 open/close,会带来大量系统调用:

openat
write
close

在高并发线程池中,如果每个任务都打印日志,这个开销会很明显。

更高性能的设计通常是:

Logger 持久持有 ofstream
多线程写入时加锁
定期 flush
或者使用异步日志线程

异步日志的典型结构是:

业务线程生成日志
        |
        v
日志消息进入阻塞队列
        |
        v
专门日志线程批量写文件

这又回到了生产者消费者模型。业务线程是日志生产者,日志线程是消费者,日志队列是交易场所。

异步日志的优势:

业务线程不直接执行慢速磁盘 IO
可以批量写入,减少系统调用次数
日志模块对主业务延迟影响更小

但异步日志也有代价:

程序崩溃时队列中日志可能丢失
退出时必须 flush 队列
实现复杂度更高

工程阶段需要根据性能目标升级为异步日志。

13.内核视角:日志写入可能拖慢多线程程序

日志输出最终通常涉及系统调用。系统调用会从用户态切换到内核态,成本比普通函数调用高。

一次文件日志可能包括:

  • 用户态格式化字符串
  • 获取互斥锁
  • 打开文件
  • 写入 page cache
  • 关闭文件
  • 释放互斥锁

其中任何一步都可能成为瓶颈。

加锁竞争严重时,线程可能阻塞在 mutex 上。Linux pthread mutex 底层通常基于 futex:

无竞争:用户态原子操作加锁成功
有竞争:futex_wait 进入内核睡眠
解锁时:futex_wake 唤醒等待线程

文件写入进入内核后,也不是简单“把字符串放到磁盘”。它会经过:

系统调用入口
文件描述符表
VFS
具体文件系统
page cache
块层
磁盘驱动
存储设备

通常写入先进入 page cache,然后由内核后台回写线程异步刷盘。如果日志量很大,page cache 压力、IO 调度、磁盘带宽都会影响程序。

所以多线程服务中不能无节制打印日志。尤其是 DEBUG 级别日志,如果在高频路径中大量输出,会改变程序时序,甚至掩盖原本的并发问题。

14.总结

这套日志系统由几个核心组件组成:

  • LogLevel:定义日志等级
  • GetTimeStamp:生成线程安全时间戳
  • LogStrategy:日志输出策略接口
  • ConsoleLogStrategy:控制台输出策略
  • FileLogStrategy:文件输出策略
  • Logger:日志系统入口
  • LogMessage:RAII 日志消息对象
  • LOG 宏:自动注入文件名和行号
  • Mutex / LockGuard:保证输出线程安全

完整调用链如下:

LOG(LogLevel::INFO) << "message"
        |
        v
宏展开,注入 __FILE__ 和 __LINE__
        |
        v
Logger::operator()
        |
        v
构造临时 LogMessage
        |
        v
operator<< 追加日志正文
        |
        v
语句结束,LogMessage 析构
        |
        v
调用当前 LogStrategy::SyncLog
        |
        v
控制台输出或文件输出

这是一个清晰的同步日志框架。

它的优点:

接口简洁
支持流式输入
支持控制台/文件策略切换
使用 RAII 自动刷新
使用锁保证单策略内线程安全
自动记录文件名和行号

它的工程注意点:

策略切换本身需要线程安全约束
文件日志每条 open/close 性能较低
多进程写同一日志文件时进程内锁不够
高并发场景应考虑异步日志
线程日志建议补充 tid/lwp
编译时应使用 -pthread 和 -std=c++17

线程日志系统不是简单的 cout 封装。一个合格的线程日志模块至少要处理四个层次的问题。

第一层是日志内容结构:

时间、等级、pid、tid、文件、行号、正文

第二层是代码设计:

策略模式解耦输出目标
RAII 管理日志消息生命周期
宏注入调用点元信息

第三层是线程安全:

控制台和文件都是临界资源
输出过程必须互斥
localtime_r 替代 localtime
策略切换需要额外同步或启动期固定

第四层是系统与内核行为:

mutex 竞争可能进入 futex
文件写入会经过系统调用、VFS、page cache
日志不一定立即落盘
高频日志会影响线程调度和程序时序

从线程池、阻塞队列、条件变量到日志系统,本质上都围绕同一个主题:多线程程序必须具备可控的同步关系和可观测的运行状态。

日志模块提供的就是可观测性。没有日志,多线程程序的问题只能靠猜;有了结构化、线程安全、可扩展的日志系统,才能基于时间线分析并发行为,定位线程启动、任务调度、锁竞争、线程退出等关键路径。

因此,在设计线程池之前先设计日志模块,是正确的工程顺序。日志不是辅助代码,而是多线程系统的基础设施。

本章完。

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐