来自于《软件架构设计:大型网站技术架构与业务架构融合之道》中第 4 章内容。
总结:Linux RingBuffer 无锁,前提要求多线程读、单线层写,无法做到多线程写。单线程写的方式时,在写入或者写出数据后,先调用内存屏障,再更新 buffer 的 start 和 end 点。这种方式使得其他缓存中的 start 或 end 都 invalid,从而读取最新数据。
内存屏障
下面是 Linux 内核的 kfifo.c 的源代码的一部分。这是一个 RingBuffer,允许一个线程写、一个线程读,整个代码没有任何的加锁,也没有 CAS,但线程是安全的,这是如何做到的?
//入队(插入数据到 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,它有两个指针 fifo→in 和 fifo→out,分别对应队列的头部和尾部,写的线程操作 fifo→in,读的线程操作 fifo→out,通过 fifo→in 和 fifo→out 的比较,就知道队列是空还是满。在这里,内存屏障起到两个作用:
- 一个线程改了 fifo→in 或者 fifo→out 之后,另外一个线程要立即可见。另外一个线程去读取这个值时,必须要能得到最新的值。有人可能会问,为什么会出现读不到最新的值?因为在多核 CPU 体系下,每个 CPU 有自己的缓存!改过的这个值可能还在 CPU 的缓存里,没有刷新到内存里。内存屏障就是要强制把这个值刷新到内存里面。
- 要保证先操作数据,先执行 memcpy 操作,后修改 fifo→in 或者 fifo→out 的值。为此,在 memcpy 和修改 fifo→in/fifo→out 之间插入了内存屏障。
补充:Linux 内存屏障使得 cache 缓存失效的原因
现代多处理器系统通常使用缓存一致性协议(如 MESI 协议)来确保不同处理器之间的缓存一致性。这些协议通过以下方式工作:
MESI 协议:MESI 是 Modified, Exclusive, Shared, Invalid 的缩写,表示缓存行的状态。当一个处理器修改了一个缓存行时,该缓存行的状态会变成 Modified,并且其他处理器的相同缓存行会被标记为 Invalid。这样,当其他处理器试图访问这个缓存行时,它们会从主内存或其他处理器的缓存中重新加载最新的数据。
补充:Linux 内存屏障函数
smp_mb()是全内存屏障,确保所有在屏障之前的内存操作都完成之后,屏障之后的内存操作才能开始。这包括读操作和写操作。smp_wmb()是一个写内存屏障,确保所有在屏障之前的写操作都完成之后,屏障之后的写操作才能开始。smp_rmb()是一个读内存屏障,确保所有在屏障之前的读操作都完成之后,屏障之后的读操作才能开始。
基于内存屏障,有了 Java 中的 volatile 关键字,再加上单线程写的原则,就有了 Java 中的无锁并发框架——Disruptor,其核心就是“一写多读,完全无锁”。有兴趣的读者,可以参看其源码,进一步分析其设计技巧。
相关的 Java 也有对应的内存屏障函数,参见如何禁用指令重排序。