
AMD
很多人都回答过这个问题了,我在他们的基础上补充点内容吧。环形缓冲区(RingBuffer)很容易实现无锁(lock free或者lock less)。无锁方式重点不在性能,而在于简单。它没有加锁、解锁步骤,也没有各种等待者队列,大不了重试就好了。我简单举个例子,进程P1持续产生一些数据,另一个进(线)程C1获取这些数据并做进一步处理。这就类似生产者与消费者模式,是很常见的并行模式。数据处理分两个阶段,生产者和消费者各管一个阶段,前者负责第一阶段处理并把数据交给后者,后者进行第二阶段处理。这两个阶段可并行开展,如同流水线一般,这难道就是并行?生产者和消费者都可以有多个,情况会复杂些。先讲简单的情况,生产者与消费者都仅为一个。
P1为生产者,C1是消费者,P1处理完的数据怎样传给C1?消息机制可用,类似的管道也行。不过,在P1与C1之间设共享内存效率最高。
P1持续向共享内存写入数据,C1不断从中取出数据进行第二阶段的处理。P1无需等待C1接收完毕,数据传送无系统调用,皆在用户态,效率更高且更具可控性。还能扩展成多个生产者对多个消费者的模式。先来说只有一个P1和C1时的情况:

移动
RingBuffer本质也是一块内存,其大小固定,用到末尾就又从头开始用,像旁边画的圆环,因此被称为环形缓冲区。图3里增添可用空间指针与已取数据指针这两个指针。生产者P1完成第一阶段后,把Data 1写入RingBuffer,具体过程如下:步骤1:P1读取可用空间指针。步骤2:P1把Data 1写入可用空间指针指向的空间。步骤3:P1把可用空间指针修改为指向Data 1末尾。消费者C1日常工作内容如下:步骤1:轮询读取可用空间指针,若发现其有变化,就表明有新数据写入RingBuffer(如示例中的Data 1)。步2:在已取数据指针位置读取Data 1。步骤3:读取结束后,修改已读取数据指针,使其指向Data 1的末端。读取数据后,C1进入第二阶段处理,不过这不在我们的讨论范围内。仅P1会修改可用空间指针,而C1会对可用空间指针进行读取。已取数据指针也是如此,C1负责写,P1负责读。两个指针分别只有一个进/线程进行写、读操作。所以,可以得出结论:两个指针修改时均无需任何锁。这里重点讲一下,P1修改可用空间指针、C1读取可用空间指针为何无需加锁。指针为64位无符号长整型(8字节),读写这种64位长整型数据时,只要64位不跨CacheLine,那就是原子操作。原子性意味着,P1修改64位(8字节)数据时若改到一半,C1不能读取。重点在于有个指针能让C1不会读一半新一半旧,所以无需锁。我再问个问题,P1的取数据指针修改后,是否要加内存屏障?IT知识刺客有一系列文章详细阐述内存屏障。HPC(高性能计算首篇):彻底弄懂并发编程和内存屏障看这一文就够。HPC(高性能计算第一篇):彻底搞懂并发编程与内存屏障(二)。HPC(高性能计算第一篇):彻底弄懂并发编程与内存屏障(完结篇)。这个系列也可以去看看。基础软件开发新坑:神秘MESI与坑爹的LockFree(一)基础软件开发新坑:神秘MESI与坑爹的LockFree(二)基础软件开发新坑:神秘MESI与坑爹的LockFree(三)我直接给出答案,无需内存屏障。不知这个结果是否出乎你的意料。图3右侧,P1修改可用空间指针后添加内存屏障,其作用是在P1进行后续内存访问前,使可用空间指针全局化,即将其新值写入全局可见区(L3或者内存)。在完成此全局化之前,会阻止P1进程后续的访存指令执行(不影响其他指令执行)。内存屏障不会专门将可用空间指针新值传给C1进/线程所在Core。内存屏障仅仅是P1进/线程所在Core自身的一种行为。若在可用空间指针全局化之前,P1继续进行后续内存访问可能产生问题的话,就要添加内存屏障,否则不必添加。结合图3来看,图3右侧,P1修改可用空间指针后,若C1没看到该指针新值,P1继续后面的内存操作,是否会存在问题?没问题的。P1之后的内存操作与Data 1再无关联,C1要等到读到可用空间指针的新值时,才开始读取Data 1。因此,在可用空间指针新值实现全局化之前,P1继续进行后续操作是不会有问题的,也就不需要内存屏障了。(判断RingBuffer是否写满的情况需另行分析,这与此处有所不同)还有一个问题,前面提到过,P1往RingBuffer里写入Data 1的完整过程如下:步骤1:P1读取可用空间指针。步骤2:P1将Data 1写入可用空间指针指向的空间。步骤3:P1将可用空间指针修改为指向Data 1的末尾。是否会有这样的情形:步3对可用空间指针的修改率先完成,此时指针已指向Data 1的末尾。C1随即读取到可用空间指针的新值,也就是Data 1的末尾位置。然而,步2中P1向RingBuffer写入Data 1的操作尚未完成。若有这种情况,在P1尚未完成Data 1写入时,C1就开始读Data 1,这肯定会产生问题。这种时候就得看CPU了。在x86 - 64微架构下不会,因为Intel和
AMD都确保了内存写入是有顺序的,也就是Store Store有序。好了,言归正传。当生产者和消费者都仅有一个时,RingBuffer的管理和连锁都是不必要的。并且在x64微架构下,也无需内存屏障。RingBuffer的优势在于简单、高效。在基础软件开发新坑——神秘的MESI和坑爹的LockFree里提到了内存屏障。文中设计了一个程序来测量CPU中Core间的同步延迟,当一个Core修改一处内存时,另一个Core要看到新值需210到220个周期。
测试程序加入内存屏障,延迟不会从200多周期进一步降低。若有兴趣不妨一试,以加深对内存屏障的理解。下面接着探讨更复杂的情形,即生产者和消费者为多个时的情况。先来讲多个生产者、单个消费者这种。这种直接传数据的情况就不再讨论了,它有时也存在优势。还是来讲讲增加了RingBuffer的情况吧。
5所示,现在又多了一个指针:写入数据指针。假设生产者P2向RingBuffer写入Data 1,过程如下:步骤1:设Data 1的长度为Len,对可用空间指针进行修改,使其向下移动Len字节,这是在RingBuffer里分配空间,对应图5右上第二幅小图。步骤2:获取空间后,P2将Data 1写入RingBuffer。可参照图5右上第二幅小图。这里的写入数据指针,显然就是单生产者情况下的可用空间指针,其作用是让C1知晓有新Data写入。相比单生产者时多了分配空间步骤,即先分配空间再写入。有多个生产者,都向一处写会相互覆盖,所以得先分配空间。好,现在看步1到步3,哪一步需要锁?那自然就是步1了。
6所示,P1到Pn这多个生产者进/线程能够同时读取和修改可用空间指针。文章开头不是说不用锁吗,这儿怎么又说要用锁?我所说的锁是CPU层面的,就像x64微架构里的LOCK前缀。在用户层程序方面,实际上不存在锁,只需重试。具体流程如下:步骤1:从P1到Pn,同时读取可用空间指针,假定其为Address_A。步骤2:将P1至Pn分别加上各自要写入数据的长度,从而得到Addrees_1(即P1的新地址)、Address_2(P2的新地址)等等。步骤3:P1至Pn同时执行带有LOCK前缀的比较并交换指令LOCK cmpxchg Address_...,(mem)。就P1而言:对比可用空间指针是否还是Address_A,若是,则将可用空间指针更新为其新地址:Address_1。P2亦是如此。对比可用空间指针是否依旧为Address_A,若是,则将可用空间指针修改为新地址:Address_2。一直到Pn,全都相同。这里CPU必然存在一个仲裁机制。假定经仲裁后,P2的LOCK cmpxchg得以成功执行,那么可用空间指针就会被修改为Address_2。而P1、Pn等其他生产者进/线程的LOCK cmpxchg则会失败(通过判断RAX寄存器的值就能知晓成功与否)。失败了也没什么大不了的。
7所示,P2成功将可用空间指针修改,从而获取了空间,接着向RingBuffer里写入Data 1。P1、Pn继续争抢着修改可用空间指针。CPU层面是有LOCK的。但在用户层程序方面,进/线程未阻塞、未获取空间,只是需要再重试一次罢了。无锁相关内容还可参考这两篇文章。无锁的线程池、内存池、队列,其设计思路是怎样的?C++里无锁数据结构和有锁数据结构比,速度、性能等方面有啥不同?在多线程下该怎么选择?再看RingBuffer,如同开头所说,不存在锁(这里指用户层程序的锁),也没有等待者队列以及保护该队列的锁,如此一来,生产者向消费者传递数据就更为简单、高效,这便是RingBuffer的意义所在。