在1991年发表的论文《 Scalable Reader-Writer Synchronization for 共享内存多处理器”(John M. Mellor-Crummey和Michael L.Scott)提供了一种基于票证锁定算法(https://www.cs.rochester.edu/u/scott/papers/1991_PPoPP_read_write.pdf)的公平RW锁定。
在伪代码中,它看起来像这样(http://www.cs.rochester.edu/research/synchronization/pseudocode/rw.html):
type counter = unsigned integer
// layout of counter
// 31 ... 16 15 ... 0
// +------------------------------+
// | reader count | writer count |
// +------------------------------+
const RC_INCR = 0x10000 // to adjust reader count
const WC_INCR = 0x1 // to adjust writer count
const W_MASK = 0xffff // to extract writer count
// mask bit for top of each count
const WC_TOPMSK = 0x8000
const RC_TOPMSK = 0x80000000
type lock = record
requests : counter := 0
completions : counter := 0
procedure start_write (L : ^lock)
counter prev_processes :=
fetch_and_clear_then_add (&L->requests, WC_TOPMSK, WC_INCR)
repeat until completions = prev_processes
procedure start_read (L : ^lock)
counter prev_writers :=
fetch_and_clear_then_add (&L->requests, RC_TOPMSK, RC_INCR) & W_MASK
repeat until (completions & W_MASK) = prev_writers
procedure end_write (L: ^lock)
clear_then_add (&L->completions, WC_TOPMSK, WC_INCR)
procedure end_read (L : ^lock)
clear_then_add (&L->completions, RC_TOPMSK, RC_INCR)
它使用fetch_and_clear_then_add和clear_then_add原语 没有在现代硬件中实现。显然有人可以效仿他们 使用CAS-loop软件。
但是我想到使用这些的理由 原语(防止溢出)来自于 使读者和作家的计数器分开且精确。但是因为 直觉可能是错误的。我相信溢出预防逻辑 可以报废而不会破坏底层算法。
我试图用Google搜索类似结果,但至今未发现任何结果。也许 我丢失了一些东西。
因此,让我更详细地说明我的想法。我认为标准的fetch_add 指令在此算法下也可以正常工作。
阅读器计数包含在单词的上半部分。如果 fetch_add(0x10000)导致溢出,多余的位将静默 从单词边界掉下来。没问题。
作家数量位于世界的下半部分。如果
fetch_add(1)导致溢出,多余的位将溢出到
读者人数。哎呀。 2 ^ 16名作家中有1名冒犯了我们
规则,这会破坏读者人数。但是,等等,这真的有问题吗?
之后我们拥有什么?让我们来看看。在requests
一词中,
过渡之后:
(0xYYYY,0xFFFF)->(0xYYYY + 1,0x0000)// 0xYYYY代表未知的读者数,0xFFFF代表溢出前的作家数
与此同时,completions
单词包含的值小于或
等于( 0xYYYY, 0xFFFF )
。然后“罪犯”线程开始轮询
completions
字直到它等于所需值为止。在它之前开始的任何读者或作家都不会在completions
字词中产生任何令人困惑的值。当他们前进并解锁时
始终会达到所需的值。之后,“罪犯”
线程开始。
另一方面,以后到达的任何新线程都看不到 令人困惑的价值。作家增加下半部分 单词,读者增加上半部分。他们仍然相互命令。并且直到“罪犯”线程完成工作并解锁后才能继续。
由“犯罪者”完成的解锁完全补偿了其犯罪。它
将导致completions
字中的作者数出现对称溢出,并向其中的读者数溢出1。因此,下一个到达的线程将完全按计划启动,因为它一直在期望“计算错误”的值。整个过程将顺利进行。
因此,最终结果是,在2 ^ 16个写锁中,读锁计数将偏移1。
一个简单的非正式例子就是 根本没有读取器锁。在这种情况下,带有标准fetch_add()指令的RW锁的行为将完全类似于普通的票证锁。读者数量每2 ^ 16个作家锁将稳定增长1。但 这是我们在头脑中所做的区分。在机器级别,这是 仅增加2 ^ 32个字。这没有区别 用普通的票证锁定。
现在将读者添加到图片中。我什么也想不出来 安排到达读者的时间,仅增加 这个词,可以破坏整个画面。从一个角度来看 读者的到来似乎使作家突然来了2 ^ 16 作家。从现在起,它仍然可以用作普通的票锁 观点。
现在,从读者的角度来看,情况有所不同。他们看 仅按作者数计算,不计较任何溢出 这个词的上半部。当读者到达时,它会记住 最后到来的作家,并抵制任何即将到来的作家 2 ^ 16增量。然后,它会一直等到记忆中的作者完成并 继续自己的工作。完成后,它又变得巨大了 2 ^ 16增量,因此任何可能等待的毫无怀疑和天真的作家 可以得出结论,这是一堆2 ^ 16个并发作者 现在完成。
如果我的推理有误,请纠正我。
C ++实现如下:
class shared_ticket_lock : non_copyable
{
public:
void lock() noexcept
{
base_type tail = tail_.fetch_add(exclusive_step, std::memory_order_relaxed);
for (;;) {
base_type head = head_.load(std::memory_order_acquire);
if (tail == head)
break;
}
}
void unlock() noexcept
{
base_type head = head_.load(std::memory_order_relaxed);
head_.store(head + exclusive_step, std::memory_order_release);
}
void lock_shared() noexcept
{
base_type tail = tail_.fetch_add(shared_step, std::memory_order_relaxed);
for (tail &= exclusive_mask;;) {
base_type head = head_.load(std::memory_order_acquire);
if (tail == (head & exclusive_mask))
break;
}
}
void unlock_shared() noexcept
{
head_.fetch_add(shared_step, std::memory_order_release);
}
private:
using base_type = std::uint32_t;
static constexpr base_type shared_step = 1 << 16;
static constexpr base_type exclusive_mask = shared_step - 1;
static constexpr base_type exclusive_step = 1;
std::atomic<base_type> head_ = ATOMIC_VAR_INIT(0);
std::atomic<base_type> tail_ = ATOMIC_VAR_INIT(0);
};
注意:原始论文指出,可以使用普通的fetch_add实现公平的RW锁定,但是代码会稍微复杂一些。目前尚不清楚作者的意思。我猜有些不同(例如用单独的单词维护读者和作家的计数器),因为据我所知,这绝对不复杂。
更新:可以在这里找到工作代码和简单测试: