C#/CLR:内存屏障和撕裂的读取
本文关键字:撕裂 读取 CLR 内存 | 更新日期: 2023-09-27 18:32:12
只是在业余时间玩弄并发性,并希望尝试在不使用读取器侧锁的情况下防止读取中断,这样并发读取器就不会相互干扰。
这个想法是通过锁序列化写入,但在读取端只使用内存屏障。这是一个可重用的抽象,它封装了我想出的方法:
public struct Sync<T>
where T : struct
{
object write;
T value;
int version; // incremented with each write
public static Sync<T> Create()
{
return new Sync<T> { write = new object() };
}
public T Read()
{
// if version after read == version before read, no concurrent write
T x;
int old;
do
{
// loop until version number is even = no write in progress
do
{
old = version;
if (0 == (old & 0x01)) break;
Thread.MemoryBarrier();
} while (true);
x = value;
// barrier ensures read of 'version' avoids cached value
Thread.MemoryBarrier();
} while (version != old);
return x;
}
public void Write(T value)
{
// locks are full barriers
lock (write)
{
++version; // ++version odd: write in progress
this.value = value;
// ensure writes complete before last increment
Thread.MemoryBarrier();
++version; // ++version even: write complete
}
}
}
不要担心版本变量上的溢出,我以另一种方式避免这种情况。那么,我在上述内容中对Thread.MemoryBarrier的理解和应用是否正确?是否有任何不必要的障碍?
研究了你的代码,它对我来说确实是正确的。立即跳出的一件事是,您使用了已建立的模式来执行低锁定操作。我可以看到您正在使用version
作为一种虚拟锁。偶数被释放,奇数被获取。而且,由于您对虚拟锁使用单调递增的值,因此您也避免了ABA问题。但是,最重要的是,在尝试读取时继续循环,直到观察到虚拟锁定值在读取开始之前与完成之后相同。否则,您将此视为读取失败,然后重试。所以是的,在核心逻辑上做得很好。
那么内存屏障发生器的放置呢?嗯,这一切看起来也很好。所有Thread.MemoryBarrier
调用都是必需的。如果我必须吹毛求疵,我会说您需要在Write
方法中增加一个,以便它看起来像这样。
public void Write(T value)
{
// locks are full barriers
lock (write)
{
++version; // ++version odd: write in progress
Thread.MemoryBarrier();
this.value = value;
Thread.MemoryBarrier();
++version; // ++version even: write complete
}
}
此处添加的调用可确保++version
和this.value = value
不会被交换。现在,ECMA规范在技术上允许这种指令重新排序。但是,Microsoft的 CLI 实现和 x86 硬件在写入时都已经具有易失性语义,因此在大多数情况下实际上并不需要它。但是,谁知道呢,也许有必要在针对 ARM CPU 的 Mono 运行时上这样做。
在事情Read
方面,我找不到任何缺点。事实上,您拨打的电话正是我会放置它们的位置。有些人可能想知道为什么在初次阅读version
之前您不需要一个。原因是,由于Thread.MemoryBarrier
更靠后,外部循环将捕获第一次读取被缓存的情况。
因此,这让我想到了关于性能的讨论。这真的比在Read
方法中进行硬锁更快吗?好吧,我对您的代码进行了一些非常广泛的测试来帮助回答这个问题。答案是肯定的!这比硬锁要快得多。我使用 Guid
作为值类型进行了测试,因为它是 128 位,因此比我的机器的本机字大小(64 位)大。我还对作家和读者的数量使用了几种不同的变体。您的低锁定技术始终且明显优于硬锁定技术。我什至尝试了一些使用Interlocked.CompareExchange
进行保护阅读的变化,它们的速度也都较慢。事实上,在某些情况下,它实际上比硬锁慢。我必须说实话。我对此一点也不感到惊讶。
我还做了一些非常重要的有效性测试。我创建了可以运行相当长一段时间的测试,但没有一次看到撕裂的读数。然后作为对照测试,我会调整Read
方法,我知道它会不正确,然后我再次运行测试。这一次,正如预期的那样,撕裂的读数开始随机出现。我将代码切换回您拥有的代码,撕裂的读取消失了;再次,正如预期的那样。这似乎证实了我已经预料到的。也就是说,您的代码看起来正确。我没有各种各样的运行时和硬件环境可供测试(也没有时间),所以我不愿意给它 100% 的批准,但我认为我可以给你的实现竖起两个大拇指。
最后,尽管如此,我仍然会避免将其投入生产。是的,这可能是正确的,但是下一个必须维护代码的人可能不会理解它。有人可能会更改代码并破坏它,因为他们不了解更改的后果。你必须承认这段代码非常脆弱。即使是最轻微的变化也可能打破它。
您似乎对无锁/无等待实现感兴趣。让我们从这个讨论开始,例如:无锁多线程适用于真正的线程专家