
1. 项目概述为什么我们需要一个自己的多线程安全日志类如果你用C写过稍微复杂点的项目尤其是那种需要长时间运行的服务端程序肯定遇到过日志问题。用std::cout或者printf直接打印在单线程的玩具程序里没问题一旦进入多线程世界控制台的输出就会乱成一锅粥不同线程的日志信息交错在一起根本没法看。更别提性能问题了频繁的I/O操作会成为性能瓶颈。市面上当然有现成的日志库比如spdlog、glog功能强大生态完善。但有时候项目有特殊限制比如不能引入外部依赖或者你想彻底搞明白日志系统背后的门道自己动手设计实现一个就成了一个非常值得投入的练手项目。这个“C多线程安全日志类”项目核心目标就是打造一个高性能、线程安全、易于使用的日志记录工具。它要能优雅地处理多个线程同时写日志的竞争问题要能高效地将日志信息写入文件或控制台还要提供灵活的日志级别控制、格式化输出等功能。这不仅仅是一个工具类它是对C多线程编程、I/O操作、资源管理、设计模式如生产者-消费者的一次综合实践。通过实现它你能深入理解锁的粒度控制、异步操作的优劣、缓冲区设计对性能的影响等关键知识点。接下来我会带你从设计思路到代码实现完整地走一遍这个轮子的制造过程并分享我在实际开发中踩过的坑和总结的经验。2. 核心设计思路与架构选型设计一个日志类首先要回答几个核心问题日志写到哪里如何保证多线程安全同步写还是异步写日志格式怎么定围绕这些问题我们展开设计。2.1 同步 vs. 异步性能与可靠性的权衡这是第一个重大决策点。同步日志意味着调用Log(INFO, “message”)时当前线程会阻塞直到日志信息被完全写入目标文件或控制台。实现简单数据绝对不会丢失只要写入函数成功返回。但缺点显而易见I/O操作是慢速操作会严重拖慢业务线程的执行速度。异步日志则采用生产者-消费者模型。业务线程生产者只需将日志信息放入一个内存缓冲区队列中然后立刻返回继续执行后续逻辑。由一个或多个专用的后台线程消费者负责从缓冲区中取出日志信息批量写入文件。它的优势是性能极高对业务线程的侵入性最小。但缺点是需要管理缓冲区并且在程序异常崩溃时缓冲区中尚未写入磁盘的日志会丢失。我的选择与理由对于大多数追求性能的服务端应用我强烈推荐异步日志。日志丢失的风险可以通过定期刷盘fsync和合理的缓冲区大小来降低到可接受范围。而性能提升带来的收益是实实在在的。本项目将实现一个单消费者、多生产者的异步日志模型这也是最经典和实用的模型。2.2 线程安全与锁的粒度多线程安全是核心诉求。当多个线程同时调用日志接口时我们必须保证每条日志信息的完整性不会被其他线程的日志内容打断。内部数据结构如缓冲区队列的访问是安全的。最粗暴的方法是在日志写入函数内部用一个全局的std::mutex锁住所有操作。但这会使得所有日志线程串行化即便用异步模式生产者们往缓冲区放数据时也会互相阻塞影响并发度。更优的方案是采用**双缓冲区Double Buffering或环形缓冲区Ring Buffer**技术。这里我们采用一个更直观的队列方案并精细控制锁的粒度前端生产者多个线程并发写入时竞争的是将日志条目放入队列这一操作。我们使用一个std::mutex来保护这个队列的push操作。由于push操作非常快只是内存拷贝锁的持有时间极短竞争不会太激烈。后端消费者后台线程独自负责从队列中pop出日志并写入文件。这里pop操作也需要锁保护但我们可以通过批量pop一次取出多条来分摊锁的开销。实际上为了简化前后端通常共享同一个队列和同一个互斥锁但配合条件变量std::condition_variable来实现高效的后台线程唤醒。2.3 日志条目设计与格式化一条日志信息应该包含哪些元数据通常有时间戳精确到毫秒或微秒这是排查问题的关键。日志级别如DEBUG, INFO, WARN, ERROR, FATAL。用于过滤和分类。线程ID记录是哪个线程产生的日志对诊断多线程问题至关重要。源文件位置可选__FILE__,__LINE__。在Debug时非常有用但会降低性能。正文消息用户要记录的具体内容。我们需要设计一个LogEntry结构体来封装这些信息。格式化则是在写入前将这些元数据和用户消息组合成一个字符串。为了提高效率应避免在生成日志的线程中进行复杂的字符串格式化如sprintf可以考虑使用更快的格式化库如fmtlib或者将格式化工作推迟到后台线程。2.4 接口设计易用性与灵活性好的接口应该让用户用起来毫无负担。通常采用宏Macro来提供简洁的接口同时自动捕获文件名、行号等信息。#define LOG_DEBUG(format, ...) Logger::GetInstance().WriteLog(LogLevel::DEBUG, __FILE__, __LINE__, format, ##__VA_ARGS__) #define LOG_INFO(format, ...) Logger::GetInstance().WriteLog(LogLevel::INFO, __FILE__, __LINE__, format, ##__VA_ARGS__) // ... 类似定义WARN, ERROR, FATAL用户就可以这样使用LOG_INFO(“User %s logged in from %s”, userId.c_str(), ip.c_str());同时类应该支持设置日志级别低于该级别的日志不记录、设置输出目标文件/控制台、设置单个日志文件大小滚动归档等。3. 核心组件详细实现基于以上设计我们开始动手实现。我们将构建几个核心类LogEntry日志条目、LogBuffer缓冲区、AsyncLogging异步日志核心、Logger对外接口。3.1 LogEntry日志信息单元LogEntry负责保存一条日志的所有原始信息。它不负责格式化格式化工作交给消费者线程。// log_entry.h #pragma once #include string #include chrono #include “log_level.h” struct LogEntry { std::chrono::system_clock::time_point timestamp; LogLevel level; std::thread::id threadId; std::string sourceFile; // 可选 int sourceLine; // 可选 std::string message; LogEntry(LogLevel lvl, std::string msg, std::string file””, int line0) : timestamp(std::chrono::system_clock::now()) , level(lvl) , threadId(std::this_thread::get_id()) , sourceFile(std::move(file)) , sourceLine(line) , message(std::move(msg)) {} };注意这里使用了移动语义std::move来转移message和sourceFile的所有权避免不必要的拷贝。3.2 LogBuffer高效的内存缓冲区缓冲区是异步日志的基石。我们不直接用std::queueLogEntry因为每个LogEntry都会动态分配内存频繁的new/delete会影响性能。我们实现一个固定大小的内存块缓冲区管理一块连续的内存如std::vectorchar用于存放格式化后的日志字符串。// log_buffer.h #pragma once #include vector #include string #include atomic class LogBuffer { public: explicit LogBuffer(size_t capacity 4 * 1024 * 1024) // 默认4MB : buffer_(capacity), writeIndex_(0) {} bool Append(const char* data, size_t len) { if (len AvailableBytes()) { return false; // 空间不足 } std::memcpy(buffer_.data() writeIndex_, data, len); writeIndex_ len; return true; } void Reset() { writeIndex_ 0; } const char* Data() const { return buffer_.data(); } size_t Length() const { return writeIndex_; } size_t AvailableBytes() const { return buffer_.size() - writeIndex_; } bool IsEmpty() const { return writeIndex_ 0; } private: std::vectorchar buffer_; std::atomicsize_t writeIndex_; // 使用原子操作避免单独锁 };AsyncLogging类可以持有两个或多个LogBuffer一个currentBuffer_供前端线程追加数据一个nextBuffer_作为备用。当currentBuffer_写满时交换currentBuffer_和nextBuffer_并将写满的缓冲区移入待写入文件的队列。这种双缓冲技术能进一步减少前端线程的等待。3.3 AsyncLogging异步日志核心引擎这是最复杂的部分实现了生产者-消费者模型。// async_logging.h (简化版核心逻辑) #pragma once #include “log_buffer.h” #include “log_entry.h” #include thread #include mutex #include condition_variable #include vector #include memory #include atomic class AsyncLogging { public: AsyncLogging(const std::string basename, off_t rollSize, int flushInterval 3); ~AsyncLogging(); void Start(); void Stop(); void Append(const char* logline, int len); // 前端生产者调用 private: void ThreadFunc(); // 后端消费者线程函数 using Buffer LogBuffer; using BufferPtr std::unique_ptrBuffer; using BufferVector std::vectorBufferPtr; std::atomicbool running_; const std::string basename_; const off_t rollSize_; // 日志文件滚动大小 const int flushInterval_; // 刷新间隔秒 std::thread thread_; std::mutex mutex_; std::condition_variable cond_; BufferPtr currentBuffer_; // 当前前端缓冲区 BufferPtr nextBuffer_; // 备用前端缓冲区 BufferVector buffersToWrite_; // 待写入文件的后端缓冲区队列 };关键实现细节 (AsyncLogging::Append)前端线程调用Append传入格式化好的日志行。如果currentBuffer_空间足够直接追加进去。这个操作很快通常不需要锁。如果currentBuffer_空间不足说明它已经写满了。此时需要 a. 获取互斥锁 (std::unique_lockstd::mutex lock(mutex_))。 b. 将currentBuffer_移入buffersToWrite_队列。 c. 如果nextBuffer_可用则将其作为新的currentBuffer_。否则new一个新的缓冲区这种情况很少发生。 d. 通知后台线程 (cond_.notify_one())。 e. 将日志行追加到新的currentBuffer_。如果前端日志产生速度极快导致buffersToWrite_队列堆积过多比如超过25个缓冲区可以采取丢弃策略丢弃非FATAL级别的日志防止内存爆涨。这是一种压力保护机制。关键实现细节 (AsyncLogging::ThreadFunc)后台线程启动后进入循环。等待条件变量被唤醒超时或前端通知。被唤醒后在锁的保护下交换buffersToWrite_和一个本地BufferVector。这样就把待处理的数据从共享区拿到了线程本地可以快速释放锁减少对前端的阻塞。遍历本地的BufferVector将每个缓冲区的内容写入日志文件。写入后根据需要fflush或fsync刷盘并检查当前日志文件大小超过rollSize_则创建新文件滚动。回收处理完的缓冲区放入一个空闲缓冲区池供后续复用避免反复分配内存。3.4 Logger单例与对外接口Logger类采用单例模式饿汉或懒汉提供全局唯一的访问点并封装AsyncLogging的启动、停止和日志写入接口。// logger.h #pragma once #include “async_logging.h” #include “log_level.h” #include string class Logger { public: static Logger GetInstance(); void Init(const std::string basename “default.log”, off_t rollSize 100 * 1024 * 1024, // 100MB滚动 int flushInterval 3); void WriteLog(LogLevel level, const char* file, int line, const char* format, ...); void SetLogLevel(LogLevel level) { logLevel_ level; } LogLevel GetLogLevel() const { return logLevel_; } private: Logger(); ~Logger(); LogLevel logLevel_; std::unique_ptrAsyncLogging asyncLogger_; bool initialized_; // 格式化输出线程ID std::string FormatThreadId(std::thread::id id); // 格式化时间戳 std::string FormatTimestamp(const std::chrono::system_clock::time_point tp); };WriteLog函数的核心工作是判断当前日志级别是否低于设置级别是则直接返回。使用va_list处理可变参数格式化用户消息。将时间戳、线程ID、级别、文件行号、格式化后的消息拼接成一条完整的日志行字符串。调用asyncLogger_-Append(logline.c_str(), logline.length())。4. 性能优化与关键参数调优实现基本功能后性能优化是下一个重点。日志系统本身不能成为系统的瓶颈。4.1 时间戳获取的优化获取高精度时间戳如std::chrono::system_clock::now()是有开销的。如果每条日志都调用在超高并发下会成为热点。一个优化策略是在后台消费者线程中统一获取时间戳。但这就要求前端在生成LogEntry时时间戳是空的或者只记录一个相对时间或序号由后台线程在写入时补充。这增加了复杂性并可能影响日志时间的绝对精度有微小延迟。对于绝大多数应用每条日志独立获取时间戳的精度损失是可以接受的除非你的QPS真的极高十万级以上。一个折中方案是缓存时间戳比如每毫秒或每10毫秒更新一次缓存的时间字符串。4.2 缓冲区大小与数量单个缓冲区大小 (LogBuffer容量)太小会导致频繁交换和通知后台线程增加锁竞争和系统调用开销。太大会增加单条日志的延迟等待缓冲区填满和内存占用。4MB到8MB是一个经验上的甜点区间。空闲缓冲区池大小维护一个空闲缓冲区池比如4个可以避免在缓冲区交换时频繁进行内存分配和释放提升性能。buffersToWrite_队列长度预警如前所述设置一个上限如25超过后丢弃日志是保护系统在极端情况下的自救手段。4.3 文件写入优化批量写入后台线程一次性写入一个BufferVector中的所有数据而不是每条日志写一次文件。这能极大减少write()系统调用的次数。设置文件缓冲区使用setvbuf为日志文件指针设置一个大的缓冲区如_IOFBF, 1MB让标准C库帮我们缓冲进一步合并系统调用。刷盘策略 (flushInterval)默认每隔3秒调用一次fflush确保日志能持久化到磁盘。在追求极致性能且能容忍少量日志丢失的场景可以增大这个间隔甚至不主动刷盘交给操作系统。在要求绝对可靠如金融交易的场景可能需要每条关键日志后都执行fsync性能代价很高。4.4 避免日志内容动态分配这是性能杀手。我们的优化已经在做了LogBuffer使用预分配的大块连续内存。格式化日志行时可以使用fmt::format_to_n()直接格式化到LogBuffer的剩余空间或者使用snprintf到栈上的字符数组再Append到缓冲区。避免使用std::stringstream或频繁创建std::string。5. 常见问题排查与实战心得即使设计再完善实际使用中还是会遇到各种问题。这里分享一些典型的“坑”和解决思路。5.1 日志丢失或不完整现象程序崩溃后最后几条日志没找到。排查检查是否是异步日志模式。如果是崩溃时在内存缓冲区currentBuffer_或buffersToWrite_里的日志肯定会丢失。这是异步日志的固有缺陷。检查刷盘间隔。如果设置的是每10秒刷盘那么崩溃前9秒内的日志都在操作系统缓冲区可能丢失。检查缓冲区交换逻辑。前端Append时如果缓冲区满交换和通知后台线程的过程中发生崩溃也可能导致日志卡在“交接”状态。解决对于关键日志如错误、交易流水可以考虑使用同步日志模式或者提供一个Flush()接口让业务线程在关键点强制刷盘。缩短flushInterval比如设为1秒牺牲一点性能换取更高的可靠性。在程序收到终止信号如SIGTERM, SIGINT时在退出处理函数中调用日志器的Stop()方法它会等待后台线程写完所有缓冲区的日志。5.2 日志文件滚动Rotate失败现象日志文件超过设定大小后没有创建新文件或者新文件创建了但日志仍写入旧文件。排查权限问题检查程序是否有在日志目录创建和写入文件的权限。逻辑错误检查滚动逻辑。是在每次写入前检查文件大小还是在写入后检查off_t rollSize_比较时用的是ftell还是stat获取的文件大小注意ftell返回的是当前文件偏移量不一定是物理文件大小。文件指针未更新创建新文件后是否正确关闭了旧文件的FILE*并打开了新文件的FILE*文件指针是否全局唯一并被正确更新解决// 在ThreadFunc的写入循环中 if (currentWriteFile_ nullptr || writtenBytes_ rollSize_) { if (currentWriteFile_) { fclose(currentWriteFile_); } currentWriteFile_ OpenNewLogFile(); // 根据basename和时间生成新文件名 writtenBytes_ 0; } size_t n fwrite(buffer-Data(), 1, buffer-Length(), currentWriteFile_); writtenBytes_ n;确保writtenBytes_是累计写入当前文件的总字节数。5.3 多线程死锁或性能骤降现象程序在高并发下卡住或者日志吞吐量上不去。排查锁竞争用性能分析工具如perf,vtune查看mutex_的争用情况。如果前端线程大量时间花在等待锁上说明缓冲区太小或后台线程消费太慢。后台线程阻塞检查后台线程的ThreadFunc。文件写入操作fwrite是否可能因为磁盘满、IO错误而长时间阻塞这会拖累整个日志系统。考虑使用非阻塞IO或分离IO线程但这会大大增加复杂度。内存分配检查是否有在日志调用路径上非缓冲区内部进行动态内存分配new/delete。这可能会与业务代码的内存分配器产生锁竞争。解决增大缓冲区大小减少交换频率。确保所有字符串操作如格式化使用栈上空间或缓冲区空间。为日志文件挂载高性能磁盘如SSD。简化日志格式减少单条日志的长度。5.4 日志格式错乱现象日志行中间出现乱码或者两条日志的内容混杂在一行。排查线程安全最可能的原因是线程不安全。检查FormatTimestamp、FormatThreadId等函数是否使用了非线程安全的函数如localtime_rvslocaltime。确保这些函数是线程安全的或者使用锁保护。缓冲区溢出检查Append操作是否做了边界检查。如果一条日志过长超过了缓冲区剩余空间而代码没有正确处理如截断或换缓冲可能导致数据写入越界破坏内存。非原子操作一条日志的生成和写入是否是原子的例如先写时间戳再写消息如果中途被切换线程其他线程的日志插进来就会错乱。我们的设计保证了Append的调用是原子的一条完整的日志行一次性写入缓冲区所以问题通常出在生成完整日志行之前的步骤。解决使用线程安全函数如localtime_r,gmtime_r。在Append中严格进行长度检查。确保从生成日志字符串到调用Append之间字符串本身是完整的。我个人最深刻的一个教训曾经在一次压力测试中日志性能突然暴跌。用strace跟踪发现write系统调用频率正常但每次调用的耗时波动极大。最后发现是运维同事为了“安全”给日志磁盘挂载时加了sync选项导致每次写入都变成同步写。去掉这个选项后性能立刻恢复正常。所以日志系统的性能不仅取决于代码还严重依赖运行环境和配置。