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

时间:2016-10-27 09:29:04

标签: c# .net thread-safety lock-free reference-counting

作为无锁代码的第一次动手冒险(我直到现在才读到它),我想尝试为IDisposable构建一个无锁的引用计数包装器类。

这是实际的无锁嵌套类:

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();
    }
}

它是一个私有嵌套类,因此我可以确保仅为以下目的调用方法:

  • 用于创建第一个强引用的构造函数
  • RegisterShare用于复制强引用
  • UnregisterShare用于发布强引用
  • TryRegisterShare用于将弱引用推广为强引用

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

我不相信处理参考文献分享的外部课程对此很重要,但如果有兴趣的话,你可以在这里找到它: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();
        }
    }
}

1 个答案:

答案 0 :(得分:1)

警告:我不知道C#,但我确实知道使用std :: atomic和atomic stuff in x86 asm的C ++,并且你的代码(以及函数/方法名称)看起来很可读/清晰所以我认为我理解发生了什么。

您正在实施与C++11's std::shared_ptr类似的内容,因此您可以查看它的实现以获取灵感。 (此类类似于后端控制块,用于分隔共享同一指针的shared_ptr个对象。)

如果两个线程都运行UnregisterShare并将引用计数降低到零以下,那么它们都会尝试.Dispose()。这是一个错误,类似于双重免费或双重解锁。您可以通过将代码更改为==而不是<=来检查它或将其打包,因此只有一个线程运行.Dispose()<=看起来像是两个世界中最糟糕的:可能很难识别的不良行为。

  TryRegisterShare中的

,除非所有强引用都被释放,否则prevvalue的第一个赋值保证大于零?

除非在参考时未能调用RegisterShare,或者发布错误的错误,否则我认为是这样。在那里使用if(prevValue <= 0) return false是明智的,以确保你在双重释放的情况下拯救,因为某些事情导致引用数量为负。

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

  • 主题A-&gt;取消共享
  • 主题B-&gt; TryShare(检测到失败,但暂时离开refcount = 1)
  • 主题C-&gt; TryShare成功
  • 主题A-&gt; Dispose

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

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

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

所以我认为在某个地方需要有一个cmpxchg循环,但是如果我的逻辑是正确的,你可以放弃将它放在UnShare的refcount-drops-zero-zero路径中,而不是在可能更热的{{{ 1}}。

TryRegisterShare看起来很安全,如果你可以绝对保证引用数量不是事先为零。在已经有ref的线程中应该是这种情况。对于错误检查,您可以检查增量的返回值,以捕获您不小心复活死对象的情况(即,另一个线程即将(或可能已经)已处置。

在C ++中,如果多个线程正在读取和写入相同的RegisterShare,则可能会违反此假设。但是,这需要shared_ptr才能安全,that won't compile因为std::atomic<std::shared_ptr<int>> foo;并非易于构建。

因此,C ++保护自己免受对参考包装器对象的未锁定并发访问(通过声明未定义行为);你也应该。否则,另一个引用此线程正在复制的shared_ptr对象的线程可能会在其上调用shared_ptr,因此在此线程读取指向控制块的指针后,可能会减少控制块中的refcount ,但之前此线程会增加控制块引用计数。

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

UnShare中的减量需要在.reset()执行任何操作之前全局可见,并且需要为release operation以确保加载/存储到共享对象之前在全局可见之前变为全局可见引用。

您的代码已经实现了这一点,因为.Dispose()在任何体系结构(不仅仅是Windows)上都具有.NET中的顺序一致性语义,因此没有任何内容可以在任何方向上重新排序。

AlexRP's blog post about the .NET memory model 详细说明了相关内容(联锁,Interlocked.anything / Thread.VolatileReadWrite / Volatile.Read ,宽度为Write的类型上的简单加载或存储是自动原子的(但不是同步的)。)有趣的事实:在Microsoft的x86实现中,IntPtr.Size和写入是由MFENCE指令在任何一方包围,但语言标准只需要获取或释放语义(x86在没有障碍的情况下免费提供)。 Mono并没有为这些功能使用障碍,但MS无法删除它们,直到每个人的错误代码停止依赖于该实现细节。

除了减量之外,我认为其他一切只需要原子性来进行重新计数,而不是任何其他操作的同步。引用计数不会从多个线程同步对共享对象的访问,因此TryShare / UnShare对不会形成关键部分(需要获取/释放语义)。从构造函数到导致Thread.VolatileRead的最终UnShare,此类在同步时是不干涉的。在C ++中,我认为您可以使用.Dispose()作为RegisterShare增量和TryShare cmpxchg。在一些弱有序的架构上,比如ARM,这将需要更少的障碍。 (这在C#中没有选择:你只有顺序一致性的互锁操作,这需要ARM的完全障碍。但是,x86上没有额外的成本,因为memory_order_relaxed读取了x86上的修改写入指令已经是完整的内存障碍,如MFENCE(lock))