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 知识体系图

下面这张图展示了多线程日志安全的三种方案及其关系:

多线程日志安全方案 互斥锁保护 线程局部存储(TLS) 无锁日志队列 pthread_mutex_lock 临界区最小化 内存拷贝代替I/O __thread 关键字 每线程独立缓冲区 定时批量刷盘 CAS原子操作 环形缓冲区 ABA问题处理 选择建议 日志量小 → 互斥锁 | 线程独立大量日志 → TLS | 极致性能 → 无锁队列 不要过度设计,先跑起来再优化

14.6 我的建议

如果你刚开始做多线程日志系统,我建议先从互斥锁方案入手。它简单可靠,不容易出bug。等系统跑起来后,用性能分析工具看看锁竞争是否严重,再决定是否升级到TLS或无锁队列。

记住一句话:先保证正确性,再追求性能。日志系统如果不可靠,那还不如没有日志。

最后分享一个经验:无论用哪种方案,一定要加一个“日志丢弃”机制。当系统负载极高时,宁可丢几条日志,也不能让日志系统拖垮整个应用。我一般会设置一个最大队列深度,超过就丢弃,同时记录丢弃次数。


公众号:蓝海资料掘金营,微信deep3321