直接裸用 std::queue + std::thread 会丢日志,因其非线程安全,即使单生产者单消费者也会因编译器重排和数据竞争导致 double free、段错误或日志截断;应基于 std::atomic 实现无锁环形队列,配合 memory_order_acquire/release 和固定大小 trivially copyable 结构体。

为什么 std::queue + std::thread 直接裸用会丢日志
因为标准容器不是线程安全的——哪怕只是单生产者、单消费者,std::queue::push() 和 std::queue::pop() 在不同线程里并发调用,仍可能触发数据竞争。C++ 标准没保证这种“看似安全”的场景,实际在优化开启时(比如 GCC 的 -O2),编译器可能重排内存访问,导致队列内部指针错乱或析构未完成的对象。
常见错误现象:double free、segmentation fault、日志突然中断但程序不崩溃、部分日志内容被截断或覆盖。
- 别用
std::mutex包裹队列操作来“凑合”——锁会把异步写日志变成同步写,吞掉性能优势 - 也不要自己手写环形缓冲区并依赖
volatile做同步——它不提供原子性或内存序保证 - 真正能用的基底是
std::atomic+ 显式memory_order,配合固定大小的数组模拟无锁队列
怎么用 std::atomic 实现单消费者无锁队列
核心思路:用两个 std::atomic_size_t 分别记录生产者和消费者的下标位置,所有操作只做原子自增,不加锁;缓冲区大小必须是 2 的幂(便于位运算取模),且预留一个空槽避免 full / empty 判定歧义。
关键参数差异:memory_order_relaxed 用于纯计数更新(如 enqueue_pos.fetch_add(1, std::memory_order_relaxed)),而读写缓冲区元素前后必须配对使用 memory_order_acquire / memory_order_release,防止指令重排破坏可见性。
立即学习“C++免费学习笔记(深入)”;
- 缓冲区建议大小设为 8192 或 16384——太小容易满导致日志阻塞主线程,太大浪费内存且 cache 局部性变差
- 消费者线程不能忙等,要用
std::this_thread::yield()或短时std::this_thread::sleep_for(1us)降低 CPU 占用 - 日志结构体必须是 trivially copyable,不能含
std::string或虚函数——否则无法安全 memcpy 到缓冲区
struct LogEntry {
uint64_t ts;
uint32_t level;
char msg[512]; // 静态缓冲,避免堆分配
};
如何把日志刷到磁盘而不卡住消费者线程
消费者线程负责从无锁队列取日志、格式化、写入文件,但直接调用 fwrite() 或 ofstream::write() 后立刻 fflush() 会导致 I/O 成为瓶颈。真正的落盘延迟来自内核 page cache 刷回磁盘的过程,而非 write() 系统调用本身。
性能影响点:频繁 fsync() 会让写入速度跌到几百 KB/s;完全不用则断电可能丢失数秒日志。折中方案是批量 + 定时 + 条件触发三者结合。
- 攒够 4KB 或 10ms 再
write()一次,减少系统调用次数 - 每 500ms 调用一次
fsync(),而不是每次写都刷盘 - 当队列剩余空间 fsync() 并短暂等待(
usleep(100)),防爆满 - 打开文件时用
O_APPEND | O_WRONLY | O_CREAT,避免多进程写冲突;禁用O_SYNC——它会让每次 write 都等磁盘,彻底废掉异步意义
为什么 std::shared_ptr 日志对象在无锁队列里很危险
因为 std::shared_ptr 的引用计数递减操作(析构时)不是无锁的——它内部用原子操作保护计数器,但这个过程仍可能和消费者线程的 pop 操作发生竞争:生产者刚把 shared_ptr 存进队列,消费者取出后析构,计数器减为 0 触发 delete,此时若生产者还在构造另一个同内容的 shared_ptr,就可能 double-free。
更隐蔽的问题是:即使没 crash,频繁 new/delete 加原子计数也会显著拖慢生产路径,违背“零拷贝、低开销”的设计初衷。
- 改用栈上分配的
LogEntry结构体(见上文示例),通过值传递进队列 - 如果消息体超长,预分配固定池(如
static std::array<:aligned_storage_t>, 1024></:aligned_storage_t>),用位图管理生命周期,避免 malloc - 绝对不要在日志路径里调用
std::make_shared或任何可能抛异常的函数——异常穿透线程边界不可控
无锁的难点不在“锁没了”,而在内存序的精确控制和资源生命周期的严格隔离。很多人卡在调试阶段发现日志偶尔错乱,最后发现是忘了在 consumer.load(std::memory_order_acquire) 前加 std::atomic_thread_fence(std::memory_order_acquire) ——这种细节不会报错,但会在高负载下间歇性失效。



















