这个无锁引用计数包装器是否需要额外的栅栏或挥发物?

Does this lock-free reference-counting wrapper need additional fences or volatiles?

作为无锁代码的第一次实践冒险(到目前为止我只读过它),我想尝试为 IDisposable 构建一个无锁引用计数包装器 classes.

这里是实际的无锁嵌套class:

private sealed class Wrapper
{
    public T WrappedObject { get; private set; }
    private int refCount;

    public Wrapper(T objectToWrap)
    {
        WrappedObject = objectToWrap;
        refCount = 1;
    }

    public void RegisterShare()
    {
        Interlocked.Increment(ref refCount);
    }
    public bool TryRegisterShare()
    {
        int prevValue;
        do {
            prevValue = refCount;
            if (prevValue == 0)
                return false;
        } while (prevValue != Interlocked.CompareExchange(ref refCount, prevValue + 1, prevValue));
        return true;
    }
    public void UnregisterShare()
    {
        if (Interlocked.Decrement(ref refCount) <= 0)
            WrappedObject.Dispose();
    }
}

这是一个私有嵌套 class,因此我可以确保仅出于以下目的调用这些方法:

我相信我已经有了基本的想法,我只是不确定这是否真的是线程安全的。我想到一个问题:在 TryRegisterShare 中,除非释放所有强引用,否则对 prevValue 的第一个赋值是否保证大于零?我需要一些围栏或挥发物吗?

我认为处理引用共享的外部 class 对此并不重要,但如果有人感兴趣,您可以在这里找到它:https://codepaste.net/zs7nbh


更新:

这是考虑到@PeterCordes 不得不说的内容的修改后的代码。

private sealed class Wrapper
{
    public T WrappedObject { get; private set; }
    private int refCount;

    public Wrapper(T objectToWrap)
    {
        WrappedObject = objectToWrap;
        refCount = 1;
    }

    public void RegisterShare()
    {
        Interlocked.Increment(ref refCount);
    }
    public bool TryRegisterShare()
    {
        return Interlocked.Increment(ref refCount) > 0;
    }
    public void UnregisterShare()
    {
        if (Interlocked.Decrement(ref refCount) == 0
            && Interlocked.CompareExchange(ref refCount, int.MinValue, 0) == 0)
        {
            WrappedObject.Dispose();
        }
    }
}

警告:我不懂 C#,但我知道 C++ std::atomic 和 atomic stuff in x86 asm,而且你的代码(和 function/method 名称)看起来很漂亮 readable/clear 所以我想我明白发生了什么。

您正在实现类似于 C++11's std::shared_ptr 的东西,因此您可以查看它的实现以获取灵感。 (这个 class 就像 back-end 控制块,它分隔 shared_ptr object 共享相同的指针引用。)


如果两个线程都 运行 UnregisterShare 并将引用计数降到零以下,它们都会尝试 .Dispose()。这是一个错误,类似于 double-free 或 double-unlocking。您可以检查它,或者通过将代码更改为 == 而不是 <= 来掩盖它,因此只有一个线程线程 运行s .Dispose()<= 看起来是两全其美:难以识别的不当行为。


in TryRegisterShare, is the first assignment to prevValue guaranteed to be larger than zero unless all strong references were released?

除非出现引用时无法调用 RegisterShare 之类的错误,或者 double-release 错误,是的,我认为是这样。在这里使用 if(prevValue <= 0) return false 是明智的,以确保您在 double-release 某些情况下使引用计数为负数。

