目录
无锁环形缓冲区设计原理
阅读linux源码首先从理解原理开始,linux文档已经整理的很好了,直接查看源码目录里的Documentation/trace/ring-buffer-design.txt,以下提供本人的翻译和解释。
本文中使用的术语
tail - 在环形缓冲区中发生新写入的位置。
head - 在环形缓冲区中发生新读取的位置。
producer(生产者) - 写入环形缓冲区的任务(与writer相同)。
writer(写者) - 与producer 相同。
consumer(消费者) - 从缓冲区读取的任务(与writer相同)。
reader (读者)- 与consumer相同。
reader_page - 一个(大部分)由读者提供的在环形缓冲区外使用的页面。
head_page - 指向读者下一步将使用的页面的指针。
tail_page - 指向将被写入的下一页的页面的指针。
commit_page - 指向最后一次完成非嵌套写入的页面的指针。
cmpxchg - 硬件支持的原子操作,执行以下操作:
(如果 A == C ,A = B)
R = cmpxchg(A,C,B)//是指当且仅当现在的A等于C,将B写入A,然后把老的(现在)的A写入R。R获取的是之前的A的值,而不管A是否更新为B。要查看更新是否成功,可以使用R==C比较。(注:也就是说不管A的值是什么,R的值是原来A的值,判断R==C为true,说明A成功赋上B的值了,R==C为false说明失败了)
通用环形缓冲区概述
-----------------------
环形缓冲区可以在覆盖模式下使用,也可以在生产者/消费者模式下使用。
生产者/消费者模式,是指生产者填满缓冲区的时候,消费者消费任何东西之前,生产者就会停止写缓冲区。这将丢失最近的事件。
覆盖模式,是指生产者填满缓冲区的时候,消费者消费任何东西之前,生产者将覆盖旧的数据。这将丢失最早的事件。
两个写入者(writers)不能同时写入(在相同的per-cpu缓冲区上),但是一个写入者可以打断另一个写入者,但他必须在前一个写入者可以继续写入之前完成写入。这对算法来说非常重要。写入者的行为就像一个栈。中断的方式会强制执行这种行为。
writer1 start
<preempted> writer2 start
<preempted> writer3 start
writer3 finishes
writer2 finishes
writer1 finishes
这非常像是一个写入者被一个中断抢占了,并且这个中断也在进行写操作。
读者可以随时读。但是并没有两个读者同时在运行,同时一个读者也不能抢占/中断另一个读者。读者无法抢占/中断写入程序,它可以从缓冲区读出/消费的同时写者写入,但读者必须在另一个处理器上执行此操作。
写者可以抢占读者,但读者不能抢占写者。读者可以读取缓冲区,同时在另一个处理器上作为写者。
环形缓冲区由一个链表保存的页面组成。
初始化时,为读者分配的页面不是环形缓冲区的一部分。
head_page, tail_page 和commit_page页都初始化指向同一个页面。
读者页面被初始化时,next指针指向head page,同时它的previous指针指向head page的前一页。
读者有自己的页面可供使用。在启动时,此页面就是已分配好的,只是没有加到列表中。当读者想要要从缓冲区读取,如果其页面为空(如启动时),它将与head page交换页面。老的读者页面将成为环形缓冲区的一部分,head_page将被移出。插入的页面(老的reader_page)将成为新的head page。
一旦新页面交给读者,只要一个写者离开了那一个页面读者可以做任何处理。
一个如何交换读者页面的示例:注意,这并不是真实的在缓冲区中的head page,仅仅是演示:
+------+
|reader| RING BUFFER
|page |
+------+
+---+ +---+ +---+
| |-->| |-->| |
| |<--| |<--| |
+---+ +---+ +---+
^ | ^ |
| +-------------+ |
+-----------------+
+------+
|reader| RING BUFFER
|page |-------------------+
+------+ v
| +---+ +---+ +---+
| | |-->| |-->| |
| | |<--| |<--| |<-+
| +---+ +---+ +---+ |
| ^ | ^ | |
| | +-------------+ | |
| +-----------------+ |
+------------------------------------+
+------+
|reader| RING BUFFER
|page |-------------------+
+------+ <---------------+ v
| ^ +---+ +---+ +---+
| | | |-->| |-->| |
| | | | | |<--| |<-+
| | +---+ +---+ +---+ |
| | | ^ | |
| | +-------------+ | |
| +-----------------------------+ |
+------------------------------------+
+------+
|buffer| RING BUFFER
|page |-------------------+
+------+ <---------------+ v
| ^ +---+ +---+ +---+
| | | | | |-->| |
| | New | | | |<--| |<-+
| | Reader +---+ +---+ +---+ |
| | page ----^ | |
| | | |
| +-----------------------------+ |
+------------------------------------+
交换的页面可能是commit page和tail page,如果在环缓冲区中保存的内容小于在buffer page中保存的内容。
reader page commit page tail page
| | |
v | |
+---+ | |
| |<----------+ |
| |<------------------------+
| |------+
+---+ |
|
v
+---+ +---+ +---+ +---+
<---| |--->| |--->| |--->| |--->
--->| |<---| |<---| |<---| |<---
+---+ +---+ +---+ +---+
这种情况对该算法仍然有效。
当写入者离开这个页面时,它(页面)只是进入环形缓冲区,因为读者页面仍然指向环形缓冲区的下一个位置。
主要指针
reader page - 仅由读者使用的页面,不是 环形缓冲区的组成部分(可被换入)。
head page - 环形缓冲区中要和读者交换的下一个页面。
tail page - 下一次写入的页面。
commit page - 上次完成写入的页面。
commit page仅由writer stack的最上面那个写者更新。一个写者抢占其他写者不会移动commit page。
当数据写入环形缓冲区时,会在环形缓冲区中保留(reserved)一个位置,并传回给写者。当写者将数据写入该位置完成后,它将提交这次写入。
在此次操作期间的任何时候都可能发生另一次写入(或读取)。如果发生了另一次写操作,这个新操作必须先完成,然后继续前一次写操作。
Write reserve:
Buffer page
+---------+
|written |
+---------+ <--- given back to writer (current commit)
|reserved |
+---------+ <--- tail pointer
| empty |
+---------+
Write commit:
Buffer page
+---------+
|written |
+---------+
|written |
+---------+ <--- next position for write (current commit)
| empty |
+---------+
If a write happens after the first reserve:
Buffer page
+---------+
|written |
+---------+ <-- current commit
|reserved |
+---------+ <--- given back to second writer
|reserved |
+---------+ <--- tail pointer
After second writer commits:
Buffer page
+---------+
|written |
+---------+ <--(last full commit)
|reserved |
+---------+
|pending |
|commit |
+---------+ <--- tail pointer
When the first writer commits:
Buffer page
+---------+
|written |
+---------+
|written |
+---------+
|written |
+---------+ <--(last full commit and tail pointer)
在不抢占另一次写入的情况下,commit指针指向最后一个被提交的写入的位置。当一个写者抢占了另一个已经提交的写者,则它只会成为pending commit(挂起的提交)而不是一个full commit(完整的提交)直到所有的写入都被提交完成。
commit page指向最后一个full commit的页面。tail page 指向最后的写入页面(在提交前的)。
tail page始终等于或在commit page之后。它可能就在几个页面前。如果tail page赶上commit page页,则不会再进行写入(无论哪种模式的环形缓冲区:覆盖和生产/消费者)。
页的顺序为:
head page
commit page
tail page
可能的情况:
tail page
head page commit page |
| | |
v v v
+---+ +---+ +---+ +---+
<---| |--->| |--->| |--->| |--->
--->| |<---| |<---| |<---| |<---
+---+ +---+ +---+ +---+
有一种特殊情况,就是head page在commit page之后,并且可能在tail page之后。那种情况是当commit (和tail) page已经被reader page交换的时候。这是因为head page总是是环形缓冲区的一部分,但reader page不是。每当环缓冲区中已提交的页面少于一个满页面,并且一个读者换出一个页面时,它将会换出commit page。
reader page commit page tail page
| | |
v | |
+---+ | |
| |<----------+ |
| |<------------------------+
| |------+
+---+ |
|
v
+---+ +---+ +---+ +---+
<---| |--->| |--->| |--->| |--->
--->| |<---| |<---| |<---| |<---
+---+ +---+ +---+ +---+
^
|
head page
在这种情况下,当tail和commit移回到环形缓冲区时,head page不会移动。
如果commit page仍然指在这个页面上,则读者无法将页面交换到环形缓冲区中。如果读取满足last commit(实际提交没有pending 或者 reserved),那么就没有什么可读的了。在另一次full commit完成之前,缓冲区被视为空的。
当tail与head页相遇时,如果缓冲区处于覆盖模式,head page将向前推一页。如果缓冲区位于生产者/消费者中模式下,写入将失败。
覆盖模式:
tail page
|
v
+---+ +---+ +---+ +---+
<---| |--->| |--->| |--->| |--->
--->| |<---| |<---| |<---| |<---
+---+ +---+ +---+ +---+
^
|
head page
tail page
|
v
+---+ +---+ +---+ +---+
<---| |--->| |--->| |--->| |--->
--->| |<---| |<---| |<---| |<---
+---+ +---+ +---+ +---+
^
|
head page
tail page
|
v
+---+ +---+ +---+ +---+
<---| |--->| |--->| |--->| |--->
--->| |<---| |<---| |<---| |<---
+---+ +---+ +---+ +---+
^
|
head page
注意,reader page仍将指向上一个head page。但当交换发生时,它将使用最新的head page。
使环形缓冲器lockless(无锁)
--------------------------------
无锁算法背后的主要思想是将移动head_page指针与读者交换页面结合起来。状态标志放置在指向页面的指针内。为此,每个页面必须在内存中保持4个字节对齐。这将允许地址的2个最低有效位要用作标志,因为它们在这个地址里始终为零。要得到这个地址,只需mask掩码这个标志位。
MASK=~3
address & MASK
这两个位将保存两个标志:
HEADER - 被指向的页面是head page。
UPDATE - 被指向的页面是一个被写者更新过的,并且现在是或即将变成一个head page。
reader page
|
v
+---+
| |------+
+---+ |
|
v
+---+ +---+ +---+ +---+
<---| |--->| |-H->| |--->| |--->
--->| |<---| |<---| |<---| |<---
+---+ +---+ +---+ +---+
上面的指针“-H->”将设置成HEADER的flag。标志的下一个页面就是读者要换出的下一个页。这个指针表示下一个页面是head page。
当tail page遇到head指针时,它将使用cmpxchg更改指向UPDATE状态的指针:
tail page
|
v
+---+ +---+ +---+ +---+
<---| |--->| |-H->| |--->| |--->
--->| |<---| |<---| |<---| |<---
+---+ +---+ +---+ +---+
tail page
|
v
+---+ +---+ +---+ +---+
<---| |--->| |-U->| |--->| |--->
--->| |<---| |<---| |<---| |<---
+---+ +---+ +---+ +---+
“-U->”表示处于UPDATE 状态的指针。
对读者的任何访问都需要使用某种锁来序列化读者。但读者永远不会拿着锁去写环形缓冲器。这意味着我们只需要担心一个读者,并写入仅抢占以“堆栈”形式。
当读者尝试用环形缓冲区交换页面时,它也将会使用cmpxchg。如果指针中的flag标志位指向
没有设置HEADER flag的head page,比较将失败并且读者需要寻找新的head page,然后重试。
注意,UPDATE和HEADER的flag标志不要同时设置。
读者交换读者页面,如下所示:
+------+
|reader| RING BUFFER
|page |
+------+
+---+ +---+ +---+
| |--->| |--->| |
| |<---| |<---| |
+---+ +---+ +---+
^ | ^ |
| +---------------+ |
+-----H-------------+
读者将读者页面next指针作为HEADER设置到head page后面的page。
+------+
|reader| RING BUFFER
|page |-------H-----------+
+------+ v
| +---+ +---+ +---+
| | |--->| |--->| |
| | |<---| |<---| |<-+
| +---+ +---+ +---+ |
| ^ | ^ | |
| | +---------------+ | |
| +-----H-------------+ |
+--------------------------------------+
它使用指向上一个head page的指针执行cmpxchg以使其指向读者页面。请注意,新指针没有HEADER flag的设置。此操作会自动向前移动head page。
+------+
|reader| RING BUFFER
|page |-------H-----------+
+------+ v
| ^ +---+ +---+ +---+
| | | |-->| |-->| |
| | | |<--| |<--| |<-+
| | +---+ +---+ +---+ |
| | | ^ | |
| | +-------------+ | |
| +-----------------------------+ |
+------------------------------------+
设置新的head page后,head page的previous 指针将被更新到读者页面。
+------+
|reader| RING BUFFER
|page |-------H-----------+
+------+ <---------------+ v
| ^ +---+ +---+ +---+
| | | |-->| |-->| |
| | | | | |<--| |<-+
| | +---+ +---+ +---+ |
| | | ^ | |
| | +-------------+ | |
| +-----------------------------+ |
+------------------------------------+
+------+
|buffer| RING BUFFER
|page |-------H-----------+ <--- New head page
+------+ <---------------+ v
| ^ +---+ +---+ +---+
| | | | | |-->| |
| | New | | | |<--| |<-+
| | Reader +---+ +---+ +---+ |
| | page ----^ | |
| | | |
| +-----------------------------+ |
+------------------------------------+
另一个要点:读者页面通过其previous 指针(现在指向新head page的指针)指向的页面永远不要指向读者页面。这是因为读者页面不是环缓冲区的一部分。通过next指针遍历环形缓冲区将始终停留在环形缓冲区中。通过previous 指针遍历环形缓冲区则可能不是。
注意,确定读者页面的方法只需检查前页面的previous 指针。如果上一页的next指针没有指向原始页面,那么原始页面就是读者页面:
+--------+
| reader | next +----+
| page |-------->| |<====== (buffer page)
+--------+ +----+
| | ^
| v | next
prev | +----+
+------------->| |
+----+
head page向前移动的方式:
当tail page与head page相遇且缓冲区处于覆盖模式时,并且如果发生更多的写入,那么在写者可能移动tail page之前head page必须被移动。方法是写者执行cmpxchg转换head page指针,从HEADER flag到UPDATE flag的设置。一旦设置完成,读者将无法从缓冲区交换head page,也无法移动head page,直到写者完成移动。
这就消除了读者对写者的任何竞争。读者必须自旋,这就是为什么读者不能先于写者。
tail page
|
v
+---+ +---+ +---+ +---+
<---| |--->| |-H->| |--->| |--->
--->| |<---| |<---| |<---| |<---
+---+ +---+ +---+ +---+
tail page
|
v
+---+ +---+ +---+ +---+
<---| |--->| |-U->| |--->| |--->
--->| |<---| |<---| |<---| |<---
+---+ +---+ +---+ +---+
下一页将成为新的head page。
tail page
|
v
+---+ +---+ +---+ +---+
<---| |--->| |-U->| |-H->| |--->
--->| |<---| |<---| |<---| |<---
+---+ +---+ +---+ +---+
在设置了新的head page之后,我们可以设置旧的head page指针回到NORMAL。
tail page
|
v
+---+ +---+ +---+ +---+
<---| |--->| |--->| |-H->| |--->
--->| |<---| |<---| |<---| |<---
+---+ +---+ +---+ +---+
head page移动后,tail page现在可以向前移动。
tail page
|
v
+---+ +---+ +---+ +---+
<---| |--->| |--->| |-H->| |--->
--->| |<---| |<---| |<---| |<---
+---+ +---+ +---+ +---+
以上是琐碎的更新。现在来看更复杂的场景。
如前所述,如果有足够的写入抢占第一次写入,则tail page可能会环绕缓冲区并与commit
page相遇。此时,我们必须开始删除写操作(通常是向用户发出某种类型的警告)。但是,如果提交仍在reader page中,会发生什么?commit page不是环形缓冲区的一部分。tail page必须对此作出解释。
reader page commit page
| |
v |
+---+ |
| |<----------+
| |
| |------+
+---+ |
|
v
+---+ +---+ +---+ +---+
<---| |--->| |-H->| |--->| |--->
--->| |<---| |<---| |<---| |<---
+---+ +---+ +---+ +---+
^
|
tail page
如果tail page只是将head page向前push(推),那么这个提交在离开读者页面时不会指向正确的页面。
解决方案是测试在push(推) head page之前commit page是否在读者页面上。如果是,则可以假设tail page包裹了缓冲区,我们必须删除新的写操作。
这不是竞态条件(race condition),因为commit page只能由最外层的写者(被抢占的写者)移动。
这意味着当写者正在移动tail page时,这个提交将不会移动。如果读者页面正在被当作commit page使用,那么读者无法交换该读者页面。读者只需检查提交已从读者页面删除。一旦commit page离开读者页面,它将不再返回,除非读者再次与缓冲区页换页,那也是commit page。
嵌套写入
-------------
在推进tail page时,我们必须先向前推进head page(如果head page是下一页)。如果head page不是下一页,tail page只是用cmpxchg更新。
只有写者才能移动tail page。这必须以原子方式进行,以保护嵌套写入。
temp_page = tail_page
next_page = temp_page->next
cmpxchg(tail_page, temp_page, next_page)
如果仍然指向预期的页面,则上面的代码将更新tail page。如果失败,嵌套写入将其向前推,即当前写入不需要push。
temp page
|
v
tail page
|
v
+---+ +---+ +---+ +---+
<---| |--->| |--->| |--->| |--->
--->| |<---| |<---| |<---| |<---
+---+ +---+ +---+ +---+
嵌套写入将进入并向前移动tail page:
tail page (moved by nested writer)
temp page |
| |
v v
+---+ +---+ +---+ +---+
<---| |--->| |--->| |--->| |--->
--->| |<---| |<---| |<---| |<---
+---+ +---+ +---+ +---+
上述操作将使cmpxchg失败,但由于tail page已经向前移动,写者将再次尝试保留存储
在新的tail page上。
但是head page的移动有点复杂。
tail page
|
v
+---+ +---+ +---+ +---+
<---| |--->| |-H->| |--->| |--->
--->| |<---| |<---| |<---| |<---
+---+ +---+ +---+ +---+
写入操作将head page指针转换为UPDATE。
tail page
|
v
+---+ +---+ +---+ +---+
<---| |--->| |-U->| |--->| |--->
--->| |<---| |<---| |<---| |<---
+---+ +---+ +---+ +---+
但是如果嵌套写者抢占了这里,它将看到下一个页面是head page,但也是嵌套的。它将检测到它是嵌套的,并且将保存该信息。它的检测事实上看到的指针是UPDATE flag,而不是HEADER或NORMAL。
嵌套写者将设置新的head page指针。
tail page
|
v
+---+ +---+ +---+ +---+
<---| |--->| |-U->| |-H->| |--->
--->| |<---| |<---| |<---| |<---
+---+ +---+ +---+ +---+
但它不会将更新重置回正常状态。只有写者将指针从HEAD转换为UPDATE才会把它转换回NORMAL。
tail page
|
v
+---+ +---+ +---+ +---+
<---| |--->| |-U->| |-H->| |--->
--->| |<---| |<---| |<---| |<---
+---+ +---+ +---+ +---+
嵌套写者完成后,最外层的写者将转换指针的UPDATE为NORMAL。
tail page
|
v
+---+ +---+ +---+ +---+
<---| |--->| |--->| |-H->| |--->
--->| |<---| |<---| |<---| |<---
+---+ +---+ +---+ +---+
如果有多个嵌套写入进入并移动tail page到几个页面前,则可能会更加复杂:
(first writer)
tail page
|
v
+---+ +---+ +---+ +---+
<---| |--->| |-H->| |--->| |--->
--->| |<---| |<---| |<---| |<---
+---+ +---+ +---+ +---+
写入操作将头页指针转换为UPDATE。
tail page
|
v
+---+ +---+ +---+ +---+
<---| |--->| |-U->| |--->| |--->
--->| |<---| |<---| |<---| |<---
+---+ +---+ +---+ +---+
下一个写者进入,查看更新并设置新的head page。
(second writer)
tail page
|
v
+---+ +---+ +---+ +---+
<---| |--->| |-U->| |-H->| |--->
--->| |<---| |<---| |<---| |<---
+---+ +---+ +---+ +---+
嵌套写者将tail page向前移动。但这并不能改变旧的update page
为NORMAL,因为它不是最外层的写者。
tail page
|
v
+---+ +---+ +---+ +---+
<---| |--->| |-U->| |-H->| |--->
--->| |<---| |<---| |<---| |<---
+---+ +---+ +---+ +---+
另一位写者抢占,并且在tail page之后看到的是head page。它将它从HEAD更改为UPDATE。
(third writer)
tail page
|
v
+---+ +---+ +---+ +---+
<---| |--->| |-U->| |-U->| |--->
--->| |<---| |<---| |<---| |<---
+---+ +---+ +---+ +---+
写者将向前移动head page:
(third writer)
tail page
|
v
+---+ +---+ +---+ +---+
<---| |--->| |-U->| |-U->| |-H->
--->| |<---| |<---| |<---| |<---
+---+ +---+ +---+ +---+
但是现在第三个写者确实更改了HEAD flag变成UPDATE,它将其转换为正常:
(third writer)
tail page
|
v
+---+ +---+ +---+ +---+
<---| |--->| |-U->| |--->| |-H->
--->| |<---| |<---| |<---| |<---
+---+ +---+ +---+ +---+
然后它将移动tail page,并返回给第二个写者。
(second writer)
tail page
|
v
+---+ +---+ +---+ +---+
<---| |--->| |-U->| |--->| |-H->
--->| |<---| |<---| |<---| |<---
+---+ +---+ +---+ +---+
第二个写者将无法移动tail page,因为它已被移动,因此它将重试然后将其数据添加到新的tail page.。
它将返回给第一个写者。
(first writer)
tail page
|
v
+---+ +---+ +---+ +---+
<---| |--->| |-U->| |--->| |-H->
--->| |<---| |<---| |<---| |<---
+---+ +---+ +---+ +---+
当第一个写者更新HEAD page时无法自动知道tail page是否移动。然后,它会将head page更新为它认为的新的head page。
(first writer)
tail page
|
v
+---+ +---+ +---+ +---+
<---| |--->| |-U->| |-H->| |-H->
--->| |<---| |<---| |<---| |<---
+---+ +---+ +---+ +---+
由于cmpxchg返回指针的旧值,因此第一个写者将看到它成功地将指针从NORMAL更新为HEAD。
但正如我们所看到的,这还不够好。它还必须检查是否tail page位于它原来所在的位置或在下一页上:
(first writer)
A B tail page
| | |
v v v
+---+ +---+ +---+ +---+
<---| |--->| |-U->| |-H->| |-H->
--->| |<---| |<---| |<---| |<---
+---+ +---+ +---+ +---+
如果tail page != A 并且tail page != B,那么 它必须重置指针恢复到NORMAL。事实上,它只需要担心嵌套写者,意味着它只需要在设置HEAD page之后检查这个。
(first writer)
A B tail page
| | |
v v v
+---+ +---+ +---+ +---+
<---| |--->| |-U->| |--->| |-H->
--->| |<---| |<---| |<---| |<---
+---+ +---+ +---+ +---+
现在,写者可以更新head page了。这也是为什么head page必须保持UPDATE,并且仅由最外层的写者重置的原因。这防止了读者看到错误的head page。
(first writer)
A B tail page
| | |
v v v
+---+ +---+ +---+ +---+
<---| |--->| |--->| |--->| |-H->
--->| |<---| |<---| |<---| |<---
+---+ +---+ +---+ +---+