14、多线程日志安全:互斥锁保护,线程局部存储(TLS),无锁日志队列
多线程环境下的日志系统,说白了就是个“并发写入”的问题。
多个线程同时往同一个日志文件里写,如果不做保护,轻则日志内容交错混乱,重则直接崩溃。我早年做嵌入式网关项目时,就吃过这个亏——调试一个网络协议栈的bug,结果日志文件里全是乱码,根本没法看。后来花了整整两天才定位到是日志写入没有加锁。
嗯,这节课我们就来聊聊多线程日志安全的三种主流方案:互斥锁、线程局部存储、以及无锁队列。
14.1 互斥锁保护:最直接,但要注意性能
互斥锁(Mutex)是最基础的同步机制。每个线程在写入日志前先获取锁,写完后释放锁。这样能保证同一时刻只有一个线程在操作日志缓冲区或文件。
核心原则:锁的粒度要尽量小,不要在锁内部做耗时操作(比如磁盘I/O)。
我个人的习惯是:把日志格式化放在锁外面,锁里面只做内存拷贝。这样能大幅减少锁的持有时间。
// 互斥锁保护日志写入
#include <pthread.h>
#include <stdio.h>
#include <string.h>
static pthread_mutex_t log_mutex = PTHREAD_MUTEX_INITIALIZER;
static char log_buffer[4096];
static size_t log_len = 0;
void log_write(const char* msg) {
// 先格式化到临时缓冲区(无锁)
char tmp[256];
int len = snprintf(tmp, sizeof(tmp), "[%lu] %s\n", pthread_self(), msg);
if (len <= 0) return;
// 加锁后只做内存拷贝
pthread_mutex_lock(&log_mutex);
size_t copy_len = (size_t)len < sizeof(log_buffer) - log_len ?
(size_t)len : sizeof(log_buffer) - log_len - 1;
memcpy(log_buffer + log_len, tmp, copy_len);
log_len += copy_len;
pthread_mutex_unlock(&log_mutex);
}
void log_flush() {
pthread_mutex_lock(&log_mutex);
if (log_len > 0) {
fwrite(log_buffer, 1, log_len, stderr);
log_len = 0;
}
pthread_mutex_unlock(&log_mutex);
}
避坑指南:我曾经在一个项目中把日志文件的fwrite()放在了锁内部,结果高并发时性能急剧下降。后来改成先攒到内存缓冲区,定时批量刷盘,性能提升了近10倍。
14.2 线程局部存储(TLS):每个线程自己的日志缓冲区
互斥锁虽然简单,但在高并发场景下锁竞争会非常严重。这时候可以考虑线程局部存储(Thread Local Storage,TLS)。
说白了,就是给每个线程分配一个独立的日志缓冲区。线程写自己的缓冲区,完全不需要加锁。最后再统一合并到全局日志文件中。
// 线程局部存储示例
#include <pthread.h>
#include <stdio.h>
// 每个线程独立的日志缓冲区
static __thread char tls_log_buf[1024];
static __thread size_t tls_log_len = 0;
void tls_log_write(const char* msg) {
int len = snprintf(tls_log_buf + tls_log_len,
sizeof(tls_log_buf) - tls_log_len,
"[%lu] %s\n", pthread_self(), msg);
if (len > 0) {
tls_log_len += (size_t)len;
if (tls_log_len > sizeof(tls_log_buf) - 128) {
// 缓冲区快满了,触发刷盘
tls_log_flush();
}
}
}
void tls_log_flush() {
if (tls_log_len > 0) {
// 这里仍然需要一把全局锁来保护文件写入
// 但锁的粒度已经大大降低
pthread_mutex_lock(&global_flush_mutex);
fwrite(tls_log_buf, 1, tls_log_len, log_file);
pthread_mutex_unlock(&global_flush_mutex);
tls_log_len = 0;
}
}
我的经验:TLS方案特别适合“每个线程独立产生大量日志”的场景。比如我之前做的一个多线程网络服务器,每个线程处理数百个连接,日志量巨大。用TLS后,锁竞争几乎消失了。
但要注意:TLS缓冲区大小要合理设置。太小了频繁刷盘,太大了浪费内存。我一般设为4KB,根据实际日志量调整。
14.3 无锁日志队列:CAS操作实现高性能
如果你追求极致性能,可以试试无锁队列(Lock-Free Queue)。它利用CPU的CAS(Compare-And-Swap)指令来实现并发安全,完全不需要操作系统锁。
无锁队列的核心思想是:多个线程同时往一个环形缓冲区里写数据,通过原子操作来管理读写指针。读线程(通常是专门的日志写入线程)从队列中取出数据并写入文件。
// 无锁环形队列(简化版)
#include <stdatomic.h>
#define QUEUE_SIZE 65536
typedef struct {
char data[QUEUE_SIZE][256]; // 每个槽位256字节
atomic_size_t head; // 写指针
atomic_size_t tail; // 读指针
} lockfree_queue_t;
lockfree_queue_t g_queue;
bool lockfree_enqueue(const char* msg) {
size_t head = atomic_load(&g_queue.head);
size_t next = (head + 1) % QUEUE_SIZE;
// 检查队列是否已满
if (next == atomic_load(&g_queue.tail)) {
return false; // 队列满,丢弃日志
}
// 拷贝日志内容
strncpy(g_queue.data[head], msg, 255);
g_queue.data[head][255] = '\0';
// CAS更新写指针
// 如果head没有被其他线程修改,则更新为next
return atomic_compare_exchange_strong(&g_queue.head, &head, next);
}
bool lockfree_dequeue(char* out_buf) {
size_t tail = atomic_load(&g_queue.tail);
if (tail == atomic_load(&g_queue.head)) {
return false; // 队列空
}
strncpy(out_buf, g_queue.data[tail], 255);
out_buf[255] = '\0';
// CAS更新读指针
size_t next = (tail + 1) % QUEUE_SIZE;
return atomic_compare_exchange_strong(&g_queue.tail, &tail, next);
}
注意:无锁队列虽然性能高,但实现起来非常容易出错。我曾经在CAS的ABA问题上栽过跟头——简单场景下没问题,但高并发运行几小时后,队列指针就乱了。后来加了版本号才解决。
如果你不是特别熟悉无锁编程,建议先用互斥锁方案,性能不够再优化。
14.4 三种方案对比
| 方案 | 性能 | 实现复杂度 | 适用场景 |
|---|---|---|---|
| 互斥锁 | 中等 | 低 | 日志量不大,开发周期紧 |
| 线程局部存储 | 高 | 中 | 每个线程独立产生大量日志 |
| 无锁队列 | 极高 | 高 | 极致性能要求,日志量极大 |
14.5 知识体系图
下面这张图展示了多线程日志安全的三种方案及其关系:
14.6 我的建议
如果你刚开始做多线程日志系统,我建议先从互斥锁方案入手。它简单可靠,不容易出bug。等系统跑起来后,用性能分析工具看看锁竞争是否严重,再决定是否升级到TLS或无锁队列。
记住一句话:先保证正确性,再追求性能。日志系统如果不可靠,那还不如没有日志。
最后分享一个经验:无论用哪种方案,一定要加一个“日志丢弃”机制。当系统负载极高时,宁可丢几条日志,也不能让日志系统拖垮整个应用。我一般会设置一个最大队列深度,超过就丢弃,同时记录丢弃次数。
公众号:蓝海资料掘金营,微信deep3321