一个 cmpxchg 循环看起来并不理想,但如果你无条件递增并且只是检查你是否必须从零开始,那可能会欺骗其他线程。 (例如,这一系列事件:

  • 线程 A->取消共享
  • thread B->TryShare(检测到失败,但暂时保留 refcount=1)
  • 线程 C->TryShare 成功
  • 线程 A->Dispose

我还没有看过 C++11 weak_ptr.lock() 的(Linux / gcc 实现)如何实现对 shared_ptr 的提升(我现在很好奇! ).

我想知道他们是否在循环中(在 asm 中)使用 cmpxchg,或者他们是否做了某种避免这种情况的舞蹈。例如也许 Unshare,在检测到 refcount==0 时,可以使用 cmpxchg 循环将 refcount 从零修改为 -2^31 before 调用 Dispose。 (如果它在该循环中检测到 refcount >=,它就会停止尝试杀死它。)然后我认为只要它看到 Interlocked.Increment(ref refCount) >= 1,TryShare 就会成功,因为这意味着任何 运行ning UnShare 的 cmpxchg还没有成功,也不会成功。

对于某些 use-cases,在这种情况下可能不希望 TryShare 成功。如果它的增量发现旧值是零,你可以让它调用 Unshare 再次减少计数。

所以我认为某处需要一个 cmpxchg 循环,但如果我的逻辑是正确的,您可以将它放在 UnShare 的 refcount-drops-to-zero 路径而不是 presumably-hotter 路径中TryRegisterShare.


RegisterShare 如果您可以绝对保证事先引用计数不为零,那么看起来是安全的。在已经有 ref 的线程中应该是这种情况。对于 bug-checking,您可以检查增量的 return 值以捕获您不小心使死的 object 复活的情况(即另一个线程即将(或可能已经)处理.

在 C++ 中,如果多个线程正在读取和写入同一个 shared_ptr,则可能会违反此假设。不过,它需要一个 std::atomic<std::shared_ptr<int>> foo; 才能安全,而 that won't compile 因为 shared_ptr 不是很容易构造的。

因此 C++ 保护自己免受未锁定的并发访问引用包装器 objects(通过声明它未定义的行为);你也应该否则,另一个线程引用了同一个 shared_ptr object 这个线程正在复制的线程可能已经调用了 .reset() ,因此可能会在这个线程读取指针后减少控制块中的引用计数到控制块,但是在之前这个线程增加了control-block引用计数。


我刚刚注意到标题问题是关于需要额外的栅栏或挥发物:

UnShare 中的减量需要在 .Dispose() 执行任何操作之前变得全局可见,并且需要 release operation 以确保 loads/stores 进入共享 object 在引用计数减少之前变得全局可见。

您的代码已经实现了这一点,因为 Interlocked.anything 在 .NET 中的任何体系结构(不仅在 Windows 上)具有 sequential-consistency 语义,因此无法在任何方向上对其进行重新排序.

AlexRP's blog post about the .NET memory model 解释了很多关于这个相关事物的细节(互锁,Thread.VolatileRead/WriteVolatile.Read/ Write,并且宽度不超过 IntPtr.Size 的类型的简单加载或存储是自动原子的(但不是同步的)。有趣的事实:在 Microsoft 的 x86 实现中,Thread.VolatileRead 和 Write两边都被 MFENCE 指令包围,但是语言标准只需要获取或释放语义(x86 是免费的,没有障碍)。 Mono 不对这些功能使用障碍,但 MS 无法删除它们,直到每个人的错误代码不再依赖该实现细节。


除了递减,我认为其他一切都只需要你的 ref-counting 的原子性,而不是任何其他操作的同步。 ref-counting 不同步对 s 的访问来自多个线程的 object,因此 TryShare/UnShare 对不构成关键部分(需要 acquire/release 语义)。从构造函数到导致 .Dispose() 的最终 UnShare,就同步而言,此 class 是 hands-off。在 C++ 中,我认为您可以将 memory_order_relaxed 用于 RegisterShare 增量和 TryShare cmpxchg。在某些 weakly-ordered 架构上,例如 ARM,这需要更少的障碍。 (这在 C# 中不是一个选项:您只有 sequential-consistency 互锁操作,这需要 ARM 上的完全屏障。不过,在 x86 上没有额外成本,因为在 x86 上 locked read-modify-write 指令已经像 MFENCE (Thread.MemoryBarrier))

这样的完整内存屏障