☰
手写Linux日志类:从线程安全到异步轮转的性能实践
2026/10/20 23:24:57 网站建设 项目流程

在做Linux服务端开发那会儿,我最怕的不是程序崩溃,而是崩溃后连一句日志都没有。某次线上服务半夜挂掉,我盯着终端里最后的输出,只有一行“Segmentation fault”,连函数名都没有。从那以后,我决定在所有Linux后台程序里都内置一个可靠的日志类。今天就来聊聊我自己实现的这个打印日志的类,从需求、设计到落地,把每个细节都摊开讲清楚,顺便把踩过的坑也都摆出来,给后来的人省点时间。

这篇文章不是一篇泛泛的教程,而是围绕“在Linux下实现一个日志类”这件事的完整复盘。适合刚接触Linux后台开发的初学者,也适合那些觉得市面上日志库太重、想自己动手定制一套的开发者。你会看到我是怎么拆解需求的,怎么选API的,怎么处理多线程写入的,以及日志文件无限膨胀是怎么治住的。

1. 为什么我决定自己写日志类,而不是直接抄一个现成的

1.1 现成日志库的尴尬:功能全,但总差那么一点

先聊动机。Linux下现成的日志库其实不少,功能也都很完整,支持滚动、支持异步、支持各种格式化。但用起来总有几个让我难受的点:一是依赖重,为了打个日志要引入一堆头文件和动态库;二是定制麻烦,我想在日志里加一个业务字段,得扒源码改半天;三是设计过于复杂,很多库内部有大量的配置开关,一个默认配置能把你带跑偏。

我觉得,日志这个功能本质上不复杂,无非就是“把一条带时间戳、带级别、带位置的字符串写到某个地方”。与其迁就第三方库,不如按自己的习惯写一个,完全可控,出问题也能立刻定位。而且自己写一遍,能真正理解日志系统里那些“看起来平平无奇”的机制,比如时间戳怎么取才高效,文件写入为什么慢,线程安全到底怎么保证。

1.2 我的核心需求清单

动手之前,我给自己列了个清单,明确这个日志类必须满足什么:

  • 支持日志级别:DEBUG、INFO、WARN、ERROR,并且可以在运行时调整级别,比如线上只输出INFO以上。
  • 每条日志必须包含:精确到毫秒的时间、线程ID、源代码文件名、函数名、行号。
  • 支持向终端输出,也能写入文件,最好能同时输出。
  • 多个线程并发打日志时,不能出现交错、丢失。
  • 日志文件按大小自动轮转,不能无限占磁盘。
  • 对性能影响尽量小,不能因为打日志把业务卡死。

这些就是一个可用日志类的底线。后面的实现,全部围绕这份清单展开。

2. 接口设计:日志类长什么样才能顺手

2.1 单例模式与全局入口

日志类通常做成单例。原因很简单:日志是一个全局资源,不管哪个模块打印日志,都应该写到同一个输出流里。如果搞成普通类,每个模块各自new一个,日志文件会被多个句柄同时写,没人能保证顺序。

我用的是经典懒汉式单例,加一个静态方法获取实例:

