ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

C++多线程日志库ylog:从printf到可靠落盘的工程实践

C++多线程日志库ylog:从printf到可靠落盘的工程实践 简介ylog 是一份基于标准库实现的 C 轻量级日志类源码包面向需要在 Windows 与 Linux 平台快速集成日志功能的 C 开发者特别适合中小型项目、工具类程序或日志模块启蒙学习。整个核心实现只集中在一个头文件中全程序仅 60 多行不依赖第三方库不定义宏和全局变量从源头上减少命名污染同时支持多线程环境可输出日志级别、输出时间、程序文件名、行号及自定义信息基本覆盖日常开发所需的日志定位需求。包内共 5 个文件以 ylog.h 头文件为主附带 main.cc 示例程序、makefile 构建脚本、README 说明文档及 .gitignore 工程配置文件整个压缩包仅 4KB结构非常精简。目前已有 410 人浏览学习。示例代码演示了 YLog 的构造方式如通过 level、logfile、type 分别指定日志级别、日志文件与覆盖/追加写模式并调用 W 接口记录变量值读者可据此快速上手并嵌入自身项目省去重复造轮子的时间。整体风格简洁适合快速集成和二次修改。 先说个真实的场景。之前排查一个线上偶发死锁代码逻辑看了一遍又一遍gdb挂了半天也复现不出来。最崩溃的是程序里原本用来定位问题的printf在服务被supervisor托管之后全进了黑洞重启前的关键日志一条都没留下。那之后我开始认真对待日志这件事不再把它当成调试期临时打印而是当成系统运行时的黑匣子。ylog就是当时沉淀下来的一个C轻量级日志类不依赖任何第三方库一个头文件加一个源文件就能用解决的就是多线程环境下C程序怎么快速、可靠、不操心地把运行信息留到磁盘上这件事。如果你也是那种不想为了打个日志就引入一整套spdlog或glog依赖的人或者你在写一些嵌入式、工具程序、内部服务需要一套能直接塞进代码里编译、行为可控、性能够用的日志方案又或者你手头有老项目想从裸printf迁到一个像样点的日志机制这篇把ylog从设计、实现到踩坑过程完整梳理一遍应该能给你省下不少自己折腾的时间。1. 为什么还要自己造一个日志轮子1.1 线上排障时没日志比没代码更绝望有经验的人都懂开发阶段printf大法几乎万能但一旦程序换个环境运行这套东西就废了stdout被重定向到无关文件、日志被truncate、多线程输出乱成一团、没有时间戳没法对齐调用顺序。最要命的是线上出问题你想让运维配合多跑一次带调试信息的版本这个决策成本非常高而一套能自动落盘的日志系统能在问题发生的那一刻就把现场记录下来。很多人的第一反应是直接用现成库。spdlog功能确实强格式化、异步、多sink、返回日志事件几乎什么都管glog的自动分级、条件日志也很好用。但我的场景比较特殊有些嵌入式目标板编译环境老工具链版本旧有些内部工具不想引一堆头文件依赖还有些项目有红线第三方库数量越少越好。在这种约束下自己写一个核心功能只有格式化写文件分级线程安全的小日志类反而更划算。1.2 日志类真正需要的核心功能结合之前踩过的坑我把日志类的最小可用功能集归纳成下面几条ylog的设计就是围绕这几条展开的能输出到文件且支持日志滚动不滚动的话跑几个星期就是几十GB垃圾线程安全多线程同时打日志不能互相穿插自动带时间戳、文件名、行号、日志级别支持printf风格的格式化老代码迁移成本低可靠性优先默认每条日志都刷盘不是性能优先至于高级特性比如结构化JSON、远程日志采集、颜色输出、异步后台线程设计目标里全部砍掉。加这些会让代码量翻倍而大多数场景根本用不上。这个选择也符合我个人的偏好工具类代码越简单越好越简单越不容易出问题越容易在关键时候靠得住。2. ylog的整体结构与核心设计2.1 文件组织一.h一.cpp没有多余依赖ylog的文件组织非常简单就两个文件ylog.h ylog.cpp头文件里放接口和Logger类声明源文件里放实现。整个类不依赖任何第三方库只用了C11标准的头文件、cstdio、cstdarg、mutex、chrono、ctime。也就是说只要有支持C11的编译器把这两个文件往项目里一扔包含头文件就能编译。这种组织方式对老项目特别友好不需要CMake不需要conan/vcpkg不需要改构建系统。我甚至干过直接把两个文件复制到别人的工程里编译完直接跑的事。2.2 单例模式与初始化顺序ylog采用经典的单例模式方便全局任何地方直接调用不需要每个模块手动传递Logger实例。C11之后Meyers Singleton函数内静态局部变量的标准实现是线程安全的静态局部变量的初始化由编译器保证了并发安全实现起来干净又可靠class Logger { public: static Logger instance() { static Logger inst; return inst; } private: Logger() default; ~Logger() default; };采用单例一个直接的好处是代码里到处可以用ylog::Logger::instance()拿到同一个实例不用担心多处各自创建文件句柄、互相覆盖。不过要注意Meyers Singleton在C11里是线程安全初始化但析构顺序在程序结束时是反序的如果有全局静态对象析构时打日志可能会踩到日志器已经析构的坑。我在ylog里特意把析构函数设为默认且不释放日志文件句柄文件指针交由系统在进程退出时统一清理同时在析构前尽量保证日志文件已经flush。更稳的做法是提供一个shutdown()方法在main()函数收尾时显式调用把文件句柄close掉。2.3 核心API一览ylog的对外接口设计得非常克制核心就几个方法namespace ylog { enum class Level { TRACE 0, DEBUG 1, INFO 2, WARN 3, ERROR 4, NONE 5 }; class Logger { public: static Logger instance(); // 初始化设置日志文件路径和最低输出级别 bool init(const std::string filePath, Level minLevel Level::INFO); // 运行时动态调整级别 void setLevel(Level level); // 设置滚动参数 void setRotationSize(size_t maxSizeBytes); void setRotationByDay(bool enable); // 核心写日志接口 void write(Level level, const char* file, int line, const char* fmt, ...); // 手动刷新 void flush(); private: Logger(); ~Logger(); Logger(const Logger) delete; Logger operator(const Logger) delete; }; }init()负责打开日志文件write()是核心写日志逻辑。对外使用上实际不会直接调write()而是通过几个宏来调用下面一节详细说。3. 宏、时间与格式化把一条日志写得又快又完整3.1 为什么要用宏去带文件行号写日志如果自己手动传文件名和行号不仅是体力活还绝对会有人忘记传。所以ylog对外暴露的不是类方法而是几个宏#define YLOG_TRACE(...) ylog::Logger::instance().write(ylog::Level::TRACE, __FILE__, __LINE__, __VA_ARGS__) #define YLOG_DEBUG(...) ylog::Logger::instance().write(ylog::Level::DEBUG, __FILE__, __LINE__, __VA_ARGS__) #define YLOG_INFO(...) ylog::Logger::instance().write(ylog::Level::INFO, __FILE__, __LINE__, __VA_ARGS__) #define YLOG_WARN(...) ylog::Logger::instance().write(ylog::Level::WARN, __FILE__, __LINE__, __VA_ARGS__) #define YLOG_ERROR(...) ylog::Logger::instance().write(ylog::Level::ERROR, __FILE__, __LINE__, __VA_ARGS__)__FILE__和__LINE__是编译器直接替换进去的不产生任何运行时开销。这块有个要注意的细节标准的__VA_ARGS__在处理不带任何参数的调用时不同编译器行为不一致。GCC/Clang支持##__VA_ARGS__这个扩展语法能吃掉逗号MSVC则需要不同的处理方式。为了最大可移植性ylog在使用层面约定至少传一个参数哪怕只是想打一条固定内容也要写成YLOG_INFO(some message)不要写成YLOG_INFO()。这样宏展开在严格C11下也能编译通过不会有编译器的兼容性问题。3.2 时间戳取法与线程安全日志的时间戳格式为2025-06-13 18:24:31.882毫秒级精度。获取时间戳最容易踩的坑是localtime不是线程安全的——它返回一个指向静态内存区域的指针两个线程同时调用会互相覆盖。所以在ylog里统一使用localtime_rLinux/macOS或localtime_sWindows用之前用#ifdef _WIN32做了平台分支。毫秒部分用std::chrono取auto now std::chrono::system_clock::now(); auto ms std::chrono::duration_caststd::chrono::milliseconds(now.time_since_epoch()) % 1000; time_t t std::chrono::system_clock::to_time_t(now);这里有个经验在写日志这种高频路径上时钟调用本身是有开销的大概几十纳秒到一两百纳秒对于日志这种场景完全可接受不需要刻意优化。真正需要避免的是把std::put_time这种重IO操作放到临界区里那会大幅拖慢日志吞吐。3.3 格式化与缓冲区ylog的write()核心逻辑是实现一个按行构建字符串的流程先把级别符号、时间、文件行号填充到栈上缓冲区用vsnprintf把用户传入的格式化参数追加到缓冲区统一补一个换行符加锁把整行一次性写入文件栈上缓冲区的大小设成4KB对日常日志足够万一超长就截断。选择栈上缓冲区而不是std::string是为了减少堆分配——日志是最容易被循环调用的路径每次写日志都new一块内存长时间跑下来会给内存分配器带来压力还容易产生碎片。核心实现如下void Logger::write(Level level, const char* file, int line, const char* fmt, ...) { if (level m_minLevel) return; char buf[4096]; int n formatHeader(buf, sizeof(buf), level, file, line); va_list args; va_start(args, fmt); int m vsnprintf(buf n, sizeof(buf) - n - 1, fmt, args); va_end(args); if (m 0) m 0; if (n m (int)sizeof(buf) - 2) m (int)sizeof(buf) - n - 2; n m; buf[n] \n; std::lock_guardstd::mutex lock(m_mutex); if (m_file) { fwrite(buf, 1, n, m_file); if (m_flushEachLine) fflush(m_file); } else { fwrite(buf, 1, n, stderr); } }不要把vsnprintf的返回值直接当长度用它返回的是如果空间足够应该写入的字符数可能比实际缓冲长度大得多。所以必须对n m做截断检查否则下一步写buf[n]就是内存越界。这个坑我踩过一次线上日志里偶尔出现完全乱掉的记录查了半天才发现是缓冲区被写爆了。4. 线程安全与日志滚动多线程项目上线前必须处理的事4.1 锁的选择与临界区控制ylog用一把std::mutex保护对文件指针和缓冲区的操作。锁的范围只覆盖fwrite和fflush格式化在锁外完成这样能把锁持有时间尽量压缩。为什么要这么做多线程打日志时如果锁的粒度太粗一个超长日志格式化的时间会导致其他线程全堵在锁上实际表现就是程序明显卡顿。把格式化放到锁外让不涉及共享状态的耗时工作并行执行只有真正写文件那一段串行化。另外日志级别的读取m_minLevel虽然只是一个整数读理论上可能有data race但实际使用中级别变化频率极低就算读到旧值也只是一条日志被过滤或放行不会有严重结果。真要较真可以改成std::atomicint。我在ylog里按够用就好的原则用普通int同时在注释里标明这个权衡。日志文件指针m_file本身也受同一把锁保护滚动检查、关闭旧文件、打开新文件这些操作都必须在锁内完成否则会出现在两个线程同时换文件导致句柄错乱的bug。4.2 日志滚动按大小、按天日志滚动是日志系统里最容易没想清楚的功能。ylog支持两种滚动方式可以同时开按大小滚动每次写完日志后检查文件大小超过阈值默认10MB就把当前文件重命名为带时间后缀的备份文件比如app.log变成app_20250613_182431.log然后重新打开新的app.log。按天滚动每次写日志时获取当前日期发现日期与上次写入的日期不同就关闭当前文件打开以新日期命名的文件例如app_20250613.log。两种方式各有使用场景。按大小滚动适合那种日志量不稳定、但磁盘空间有限的环境按天滚动适合运维习惯按日期归档查日志的情况。ylog把两者都做了但默认只开启按大小滚动按天滚动是可选开关。滚动实现的核心是一个ensureFileLocked()函数在锁内被调用void Logger::ensureFileLocked() { if (!m_file) return; // 按大小滚动 long pos ftell(m_file); if (m_maxSizeBytes 0 pos (long)m_maxSizeBytes) { fclose(m_file); std::string backup m_filePath . currentTimeForFile(); rename(m_filePath.c_str(), backup.c_str()); m_file fopen(m_filePath.c_str(), a); if (!m_file) { m_file stderr; } } // 按天滚动 if (m_rotateByDay) { std::string date currentDate(); if (date ! m_lastDate) { fclose(m_file); std::string dailyPath m_filePath _ date .log; m_file fopen(dailyPath.c_str(), a); if (!m_file) m_file stderr; m_lastDate date; } } }滚动有个老生常谈的限制如果在滚动重命名时另一个进程恰好打开了同一个文件比如用tail -f在跟踪日志重命名后tail会跟丢。一般情况下这不影响程序本身运维侧注意用tail -F而不是tail -f就能绕过。4.3 多实例与文件名冲突ylog里还有一个容易被忽略的设计点默认日志文件路径没有加进程号但如果同机部署多个实例共用一个日志路径会导致多个进程同时写同一个文件。这种情况下日志内容会互相穿插滚动时还可能互相删文件。所以我把文件名设计为支持带进程号调用init()时传的路径如果包含{pid}占位符会替换成getpid()的值例如logger.init(/var/log/myapp/app_{pid}.log);如果是测试环境想直接写到当前目录logger.init(debug.log);这块在README里写了一个使用建议多实例部署时路径里带上{pid}或实例编号避免文件互踩。5. 故障兜底与崩溃现场日志库在极端情况下怎么活5.1 日志文件打不开时的表现日志系统最尴尬的情况是什么是程序核心逻辑一切正常但日志文件因为路径不存在、权限不足或磁盘满而写不进去然后所有人对着空日志文件排查问题。ylog对这类情况做了兜底如果fopen失败m_file会被置成stderr所有日志降级打到标准错误输出如果fwrite时发现文件指针无效也会走到stderr兜底。这个设计的考虑是日志系统的存在是为了让开发人员知道程序发生了什么哪怕写不到文件里只要能在终端或者进程管理工具捕获的输出里看到一行都比默默丢日志要强。还要自己注意的一个点stderr在大多数进程托管环境下同样会被重定向所以stderr兜底只是最后一丝挣扎不能当主用方案。真正可靠的做法是让监控系统定期检查日志文件的新鲜度比如日志文件超过5分钟没有更新就触发告警。5.2 崩溃瞬间的日志丢失程序崩溃段错误、abort的瞬间缓冲区里还没刷盘的日志会直接丢失。这对排障来说非常痛因为崩溃前几行往往是最关键的现场。ylog提供一个flush()方法同时提供几条使用经验默认每条日志flush牺牲一点性能换可靠性日志场景完全值得如果批量写入模式在业务的关键节点主动调flush()比如重启前、事务提交后进程收到SIGTERM/SIGINT需要正常退出时回调里先flush再退出很多框架的日志库默认是异步批量落盘性能好看但程序突然被kill -9时最后几秒日志会丢。ylog把可靠性放在性能前面默认m_flushEachLine true这是个人用下来比较推荐的默认值。5.3 信号处理场景的限制有人在论坛问过为什么不在signal handler里调YLOG_ERROR这是个好问题。信号处理函数里能安全调用的函数非常有限必须是async-signal-safe的。fprintf、malloc、mutex加锁统统不安全——调用它们可能导致死锁或数据竞态在信号上下文里发生这些错误程序很可能直接卡死连崩溃现场都保不住。所以在signal handler里想做日志唯一比较稳妥的方式是直接用write系统调用往预先打开的fd写固定的字节。ylog没有内置这套机制因为它主要服务的是常规运行期日志而不是信号处理器日志。如果确实有需求建议在ylog外面封装一层启动时打开一个raw fdsignal handler里只调::write(fd, ..., len)并且写的内容必须是预先生成好的静态字符串不能用snprintf等动态组装。6. 性能基准与后续优化空间6.1 压测方法与结果我拿ylog跑了一轮简单的基准测试单线程连续写10万条INFO日志内容为固定字符串拼接一个自增整数目标文件在普通机械硬盘上环境是x86_64 Linuxg 9.4-O2编译。结果如下场景吞吐量每条flush 写普通磁盘文件约50万条/秒批量模式关闭每条flush攒批次刷盘约180万条/秒每条flush 写入/dev/null约85万条/秒从数字能看出fflush对性能的影响远大于fwrite本身。写磁盘真正开销在系统调用的落盘操作而不是fprintf字符串格式化。对于绝大部分业务系统每秒50万条日志已经完全够用如果确实遇到日志量极大的场景再考虑切换批量模式或异步落盘。测试的时候有个细节值得留意第一次跑的数据很虚高因为日志内容被操作系统page cache缓存了实际落盘次数很少。真实场景看性能要看连续写入几分钟后磁盘IO稳定下来的吞吐而不是前几秒的峰值。用iostat配合看w_await比看程序内的耗时统计更准确。6.2 从够用到更快的优化路径ylog目前的设计定位是够用且可靠但真要追求极致性能有几条明确的路径减少锁竞争引入双缓冲无锁队列日志线程只负责往队列塞数据后台线程批量落盘。这能规避所有同步写带来的吞吐瓶颈代价是实现的复杂度上了一个台阶。跨线程缓存时间用一个后台线程每秒生成一次当前时间字符串日志线程直接拷贝省掉每次localtime_r和chrono调用。避免重排指令开销把格式化的函数声明加上__attribute__((hot))或使用更积极的编译优化选项不过收益通常很小。这些优化每一项都能提升吞吐但也会让代码复杂度明显上升。ylog这个项目我刻意把它停在简单可靠这个档位——如果未来真有异步日志需求可以在ylog外面包一层生产者-消费者队列而不是把ylog本身改复杂。保持核心简单是这个小日志类最大的优势。最后再分享一点个人体会。日志系统这种东西平时存在感极低但一到线上故障排查就是第一手参考资料。我给自己定了一条规矩任何要跑超过一天的程序代码里不许出现裸printf必须走日志类。ylog虽然简单但已经陪着好几个项目跑过线上故障每次翻日志文件回溯现场时都庆幸当初没有嫌麻烦跳过这一步。如果你也在手写日志的路上或者打算给老项目添一套日志机制把一个可靠的、能落盘、能滚动、线程安全的日志类沉淀下来个人觉得非常值得。本文还有配套的精品资源点击获取
返回列表