C#/ CLR:MemoryBarrier和撕裂的读取

naa*_*ing 5 c# clr concurrency memory-barriers concurrent-programming

只是在我的业余时间玩弄并发,并希望尝试防止撕裂读取而不使用读取器端的锁定,因此并发读取器不会相互干扰.

想法是通过锁序列化写入,但在读取端只使用内存屏障.这是一个可重用的抽象,它封装了我提出的方法:

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
        }
    }
}
Run Code Online (Sandbox Code Playgroud)

不要担心版本变量溢出,我避免另一种方式.那么我对Thread.MemoryBarrier的理解和应用在上面是否正确?有没有不必要的障碍?

Bri*_*eon 3

我仔细查看了你的代码,它对我来说确实是正确的。我立即注意到的一件事是,您使用了一种既定的模式来执行低锁定操作。我可以看到你正在使用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
    }
}
Run Code Online (Sandbox Code Playgroud)

此处添加的调用可确保++versionthis.value = value不会被交换。现在,ECMA 规范在技术上允许这种指令重新排序。然而,Microsoft 的 CLI 实现和 x86 硬件都已经具有可变的写入语义,因此在大多数情况下实际上并不需要它。但是,谁知道呢,也许在针对 ARM cpu 的 Mono 运行时上有必要。

Read事情而言,我挑不出任何毛病。事实上,您拨打的电话的位置正是我本来应该放置的位置。有些人可能想知道为什么在初次阅读version. 原因是因为外层循环会捕获第一次读取由于Thread.MemoryBarrier进一步向下而被缓存时的情况。

这让我开始讨论性能。这真的比在方法中采用硬锁更快吗Read?好吧,我对您的代码进行了一些相当广泛的测试来帮助回答这个问题。答案是肯定的!这比采用硬锁要快得多。我测试使用 aGuid作为值类型,因为它是 128 位,因此比我的机器的本机字大小(64 位)大。我还对作者和读者的数量使用了几种不同的变体。您的低锁定技术始终显着优于硬锁定技术。我什至尝试了一些变体Interlocked.CompareExchange来进行保护读取,它们也都比较慢。事实上,在某些情况下,它实际上比采用硬锁要慢。我必须诚实。我对此一点也不感到惊讶。

我还做了一些非常重要的有效性测试。我创建了会运行相当长一段时间的测试,而且我一次都没有看到过破损的读物。然后作为控制测试,我会调整该Read方法,让我知道它是不正确的,然后再次运行测试。这一次,正如所料,撕裂的读物开始随机出现。我把代码改回你所拥有的,撕裂的读数消失了;再次,正如预期的那样。这似乎证实了我已经预料到的事情。也就是说,您的代码看起来是正确的。我没有各种各样的运行时和硬件环境来测试(也没有时间),所以我不愿意给予它 100% 的批准,但我确实认为我可以给你的实现竖起两个大拇指目前。

最后,尽管如此,我仍然会避免将其投入生产。是的,这可能是正确的,但是下一个必须维护代码的人可能不会理解它。有人可能会更改代码并破坏它,因为他们不了解更改的后果。你必须承认这段代码非常脆弱。即使是最轻微的改变也可能会破坏它。