来自于《软件架构设计:大型网站技术架构与业务架构融合之道》中第 4 章内容。

总结:Linux RingBuffer 无锁,前提要求多线程读、单线层写,无法做到多线程写。单线程写的方式时,在写入或者写出数据后,先调用内存屏障,再更新 buffer 的 start 和 end 点。这种方式使得其他缓存中的 start 或 end 都 invalid,从而读取最新数据。

内存屏障

下面是 Linux 内核的 kfifo.c 的源代码的一部分。这是一个 RingBuffer,允许一个线程写、一个线程读,整个代码没有任何的加锁,也没有 CAS,但线程是安全的,这是如何做到的?

linux/lib/kfifo.c at master · torvalds/linux · GitHub

//入队(插入数据到 Ringbuffer)
unsigned int __kfifo_in(struct __kfifo *fifo, const void *buf, unsigned int len)
{
	unsigned int l;
 
	l = kfifo_unused(fifo);
	if (len > l)
		len = l;
 
	kfifo_copy_in(fifo, buf, len, fifo->in);
	// kfifo_copy_in 函数的最后一行,也就是此处插入的 store barrier 屏障
	fifo->in += len;
	return len;
}
 
static void kfifo_copy_in(struct __kfifo *fifo, const void *src, unsigned int len, unsigned int off)
{
	unsigned int size = fifo->mask + 1;
	unsigned int esize = fifo->esize;
	unsigned int l;
 
	off &= fifo->mask;
	if (esize != 1) {
		off *= esize;
		size *= esize;
		len *= esize;
	}
	l = min(len, size - off);
 
	memcpy(fifo->data + off, src, l);
	memcpy(fifo->data, src + l, len - l);
	/*
	 * make sure that the data in the fifo is up to date before
	 * incrementing the fifo->in index counter
	 */
	smp_wmb();
}
 
// 出队(从 Ringbuffer 中取出数据)
unsigned int __kfifo_out(struct __kfifo *fifo, void *buf, unsigned int len)
{
	len = __kfifo_out_peek(fifo, buf, len);
	// __kfifo_out_peek 函数内部的最后一行,也就是此处插入 store barrier 屏障
	fifo->out += len;
	return len;
}
 
static void kfifo_copy_out(struct __kfifo *fifo, void *dst, unsigned int len, unsigned int off)
{
	unsigned int size = fifo->mask + 1;
	unsigned int esize = fifo->esize;
	unsigned int l;
 
	off &= fifo->mask;
	if (esize != 1) {
		off *= esize;
		size *= esize;
		len *= esize;
	}
	l = min(len, size - off);
 
	memcpy(dst, fifo->data + off, l);
	memcpy(dst + l, fifo->data, len - l);
	/*
	 * make sure that the data is copied before
	 * incrementing the fifo->out index counter
	 */
	smp_wmb();
}

先说结论,要实现这种完全的无锁,有两个核心点:

  • 读可以多线程,写必须单线程,也称为 Single-Writer Principle。如果是多线程写,则做不到无锁。
  • 在上面的基础上,使用了内存屏障。也就是 smp_wmb() 调用。从用法来讲,内存屏障是在两行代码之间插入一个栅栏,如下所示:
    代码第 1 行
    代码第 2 行
    ……
    代码第 3 行
    代码第 4 行
    

在第 2 行代码和第 3 行代码之间插入一个内存屏障,这样前两行代码就不会跑到后两行代码的后面去执行。虽然第 1 行、第 2 行之间可能被重排序;第 3 行、第 4 行可能被重排序,但第 1 行、第 2 行不会跑到第 3 行的后面去。

所谓“重排序”,通俗地讲,就是 CPU 不会按照开发者写的代码顺序来执行!有人会问这样一来代码逻辑不是全乱了吗?为什么会有重排序,此处不再展开讨论,这又是一个很深的话题,不在本书的讨论范围之内。

回到上面的 kfifo,它有两个指针 fifoin 和 fifoout,分别对应队列的头部和尾部,写的线程操作 fifoin,读的线程操作 fifoout,通过 fifoin 和 fifoout 的比较,就知道队列是空还是满。在这里,内存屏障起到两个作用:

  • 一个线程改了 fifoin 或者 fifoout 之后,另外一个线程要立即可见。另外一个线程去读取这个值时,必须要能得到最新的值。有人可能会问,为什么会出现读不到最新的值?因为在多核 CPU 体系下,每个 CPU 有自己的缓存!改过的这个值可能还在 CPU 的缓存里,没有刷新到内存里。内存屏障就是要强制把这个值刷新到内存里面。
  • 要保证先操作数据,先执行 memcpy 操作,后修改 fifoin 或者 fifoout 的值。为此,在 memcpy 和修改 fifoin/fifoout 之间插入了内存屏障。

补充:Linux 内存屏障使得 cache 缓存失效的原因
现代多处理器系统通常使用缓存一致性协议(如 MESI 协议)来确保不同处理器之间的缓存一致性。这些协议通过以下方式工作:
MESI 协议:MESI 是 Modified, Exclusive, Shared, Invalid 的缩写,表示缓存行的状态。当一个处理器修改了一个缓存行时,该缓存行的状态会变成 Modified,并且其他处理器的相同缓存行会被标记为 Invalid。这样,当其他处理器试图访问这个缓存行时,它们会从主内存或其他处理器的缓存中重新加载最新的数据。

补充:Linux 内存屏障函数

  • smp_mb() 是全内存屏障,确保所有在屏障之前的内存操作都完成之后,屏障之后的内存操作才能开始。这包括读操作和写操作。
  • smp_wmb() 是一个写内存屏障,确保所有在屏障之前的写操作都完成之后,屏障之后的写操作才能开始。
  • smp_rmb() 是一个读内存屏障,确保所有在屏障之前的读操作都完成之后,屏障之后的读操作才能开始。

基于内存屏障,有了 Java 中的 volatile 关键字,再加上单线程写的原则,就有了 Java 中的无锁并发框架——Disruptor,其核心就是“一写多读,完全无锁”。有兴趣的读者,可以参看其源码,进一步分析其设计技巧。

相关的 Java 也有对应的内存屏障函数,参见如何禁用指令重排序