
1. 项目概述为什么我们需要自己动手写日志在C项目里摸爬滚打十几年我见过太多因为日志问题而导致的“悬案”。程序在测试环境跑得好好的一到线上就间歇性崩溃查了半天最后发现日志要么没打要么打了一堆无关紧要的废话关键的错误信息和变量状态全丢了。也见过一些项目初期图省事直接用std::cout或printf输出调试信息后期日志文件膨胀到几十个G查找问题如同大海捞针性能也被拖垮。所以当我们需要一个“C实现Log日志”时我们到底在要什么绝不仅仅是一个能把字符串输出到文件或屏幕的函数。我们需要的是一套生产级的日志基础设施。它要能清晰地记录程序运行的脉络在出问题时提供完整的现场快照同时自身足够轻量、高效、可靠不能成为系统的瓶颈或单点故障。市面上的优秀库如spdlog、glog、log4cxx固然好但理解其设计精髓甚至亲手实现一个简化版对于深入理解异步、多线程、资源管理、设计模式等核心概念有不可替代的价值。这就像老司机不仅要会开车还得懂点发动机原理关键时刻能自己排查故障。2. 核心需求与设计目标拆解在动手写代码之前我们必须明确这个日志模块要达成哪些目标。一个好的日志系统应该像一个训练有素的现场记录员安静、高效、准确。2.1 核心功能需求分级输出这是日志的基石。必须支持如TRACE,DEBUG,INFO,WARN,ERROR,FATAL等不同级别。DEBUG信息在开发时满天飞但在生产环境可能完全关闭ERROR和FATAL则必须确保被记录下来。多输出目的地日志不能只往一个地方写。至少需要支持控制台标准输出/标准错误。文件支持按大小、时间滚动避免单个文件过大。网络发送到远程日志服务器如syslog、ELK。格式化灵活每条日志除了消息本身还应自动携带丰富的上下文信息且格式可配置。例如[2023-10-27 14:30:25.123][INFO][main.cpp:15][thread: 24501] This is a log message.异步日志这是高性能日志库的关键。日志写入尤其是写文件、网络可能是相对慢的I/O操作。如果同步写会阻塞调用线程极大影响程序性能。异步日志将日志消息放入一个内存缓冲区队列由后台专用线程负责取出并写入目的地调用线程放下消息后就可以立刻返回。线程安全现代C程序多是多线程的。日志模块会被所有线程同时调用必须保证内部状态和数据结构的线程安全不能出现日志内容错乱、交叉或程序崩溃。2.2 非功能需求质量属性高性能日志调用应尽可能快。这意味着内存分配要少例如使用线程局部缓存锁竞争要弱例如使用无锁队列或细粒度锁I/O延迟要隐藏异步。低侵入性使用接口应简单直观最好通过宏来调用这样可以在编译期通过条件编译完全移除低级别日志的代码实现零开销。可靠性日志系统自身要健壮。不能因为日志队列满了、磁盘满了等原因导致程序主逻辑挂起或崩溃。需要有合理的降级或丢弃策略。可配置性日志级别、输出格式、文件路径、滚动策略等最好能在程序启动时通过配置文件或代码进行配置无需重新编译。3. 整体架构与核心模块设计基于以上需求我们可以设计一个经典的生产者-消费者模型架构这也是spdlog等库的核心思想。3.1 核心类设计Logger日志器对用户暴露的主要接口。每个Logger有一个名字关联一个或多个Sink输出槽。它负责接收日志请求进行级别过滤格式化日志消息然后将格式化后的消息分发到所有关联的Sink。Sink输出槽抽象基类定义日志输出的接口。具体的派生类实现输出逻辑StdoutSink: 输出到控制台。FileSink: 输出到文件内部实现文件句柄管理和滚动逻辑。RotatingFileSink: 按文件大小滚动。DailyFileSink: 按日期滚动。SyslogSink: 输出到系统日志。Formatter格式化器负责将一条日志记录包含时间、级别、线程ID、文件名、行号、消息等格式化成字符串。通常支持类似printf的格式字符串例如%Y-%m-%d %H:%M:%S [%l] %v。AsyncWorker异步工作器这是实现异步日志的核心。它内部维护一个线程安全的队列如无锁环形缓冲区或多生产者单消费者阻塞队列。Logger将格式化好的消息放入队列AsyncWorker的后台线程不断从队列中取出消息并调用各个Sink的write方法进行实际输出。3.2 数据流与线程模型调用线程 (生产者) - Logger - 格式化 - 消息队列 - 后台线程 (消费者) - Sink - 文件/控制台/网络多生产者所有应用线程都可以调用日志宏它们是生产者。单消费者一个后台专用线程负责消费队列中的所有消息进行I/O操作。这个模型简单有效避免了多消费者写文件时的锁竞争和顺序问题。队列选择队列是关键数据结构。std::queue加std::mutex是最简单的但锁竞争可能成为瓶颈。更优的选择是使用无锁队列如moodycamel::ConcurrentQueue或自己实现一个基于std::vector的环形缓冲区配合原子操作。4. 关键实现细节与代码剖析接下来我们深入到几个最关键的实现部分看看代码到底怎么写。4.1 日志级别与条件编译首先定义日志级别并利用宏来实现条件编译和方便调用。// log_level.h enum class LogLevel { TRACE, DEBUG, INFO, WARN, ERROR, FATAL, OFF // 用于关闭所有日志 }; // log_macros.h #define LOG_TRACE(logger, ...) \ if ((logger)-shouldLog(LogLevel::TRACE)) \ (logger)-log(LogLevel::TRACE, __FILE__, __LINE__, __VA_ARGS__) #define LOG_DEBUG(logger, ...) \ if ((logger)-shouldLog(LogLevel::DEBUG)) \ (logger)-log(LogLevel::DEBUG, __FILE__, __LINE__, __VA_ARGS__) #define LOG_INFO(logger, ...) \ if ((logger)-shouldLog(LogLevel::INFO)) \ (logger)-log(LogLevel::INFO, __FILE__, __LINE__, __VA_ARGS__) // ... 其他级别类似为什么用if语句包裹这是为了性能。logger-shouldLog()是一个简单的级别比较非常快。如果级别不满足后面的格式化参数__VA_ARGS__根本不会被求值也不会构造复杂的日志消息对象避免了不必要的开销。这比在log函数内部判断级别要好。4.2 异步队列与后台线程实现这是整个日志库的“发动机”。我们实现一个简单的基于std::queue和条件变量的版本它清晰易懂对于大多数应用性能已足够。// async_worker.h #include queue #include mutex #include condition_variable #include thread #include atomic #include vector #include memory class AsyncWorker { public: using LogMsgPtr std::shared_ptrstd::string; // 消息使用智能指针避免拷贝 AsyncWorker(); ~AsyncWorker(); // 生产者调用将消息放入队列 void enqueue(LogMsgPtr msg); // 启动后台线程 void start(); // 停止后台线程并清空队列 void stop(); private: void workThread(); // 后台线程函数 std::queueLogMsgPtr queue_; std::mutex queue_mutex_; std::condition_variable queue_cv_; std::thread worker_thread_; std::atomicbool stop_flag_{false}; }; // async_worker.cpp AsyncWorker::AsyncWorker() { start(); } AsyncWorker::~AsyncWorker() { stop(); } void AsyncWorker::start() { stop_flag_ false; worker_thread_ std::thread(AsyncWorker::workThread, this); } void AsyncWorker::stop() { stop_flag_ true; queue_cv_.notify_all(); // 唤醒可能正在等待的线程 if (worker_thread_.joinable()) { worker_thread_.join(); } // 停止时可以选择将队列中剩余的消息刷出这里简单清空 std::lock_guardstd::mutex lock(queue_mutex_); while (!queue_.empty()) { queue_.pop(); } } void AsyncWorker::enqueue(LogMsgPtr msg) { { std::lock_guardstd::mutex lock(queue_mutex_); queue_.push(std::move(msg)); } queue_cv_.notify_one(); // 通知消费者有新消息 } void AsyncWorker::workThread() { std::vectorLogMsgPtr buffer; // 本地缓冲区减少锁竞争 buffer.reserve(16); while (!stop_flag_) { { // 临界区从主队列批量取消息到本地缓冲区 std::unique_lockstd::mutex lock(queue_mutex_); // 等待条件队列非空或收到停止信号 queue_cv_.wait(lock, [this]() { return stop_flag_ || !queue_.empty(); }); if (stop_flag_ queue_.empty()) { break; } while (!queue_.empty() buffer.size() buffer.capacity()) { buffer.push_back(std::move(queue_.front())); queue_.pop(); } } // 释放锁 // 在非锁状态下处理本地缓冲区中的消息执行实际的I/O写入 for (auto msg : buffer) { // 这里应该调用Sink的写入方法。简化起见我们假设有一个全局的写入函数。 // writeToAllSinks(*msg); std::cout *msg std::endl; // 示例输出到控制台 } buffer.clear(); } // 循环结束后再尝试清空一次队列尽最大努力不丢失日志 std::lock_guardstd::mutex lock(queue_mutex_); while (!queue_.empty()) { // writeToAllSinks(*queue_.front()); std::cout *queue_.front() std::endl; queue_.pop(); } }关键点解析批量处理workThread函数中没有取一条消息就写一次而是先批量从主队列取到本地buffer然后一次性处理。这显著减少了锁的争用和I/O系统调用的次数是提升吞吐量的关键技巧。条件变量使用queue_cv_.wait让后台线程在队列为空时休眠避免空转消耗CPU。当生产者enqueue时调用notify_one唤醒它。停止流程stop()设置标志位并通知条件变量后台线程检查到标志位后会处理完队列中剩余的消息再退出确保日志不丢失。内存管理使用shared_ptr管理消息字符串可以安全地在生产者和消费者之间传递所有权无需拷贝字符串内容移动语义。注意这个简单实现有一个潜在风险如果生产者速度远大于消费者I/O太慢队列会无限增长最终耗尽内存。生产级库会设置一个队列容量上限当队列满时可以采用阻塞、丢弃最旧日志或直接丢弃新日志等策略。4.3 格式化器实现格式化器需要解析格式字符串并将日志记录的各个部分填充进去。一个简单的实现是使用fmtlib现已进入C20标准库format它性能好且类型安全。这里我们展示一个基于std::ostringstream的简化版。// formatter.h #include string #include sstream #include iomanip #include chrono struct LogRecord { std::chrono::system_clock::time_point time; LogLevel level; std::string file; int line; std::string message; std::thread::id thread_id; }; class Formatter { public: Formatter(const std::string pattern [%Y-%m-%d %H:%M:%S][%l][%t][%s:%#] %v) : pattern_(pattern) {} std::string format(const LogRecord record) { std::ostringstream oss; for (size_t i 0; i pattern_.size(); i) { if (pattern_[i] % i 1 pattern_.size()) { switch (pattern_[i 1]) { case Y: // 年 case m: // 月 case d: // 日 case H: // 时 case M: // 分 case S: // 秒 formatTime(oss, record.time, pattern_[i 1]); break; case l: // 级别 oss levelToString(record.level); break; case s: // 源文件 oss record.file; break; case #: // 行号 oss record.line; break; case v: // 用户消息 oss record.message; break; case t: // 线程ID oss record.thread_id; break; default: oss pattern_[i 1]; // 未知转义原样输出 } i; // 跳过已处理的转义字符 } else { oss pattern_[i]; } } return oss.str(); } private: void formatTime(std::ostringstream oss, const std::chrono::system_clock::time_point tp, char specifier) { auto tt std::chrono::system_clock::to_time_t(tp); auto tm *std::localtime(tt); switch (specifier) { case Y: oss std::put_time(tm, %Y); break; case m: oss std::put_time(tm, %m); break; // ... 其他时间格式 default: break; } } std::string levelToString(LogLevel level) { switch (level) { case LogLevel::TRACE: return TRACE; case LogLevel::DEBUG: return DEBUG; case LogLevel::INFO: return INFO; case LogLevel::WARN: return WARN; case LogLevel::ERROR: return ERROR; case LogLevel::FATAL: return FATAL; default: return UNKNOWN; } } std::string pattern_; };4.4 文件Sink与滚动策略文件输出是日志最常用的目的地。直接写文件很简单但生产环境必须考虑日志滚动防止单个文件过大。// file_sink.h #include fstream #include string #include mutex class FileSink { public: explicit FileSink(const std::string base_filename, std::size_t max_size 1024 * 1024 * 10, // 默认10MB std::size_t max_files 5) : base_filename_(base_filename) , max_size_(max_size) , max_files_(max_files) , current_size_(0) { openLogFile(); } void write(const std::string msg) { std::lock_guardstd::mutex lock(mutex_); if (!file_.is_open()) { openLogFile(); } file_ msg std::endl; current_size_ msg.size() 1; // 1 for newline if (current_size_ max_size_) { rotateFiles(); openLogFile(); } } private: void openLogFile() { file_.open(base_filename_, std::ios::app); // 追加模式打开 if (file_) { // 获取当前文件大小 file_.seekp(0, std::ios::end); current_size_ file_.tellp(); } } void rotateFiles() { file_.close(); // 滚动文件log.txt - log.1.txt, log.1.txt - log.2.txt, ... for (int i max_files_ - 1; i 0; --i) { std::string old_name i 1 ? base_filename_ : (base_filename_ . std::to_string(i - 1)); std::string new_name base_filename_ . std::to_string(i); std::rename(old_name.c_str(), new_name.c_str()); } // 删除最旧的文件如果超过最大数量 std::string oldest base_filename_ . std::to_string(max_files_); std::remove(oldest.c_str()); current_size_ 0; } std::string base_filename_; std::size_t max_size_; std::size_t max_files_; std::size_t current_size_; std::ofstream file_; std::mutex mutex_; // 保护文件写入和滚动操作 };滚动逻辑解释假设base_filename_是app.log,max_files_3。初始写入app.log。当app.log达到10MB将其重命名为app.log.1然后新建一个app.log继续写。下次滚动时app.log.1重命名为app.log.2app.log重命名为app.log.1再新建app.log。再下次滚动时删除app.log.3如果存在app.log.2-app.log.3,app.log.1-app.log.2,app.log-app.log.1新建app.log。 这样就实现了保留最近N个日志文件。5. 集成与使用示例将以上模块组合起来形成一个简单的日志库接口。// logger.h #include log_level.h #include formatter.h #include async_worker.h #include memory #include vector class Logger { public: Logger(const std::string name, std::shared_ptrAsyncWorker worker nullptr) : name_(name), level_(LogLevel::INFO), worker_(worker) { if (!worker_) { default_worker_ std::make_sharedAsyncWorker(); worker_ default_worker_; } } void setLevel(LogLevel level) { level_ level; } bool shouldLog(LogLevel level) const { return level level_; } templatetypename... Args void log(LogLevel level, const char* file, int line, const char* fmt, Args... args) { if (!shouldLog(level)) return; LogRecord record; record.time std::chrono::system_clock::now(); record.level level; record.file file; record.line line; record.thread_id std::this_thread::get_id(); // 格式化用户消息简单示例实际应用建议用fmtlib char buffer[1024]; snprintf(buffer, sizeof(buffer), fmt, args...); record.message buffer; // 格式化完整日志行 Formatter formatter; std::string formatted_msg formatter.format(record); // 异步写入 worker_-enqueue(std::make_sharedstd::string(std::move(formatted_msg))); } void addSink(std::shared_ptrSink sink) { sinks_.push_back(sink); } private: std::string name_; LogLevel level_; std::shared_ptrAsyncWorker worker_; std::shared_ptrAsyncWorker default_worker_; // 用于没有传入worker的情况 std::vectorstd::shared_ptrSink sinks_; }; // 全局默认logger extern std::shared_ptrLogger default_logger; // log_macros.h (补充) #define LOG_INFO_FMT(fmt, ...) LOG_INFO(default_logger, fmt, ##__VA_ARGS__)使用示例// main.cpp #include logger.h #include file_sink.h #include thread std::shared_ptrLogger default_logger; int main() { // 1. 创建异步工作器 auto worker std::make_sharedAsyncWorker(); // 2. 创建Logger并设置级别 default_logger std::make_sharedLogger(Main, worker); default_logger-setLevel(LogLevel::DEBUG); // 3. 添加输出Sink auto console_sink std::make_sharedStdoutSink(); // 假设有实现 auto file_sink std::make_sharedFileSink(myapp.log, 5*1024*1024, 3); default_logger-addSink(console_sink); default_logger-addSink(file_sink); // 4. 开始记录日志 LOG_INFO_FMT(Application started. PID: %d, getpid()); std::thread t1([](){ for(int i0; i100; i) { LOG_DEBUG_FMT(Thread 1, loop %d, i); } }); std::thread t2([](){ for(int i0; i100; i) { LOG_WARN_FMT(Thread 2, value is %f, 3.14*i); } }); t1.join(); t2.join(); LOG_ERROR_FMT(Simulated error occurred at step %d, 42); // 5. 程序结束AsyncWorker析构函数会等待后台线程写完剩余日志 return 0; }6. 性能优化与高级特性探讨实现基本功能后我们可以进一步优化和增加高级特性。6.1 性能优化点线程局部存储TLS缓存每次日志调用都格式化时间、线程ID等是开销。可以为每个线程缓存这些信息只有当时间跨秒或线程切换时才更新。这能显著减少系统调用和格式化开销。使用更高效的内存分配器频繁构造std::string和shared_ptr可能带来内存分配开销。可以使用内存池或预分配缓冲区来管理日志消息内存。无锁队列如前所述将AsyncWorker中的队列替换为无锁队列如moodycamel::ConcurrentQueue可以完全消除生产者和消费者之间的锁竞争在高并发场景下提升巨大。双缓冲技术后台线程在写文件时生产者可以往另一个缓冲区写入。写完后交换缓冲区指针。这可以进一步减少甚至消除生产者在enqueue时的等待。6.2 高级特性日志过滤除了全局级别还可以支持按Logger名称、按文件/模块路径进行过滤。同步/异步模式切换有时为了调试死锁等问题需要立刻看到日志输出。可以支持同步刷新的模式。崩溃处理程序崩溃时如何确保最后的日志消息能被刷到磁盘可以注册一个信号处理函数在收到SIGSEGV等信号时立即刷新日志缓冲区。动态配置支持在运行时通过信号或配置文件热更新日志级别、输出目的地等无需重启服务。7. 常见问题与排查技巧在实际使用自研或第三方日志库时总会遇到一些坑。这里记录几个典型问题。7.1 日志性能导致服务卡顿现象服务平时正常在流量高峰或打大量DEBUG日志时响应时间急剧上升。排查检查是否是同步日志。如果是I/O阻塞是主要原因。检查异步日志队列大小。如果队列设置过小生产者可能会在队列满时被阻塞。使用性能分析工具如perf,vtune查看热点是否在日志格式化或锁竞争上耗时过多。解决确保使用异步日志。增大异步队列容量。优化格式化逻辑避免复杂的字符串操作。在线上生产环境关闭TRACE/DEBUG级别日志。7.2 日志文件丢失或不完整现象程序崩溃后最新的几条日志没写入文件。排查日志是否配置了缓冲区操作系统和std::ofstream默认可能有缓冲区内容可能还在内存中。异步日志在程序崩溃时后台线程可能来不及写出队列中的所有消息。解决对于文件Sink可以定期如每N条日志或每次写入后调用std::flush()性能有损。实现优雅关闭在程序退出前显式调用logger-flush()或worker-stop()等待所有日志写入完成。使用O_SYNC标志打开文件性能损失大不推荐常规使用。7.3 多线程日志内容错乱现象日志行被打断不同线程的日志混在同一行。排查检查每个Sink的write操作是否线程安全。如果多个线程同时写同一个文件描述符而没有同步就会发生交织。检查格式化过程是否使用了线程不安全的函数如localtime它返回静态缓冲区指针。解决确保每个Sink内部有锁保护如我们FileSink中的mutex_。使用线程安全的函数如localtime_rLinux或localtime_sWindows。7.4 日志文件滚动失败现象磁盘空间已满因为旧的日志文件没有被删除。排查滚动逻辑是否有BUG检查重命名和删除文件的代码。程序是否有权限删除文件是否其他进程如日志采集Agent正在占用旧日志文件导致无法删除解决仔细测试滚动逻辑特别是边界情况如max_files1。在删除文件前检查权限并记录错误。考虑使用“复制-截断”而非“重命名”的方式滚动对正在被读取的文件更友好。8. 自研与使用第三方库的权衡最后我们来谈谈一个根本问题到底应该自己造轮子还是直接用现成的选择自研的情况学习与研究为了深入理解日志库的原理这是最好的方式。极端受限的环境目标平台资源极其有限如某些嵌入式设备需要极度精简的实现现有库可能过于庞大。有非常特殊、定制化的需求现有库无法满足且修改第三方库的代价高于自己实现。选择第三方库的情况推荐用于生产项目追求开发效率与稳定性像spdlog、glog这样的库经过多年发展和大量项目验证在性能、功能、稳定性上通常远优于个人短期实现的版本。需要丰富的生态第三方库通常支持更多的输出后端如网络、系统日志、Windows事件查看器等、更灵活的格式化选项、更好的平台兼容性。团队协作与维护使用公认的库降低了新成员的学习成本也避免了维护一个私有轮子的长期负担。我个人在绝大多数生产项目中都会直接选择spdlog。它头文件-only易于集成性能卓越功能全面。自己动手实现一遍让我在用它的时候更能理解其配置参数背后的含义也能在遇到问题时更快地定位到是配置问题、使用问题还是库本身的问题。这种“知其所以然”的能力正是我们不断拆解和重建这些基础组件的价值所在。