环形缓冲区日志:无锁环形缓冲区设计

日志系统里有个老生常谈的问题——写入效率。你想想看,如果每条日志都要去 malloc 一块内存,再小心翼翼地 free 掉,那在高频写入场景下基本就废了。我早年做嵌入式网关时,就吃过这个亏。日志一多,堆碎片化严重,系统直接挂掉。

后来我换了个思路:用环形缓冲区,预分配一块固定内存,写日志就像往桶里倒水一样,满了就覆盖旧的。这就是我们今天要聊的无锁环形缓冲区设计。

为什么需要无锁设计?

多线程环境下,日志写入往往是高频操作。如果每次写日志都要加锁,那性能损耗是巨大的。我见过一个项目,日志系统占了 CPU 的 30% 时间,一查全是锁竞争。

无锁设计的核心思路是:利用原子操作,让生产者和消费者各自维护自己的指针。生产者只管写,消费者只管读,互不干扰。

关键点:环形缓冲区天然适合单生产者-单消费者模型。如果是多生产者,需要额外处理,但嵌入式场景下,单生产者(日志线程)加单消费者(输出线程)已经够用。

环形缓冲区的基本结构

说白了,就是一个固定大小的数组,加上两个指针:写指针(head)和读指针(tail)。写指针指向下一个要写入的位置,读指针指向下一个要读取的位置。

typedef struct {
    char *buffer;       // 数据缓冲区
    volatile uint32_t head;  // 写指针
    volatile uint32_t tail;  // 读指针
    uint32_t size;      // 缓冲区大小(必须是2的幂)
} ring_buffer_t;

这里有个小技巧:让缓冲区大小为 2 的幂。这样取模运算可以用位运算代替,效率高很多。我习惯用 size & (size - 1) == 0 来检查。

无锁写入的核心逻辑

写入时,我们只需要做三件事:

  1. 检查缓冲区是否已满
  2. 如果未满,写入数据
  3. 更新写指针

这里的关键是:写指针的更新必须是原子的。在 C 语言里,我们可以用 __sync_bool_compare_and_swap 或者 C11 的 atomic_compare_exchange_strong

int ring_buffer_write(ring_buffer_t *rb, const char *data, uint32_t len) {
    uint32_t head = rb->head;
    uint32_t tail = rb->tail;
    uint32_t free_space = (tail - head - 1) & (rb->size - 1);
    
    if (free_space < len) {
        return -1;  // 空间不足
    }
    
    // 写入数据(这里省略了环形拷贝逻辑)
    memcpy(&rb->buffer[head], data, len);
    
    // 原子更新写指针
    __sync_synchronize();  // 内存屏障
    rb->head = (head + len) & (rb->size - 1);
    
    return 0;
}

嗯,这里要注意:必须先写入数据,再更新指针。如果顺序反了,消费者可能会读到不完整的数据。我曾经在调试一个网络协议栈时,就因为这个问题查了整整两天。

无锁读取的核心逻辑

读取的逻辑是对称的:

int ring_buffer_read(ring_buffer_t *rb, char *out, uint32_t max_len) {
    uint32_t head = rb->head;
    uint32_t tail = rb->tail;
    uint32_t data_len = (head - tail) & (rb->size - 1);
    
    if (data_len == 0) {
        return 0;  // 没有数据
    }
    
    uint32_t to_read = (data_len < max_len) ? data_len : max_len;
    
    // 读取数据
    memcpy(out, &rb->buffer[tail], to_read);
    
    // 原子更新读指针
    __sync_synchronize();
    rb->tail = (tail + to_read) & (rb->size - 1);
    
    return to_read;
}

你可能会问:为什么不用锁也能保证安全?因为这里只有一个生产者和一个消费者,而且我们用了内存屏障来保证可见性。说白了,就是让 CPU 别乱优化,该刷缓存就刷缓存。

环形缓冲区的可视化

为了让你更直观地理解,我画了一张图:

环形缓冲区结构图 0 1 2 3 4 5 6 7 已写入数据 head(写指针) tail(读指针) 环形 💡 关键说明 • head 指向下一个要写入的位置(格子6) • tail 指向下一个要读取的位置(格子2) • 已写入数据区域:tail → head(格子2-5)

避免动态内存分配

这个设计最大的好处就是:所有内存都在初始化时一次性分配。运行时不会调用 malloc/free,也就不会有堆碎片问题。

// 初始化环形缓冲区
int ring_buffer_init(ring_buffer_t *rb, uint32_t size) {
    // 确保 size 是 2 的幂
    if ((size & (size - 1)) != 0) {
        return -1;
    }
    
    rb->buffer = (char *)malloc(size);
    if (!rb->buffer) {
        return -1;
    }
    
    rb->head = 0;
    rb->tail = 0;
    rb->size = size;
    
    return 0;
}

// 销毁时统一释放
void ring_buffer_destroy(ring_buffer_t *rb) {
    free(rb->buffer);
    rb->buffer = NULL;
}

你看,只有初始化和销毁时才有内存操作。运行时就是纯内存拷贝,效率极高。

我的经验:缓冲区大小建议设为 4KB 或 8KB。太小了容易丢日志,太大了浪费内存。我在一个 RTOS 项目里用 4KB 的环形缓冲区,跑了一年多没出过问题。

避坑指南

我曾经在一个项目里犯过一个低级错误:忘记处理缓冲区满的情况。结果日志一多,新数据覆盖了旧数据,调试时发现日志不全,查了三天才发现是缓冲区溢出了。

所以,一定要在写入前检查剩余空间。如果空间不足,有两种处理方式:

  • 丢弃新日志:简单粗暴,适合对完整性要求不高的场景
  • 覆盖旧日志:适合需要最新日志的场景,但要注意消费者可能读到不完整的数据

我个人更倾向于第一种。毕竟,丢几条日志总比读到脏数据强。

性能对比

方案 写入延迟 内存碎片 多线程安全 适用场景
动态分配 + 链表 高(malloc 耗时) 严重 需加锁 低频写入
环形缓冲区(无锁) 极低(memcpy) 原子操作 高频写入
静态数组 + 互斥锁 中等 互斥锁 中等频率

从表里能看出来,环形缓冲区在性能和安全性上都有明显优势。特别是对于嵌入式系统这种资源受限的环境,简直是量身定做。

写在最后

无锁环形缓冲区不是什么高深的技术,但用好了能解决大问题。我见过太多项目因为日志系统拖垮性能,最后不得不重构。如果你现在还在用 malloc 写日志,不妨试试这个方案。

记住:预分配、原子操作、内存屏障,这三个词就是无锁环形缓冲区的精髓。掌握了它们,你的日志系统就能跑得又快又稳。


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