发布于2026-07-19 阅读(0)
扫一扫,手机访问
先说几个核心判断:直接用fwrite写日志,听起来最直接,但一调用fwrite或std::ofstream::write,业务线程就得老老实实等着系统调用返回——在机械盘上、高负载下、或者日志量突然飙升时,这个调用可能卡上几毫秒甚至更久。对于游戏逻辑、高频交易引擎这类对实时性极其敏感的模块,这显然是无法接受的。
单消费者模型正是为此而生:所有生产者(业务线程)只负责把日志消息推进内存队列,然后扭头就走。唯一一个专用的I/O线程从队列里取数据、格式化、落盘,全程不干扰业务线程。这样一来,业务逻辑和I/O延迟彻底解耦,多线程争抢文件句柄或锁文件流的头疼问题也一劳永逸地解决了。
队列选型上,除非你已经在目标编译器(GCC 9+ / Clang 10+)、CPU架构(x86_64 / ARM64)和对齐要求下,把boost::lockfree::queue跑得稳稳当当,否则还是优先考虑moodycamel::ConcurrentQueue。它不依赖Boost,支持C++11,对指针和结构体拷贝语义的控制也更明晰,省心不少。
但有几个硬性约束必须注意:
noexcept移动构造。日志结构体里别塞std::string,改用char buf[512]加size_t len预分配缓冲区,避免运行时拷贝开销。ConcurrentQueue。运行时扩容会导致内存重分配,指针失效的风险不值得冒。enqueue()之前,务必检查返回值:if (!queue.enqueue(entry)) { /* 丢弃 or 触发告警 */ }。队列满了就丢弃,绝不阻塞。fsync拖垮吞吐每次写完都调fsync(),数据最安全,但性能也最惨。完全不用呢?断电丢日志的风险又扛不住。折中的办法是“批量加定时刷盘”:
std::ofstream以std::ios::app | std::ios::binary模式打开文件,并禁用内部缓冲:file.rdbuf()->pubsetbuf(nullptr, 0)。file.flush()。只有在关键节点——比如进程退出前、日志级别为ERROR时——才调用fsync(fileno(file.native_handle()))。ftruncate(0)清空内容(记得先lseek到开头),避免inode变更和系统调用开销。需要明确的是:flush()只保证数据进入内核页缓存,不保证落盘;fsync()才真正刷到磁盘,但它会阻塞,所以不能每条日志都调。
滚动日志文件这件事,本质上是原子替换文件句柄。但千万别直接rename完再reopen——正在写的ofstream还握着旧文件描述符,新日志仍会写进已经被rename的旧文件里。
正确的做法是这样的:
std::shared_ptr管理当前输出流。落盘线程每次写之前,先auto file = current_file_.load()获取当前文件指针。ofstream,然后用std::atomic_store(¤t_file_, new_file)原子替换指针。std::filesystem::rename()(C++17)或renameat2()(Linux)保证原子性。最容易忽略的一点是:滚动期间,队列里积压的日志仍然会写进“旧”文件,直到旧流析构完成。这意味着滚动不是瞬时切换,而是一个平滑过渡的过程。理解了这一点,你就能在实际项目中少踩一个坑。
