5

只是在业余时间玩并发,并想尝试在不使用读取器端的锁的情况下防止撕裂读取,因此并发读取器不会相互干扰。

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

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 的理解和应用正确吗?是否有任何障碍是不必要的?

4

2 回答 2

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
    }
}

此处添加的调用可确保++version并且this.value = value不会被交换。现在,ECMA 规范在技术上允许这种指令重新排序。但是,Microsoft 的 CLI 和 x86 硬件的实现都已经在写入时具有易失语义,因此在大多数情况下并不真正需要它。但是,谁知道呢,在以 ARM cpu 为目标的 Mono 运行时中,这可能是必要的。

Read事情的一边,我找不到任何错误。事实上,你的电话的位置正是我应该放置的位置。有些人可能想知道为什么在第一次阅读version. 原因是因为外部循环会捕获第一次读取由于Thread.MemoryBarrier进一步向下而被缓存的情况。

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

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

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

于 2013-10-30T02:24:47.657 回答
-1

看来您对无锁/无等待实现很感兴趣。让我们从这个讨论开始,例如: 无锁多线程是为真正的线程专家准备的

于 2013-09-21T18:37:01.963 回答