class Logger { public: static Logger& instance(); // ... };

注意,C++11之后,函数内静态局部变量的初始化是线程安全的,所以最简单的写法是:

static Logger& instance() { static Logger logger; return logger; }

这段代码看起来简单,但它是C++11标准保证的:首次进入instance()时,其他线程如果也在调用,会阻塞等待构造完成。不需要自己加锁,也不需要用双重检查锁。这是我最推荐的方式。

2.2 宏封装:把文件名和行号“偷”进来

日志函数如果设计成普通成员函数,调用方得自己传__FILE__和__LINE__,太蠢了。正确的做法是用宏,让预处理器自动把当前文件名、函数名、行号作为参数传给日志函数。

我设计的宏是这样的:

#define LOG_DEBUG(...) Logger::instance().log(LogLevel::DEBUG, __FILE__, __func__, __LINE__, __VA_ARGS__) #define LOG_INFO(...) Logger::instance().log(LogLevel::INFO, __FILE__, __func__, __LINE__, __VA_ARGS__) #define LOG_WARN(...) Logger::instance().log(LogLevel::WARN, __FILE__, __func__, __LINE__, __VA_ARGS__) #define LOG_ERROR(...) Logger::instance().log(LogLevel::ERROR, __FILE__, __func__, __LINE__, __VA_ARGS__)

这里用__VA_ARGS__接收可变参数,把printf风格的格式化串透传进去。实际使用起来是这样:

LOG_INFO("user login, uid=%d, ip=%s", uid, ip.c_str());

宏不仅省了手写文件名行号的麻烦,还带来一个隐藏好处:在编译期可以用条件编译启用或禁用某级别的日志。比如我经常在DEBUG编译下额外输出一些TRACE级别的细节,Release版本里完全把它们剔除,不让它们产生任何运行时代价。

3. 核心实现:从时间戳到文件行的逐层打磨

3.1 时间戳:用对接口,性能和精度兼得

日志时间戳最常见的错误用法是调time(NULL),它只能精确到秒,而且在高频打日志时会产生大量“同秒日志”,排序都困难。我改用clock_gettime(CLOCK_REALTIME, ...)获取纳秒级数值,再手动拆成毫秒。

实现如下:

#include <time.h> #include <sys/time.h> void Logger::formatTime(char* buf, size_t len) { struct timespec ts; clock_gettime(CLOCK_REALTIME, &ts); struct tm tm; localtime_r(&ts.tv_sec, &tm); snprintf(buf, len, "%04d-%02d-%02d %02d:%02d:%02d.%03d", tm.tm_year + 1900, tm.tm_mon + 1, tm.tm_mday, tm.tm_hour, tm.tm_min, tm.tm_sec, static_cast<int>(ts.tv_nsec / 1000000)); }

这里有两个关键点。第一,使用localtime_r而不是localtime,因为localtime返回的是静态缓冲区,多线程调用会互相覆盖,属于严重的数据竞争。第二,clock_gettime比gettimeofday更符合POSIX标准,能拿到纳秒,配合tms结构体的localtime_r,是Linux下最稳的组合。

3.2 线程ID:用syscall获取真实的线程标识

在Linux下,同一进程内不同线程的getpid()返回值是一样的,必须用gettid系统调用才能区分到底哪个线程打的日志。glibc没有直接封装gettid,需要自己通过syscall获取:

#include <unistd.h> #include <sys/syscall.h> long getThreadId() { return syscall(SYS_gettid); }

每次打日志都调用一次syscall,性能上有一点小开销,但通常可以接受。优化方案是用线程局部变量缓存一次,线程退出后ID不会复用,所以缓存是安全的:

thread_local long t_threadId = syscall(SYS_gettid);

这样第一次调用时取一次,后面直接用缓存值,比每条日志都陷入内核快得多。

3.3 文件名裁剪:只保留basename

__FILE__在编译时通常会给出相对路径,比如../src/logger.cpp,如果直接打日志,文件名会很长。我写了个小函数,从最后一个“/”后面截取:

const char* basename(const char* path) { const char* p = strrchr(path, '/'); return p ? p + 1 : path; }

注意,C++标准库也有basename函数,但行为因平台而异,还是自己写个简单的最放心。

3.4 可变参数格式化:格式化字符串的正确姿势

日志消息本质上就是格式化的文本。我采用C风格的vsnprintf,先算长度再写缓冲区,避免缓冲区溢出:

void Logger::log(LogLevel level, const char* file, const char* func, int line, const char* fmt, ...) { if (level < m_level) return; char message[4096]; va_list args; va_start(args, fmt); vsnprintf(message, sizeof(message), fmt, args); va_end(args); writeOut(level, file, func, line, message); }

缓冲区大小要根据实际场景设置。对于大多数日志消息,4096字节足够,但如果业务会打很长的包体内容,建议把缓冲区放到堆上,或者使用动态分配。我在项目里也遇到过日志内容被截断的问题,后来改成先确定长度再动态分配,这里给出一个更保险的版本:

void Logger::log(LogLevel level, const char* file, const char* func, int line, const char* fmt, ...) { if (level < m_level) return; va_list args; va_list args_copy; va_start(args, fmt); va_copy(args_copy, args); int needed = vsnprintf(nullptr, 0, fmt, args_copy); va_end(args_copy); std::string message(needed, '\0'); vsnprintf(&message[0], needed + 1, fmt, args); va_end(args); writeOut(level, file, func, line, message); }

先调用一次vsnprintf拿到需要的长度,再分配对应大小的string,最后真正格式化。这里两步都用同一个va_list,所以我用了va_copy来复制一份,避免第二次使用被破坏的va_list。

3.5 拼装完整日志行并写文件

所有要素都拿到后,拼成一行。完整的输出格式我设计成:

[时间] [级别] [线程ID] [文件名:函数名:行号] 消息

例如:

[2025-06-15 14:32:07.123] [INFO] [12345] [logger.cpp:main:23] server started on port 8080

格式保持固定,后续用awk或者grep就非常方便。写入文件用FILE*,因为C标准库的fprintf带缓冲,比裸write调用性能好不少:

void Logger::writeOut(LogLevel level, const char* file, const char* func, int line, const std::string& msg) { char header[512]; formatTime(header, sizeof(header)); // 拼接完整行,再一次性写 char lineBuf[8192]; int n = snprintf(lineBuf, sizeof(lineBuf), "[%s] [%s] [%ld] [%s:%s:%d] %s\n", header, levelString(level), getThreadId(), basename(file), func, line, msg.c_str()); std::lock_guard<std::mutex> lock(m_mutex); if (m_file) { fwrite(lineBuf, 1, n, m_file); fflush(m_file); // 线上模式可以关掉这行以提高性能 } // 同时输出到终端 if (m_console) { fwrite(lineBuf, 1, n, stdout); fflush(stdout); } }

这里flush的频率是一个重要取舍。每条日志都flush,性能很差,在每秒几千条日志的高压下会让程序明显变慢。如果程序崩溃,缓冲区的日志也会丢掉。我的做法是文件输出默认不flush,通过定期flush保证数据尽量不丢;终端输出则保持flush,因为人看到日志要立即反馈。

4. 线程安全与性能:日志类最容易被忽视的坑

4.1 锁的粒度与必要冲突

多线程同时调用LOG_INFO时,如果不对写文件加锁,两条日志可能交错在一起,格式全乱。因此我上面的writeOut里用mutex保护写入。这个锁的粒度是整个拼接完成后的写操作,而不是把拼接过程也包进去,这样锁内工作尽量短,竞争窗口越小,并发性能越好。

实际上,最简单可靠的方案是:在每个log调用内加锁,保证整行原子写入。这个方案在高并发下确实慢,但对于大多数Linux服务程序已经够用。如果单日日志量上亿,就需要引入异步队列。

4.2 异步日志:用双缓冲把磁盘写入拆出去

同步日志每条都等磁盘IO,磁盘再快,也比内存慢几个数量级。我实现异步日志的思路是:业务线程只负责构造日志消息推入内存队列,后台有一个专门的写入线程,每隔一段时间或者队列达到一定水位,就把这批日志一次性写进文件。

这里我用了双缓冲而不是一条条加锁,避免锁竞争。核心结构如下:

class AsyncLogger { public: AsyncLogger() : m_running(true) { m_thread = std::thread(&AsyncLogger::loop, this); } ~AsyncLogger() { m_running = false; m_cv.notify_all(); m_thread.join(); } void append(const std::string& line) { std::lock_guard<std::mutex> lock(m_mutex); m_buffers[m_current].push_back(line); m_cv.notify_one(); } private: void loop() { while (m_running) { std::unique_lock<std::mutex> lock(m_mutex); m_cv.wait_for(lock, std::chrono::milliseconds(100), [this] { return !m_buffers[m_current].empty() || !m_running; }); // 交换缓冲区,使写入线程拿到积压数据 auto& toWrite = m_buffers[m_current ^ 1]; toWrite.clear(); std::swap(toWrite, m_buffers[m_current]); lock.unlock(); FILE* fp = fopen(m_filename.c_str(), "a"); for (const auto& line : toWrite) { fwrite(line.data(), 1, line.size(), fp); } fclose(fp); } } std::thread m_thread; std::mutex m_mutex; std::condition_variable m_cv; bool m_running; std::vector<std::string> m_buffers[2]; int m_current = 0; std::string m_filename; };

这个实现的核心思想是:业务线程把日志放到缓冲区A,写入线程负责把缓冲区B写完并写文件,然后交换两个缓冲区。这样写入线程在写文件时,业务线程还能继续往另一个缓冲区写,互不阻塞。等待时用wait_for设置100毫秒的超时,保证即使业务线程不通知,最多100毫秒也会把日志刷到磁盘。

当然,异步日志会带来日志丢失的窗口:程序被kill -9强杀时,队列里的日志就丢了。我的经验是:关键错误日志也用同步方式输出,或者崩溃后用core文件补足,不要让异步队列成为线上事故后唯一线索的黑洞。

4.3 日志性能实测结论

我测试过同步和异步两种写法的差异。在单线程下,每条日志都flush,大约每秒能写3万条;改成异步批量写后,每秒能写到30万条以上,而且业务线程几乎感觉不到延迟。所以如果只是日常调试,同步就够;如果是高并发生产环境,异步是必须的。

5. 日志轮转:防止磁盘被日志塞满

5.1 按大小轮转的基本思路

运行时间一长,日志文件动不动就几个GB,磁盘直接告警。所以日志类必须支持按文件大小轮转。我的策略是:记录当前写入文件的字节数,超过设定阈值比如100MB,就把当前文件重命名,规则是log.log变成log.log.1,原来的log.log.1变成log.log.2,依次类推,最多保留10个文件,最老的删除。

轮转动作必须在写锁内完成,防止写入线程和轮转逻辑出现竞争。代码大致如下:

void Logger::checkRollOver() { if (m_fileSize >= m_maxSize) { fclose(m_file); m_file = nullptr; // 循环重命名 remove((m_filename + ".10").c_str()); for (int i = 9; i >= 1; --i) { std::string src = m_filename + "." + std::to_string(i); std::string dst = m_filename + "." + std::to_string(i + 1); rename(src.c_str(), dst.c_str()); } rename(m_filename.c_str(), (m_filename + ".1").c_str()); m_file = fopen(m_filename.c_str(), "a"); m_fileSize = 0; } }

每次fwrite后就累加m_fileSize,超过阈值就调用checkRollOver。注意这里我用更细粒度的文件大小记录,而不是依赖ftell,因为ftell在某些文件描述符下会有缓冲导致的误差。

5.2 按日期轮转的扩展

有的场景希望按天生成日志,比如access_20250615.log这种格式。我的处理方式是把日期作为文件名的一部分,在formatTime线程里取到tm后,比较当前日期的年月日和上一次的日期,不一样就关闭旧文件、打开新文件。这比大小轮转更符合运维按天清理的习惯。

这两种轮转逻辑并不冲突,可以同时开启。比如:按天生成主文件,同一天内超过100MB再滚动出.1、.2。

5.3 轮转时的数据一致性

有一个容易被忽略的坑:轮转时如果某个线程正准备写日志,文件句柄已经被关了,就会fwrite到空指针导致崩溃。所以轮转操作前,一定要加锁;写入操作也要在同一个锁内检查m_file是否为null。我的做法是writeOut里先加锁再fwrite,轮转函数也在内部加锁,保证任何时候只有一个线程操作文件。

6. 我在实现过程中踩过的坑(排查链路完整记录)

6.1 时间戳全是“1970-01-01”的坑

有一次我的日志时间全部显示成1970-01-01,查了半天,发现是memset初始化tm结构体时,把tm_year也清成了0,而localtime_r正常返回的tm本来是有值的。问题不在于localtime_r,而在于我打印之前误用了一个未初始化缓冲区的局部变量,把tm指针覆盖了。

这里也想提醒大家:格式化时间用的是局部结构体,不要图省事用静态结构体,否则多线程下时间会串。

6.2 调用系统库的basename导致文件名一直不变

最初我用libgen.h的basename函数,结果发现所有日志的文件名都显示成“logger”或“c”,因为那个函数会修改传入的字符串,且返回的可能是同一个静态缓冲区。换成自己写的strrchr版本后,问题立刻消失。

经验是:自己写一个简单字符串函数,往往比系统库更可控,也更少意外。

6.3 异步日志线程退出时丢掉了最后一批日志

我的异步日志线程是在析构函数里设置m_running=false并notify,但当时没有把当前缓冲区的数据写出去,导致最后一批日志永远丢了。后来我在退出循环前增加了一个drain函数,把剩余两个缓冲区都交换出来并写入文件:

void AsyncLogger::drain() { for (auto& buf : m_buffers) { FILE* fp = fopen(m_filename.c_str(), "a"); for (const auto& line : buf) { fwrite(line.data(), 1, line.size(), fp); } fclose(fp); } }

这个细节在做程序优雅退出时特别重要,否则排障时会发现崩溃前最重要的日志竟然神秘失踪。

6.4 日志级别在release版本被优化掉

我一开始把级别判断放在log函数内部,后来发现即使编译时定义了NDEBUG,DEBUG级别的字符串格式化依然会执行,无形中增加了开销。优化方案是让LEVEL宏里面先做一个编译期可评估的级别判断,比如:

#define LOG_DEBUG(...) \ do { \ if (Logger::instance().enabled(LogLevel::DEBUG)) \ Logger::instance().log(LogLevel::DEBUG, __FILE__, __func__, __LINE__, __VA_ARGS__); \ } while (0)

这样在release编译时,如果确定不需要DEBUG,可以把这个判断写成一个常量false,编译器就能直接剪掉这些代码。

6.5 不要忽略fflush的代价

最后一个坑是关于flush的。我最初为了“日志实时可见”,每条日志都fflush,结果线上一个高吞吐模块的吞吐量直接掉了40%。后来改成每10秒或者每512KB flush一次,性能基本恢复,最多丢失10秒的日志。在做实时链路追踪时,可以单独把TRACE级别的日志实时输出到终端,不必一刀切。

最后再分享一个实践小技巧

如果你也准备自己写日志类,我建议你先从同步版本开始,跑通基本功能后再加异步、再加轮转。千万不要一开始就上双缓冲和条件变量,因为日志类本身就是一个“如果写错了也不一定立刻崩溃”的组件,错误往往要等到极端情况才暴露。从简单开始,逐步加复杂度,每一层都验证可靠,后面排查问题会省很多力气。

我在实际项目里用这套日志类连续运行了几个月,日志文件大小、内容可读性、并发表现都符合预期。有时候我也会把回调函数挂进去,让日志超过ERROR级别时自动触发告警,这就等于一个小型的监控系统了。你完全可以根据自己的业务去扩展它,这正是自己造轮子的最大乐趣。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询