c# – .NET TPL数据流源中的线程安全

出于好奇,我正在查看.NET TPL的“数据流”库的某些部分的实现,我遇到了以下片段:

    private void GetHeadTailPositions(out Segment head, out Segment tail,
        out int headLow, out int tailHigh)
    {
        head = _head;
        tail = _tail;
        headLow = head.Low;
        tailHigh = tail.High;
        SpinWait spin = new SpinWait();

        //we loop until the observed values are stable and sensible.  
        //This ensures that any update order by other methods can be tolerated.
        while (
            //if head and tail changed, retry
            head != _head || tail != _tail
            //if low and high pointers, retry
            || headLow != head.Low || tailHigh != tail.High
            //if head jumps ahead of tail because of concurrent grow and dequeue, retry
            || head._index > tail._index)
        {
            spin.SpinOnce();
            head = _head;
            tail = _tail;
            headLow = head.Low;
            tailHigh = tail.High;
        }
    }

(可在此查看:https://github.com/dotnet/corefx/blob/master/src/System.Threading.Tasks.Dataflow/src/Internal/ConcurrentQueue.cs#L345)

根据我对线程安全的理解,这个操作很容易发生数据竞争.我将解释我的理解,然后我认为是’错误’.当然,我希望我的心理模型中的错误更可能是库中的错误,我希望有人可以指出我出错的地方.

所有给定的字段(头部,尾部,头部.低和尾部.高)都是不稳定的.根据我的理解,这给出了两个保证:

>每次读取所有四个字段时,必须按顺序读取它们
>编译器可能不会忽略任何读取,并且CLR / JIT必须采取措施来防止值的“缓存”

从我读到的给定方法,发生以下情况:

>首先读取ConcurrentQueue的内部状态(即head,tail,head.Low和tail.High).
>执行单个忙等待旋转
>然后,该方法再次读取内部状态并检查是否有任何更改
>如果状态已更改,请转到步骤2,然后重复
>一旦被认为是“稳定”,就返回读状态

现在假设这一切都是正确的,我的“问题”就是:上面的状态读取不是原子的.我没有看到任何阻止读取半写状态的内容(例如,编写器线程已更新头但尚未更新).

现在我有点意识到像这样的缓冲区中的半写状态不是世界末日 – 所有的头尾指针完全可以独立更新/读取,通常在CAS /自旋循环中.

但是后来我真的没有看到旋转一次然后重读的重点是什么.你是否真的要在一次旋转中“抓住”进行中的改变?什么是“防范”?换句话说:如果整个状态读取都是原子的,我不认为该方法可以做任何事情来帮助它,如果没有,那么该方法究竟做了什么?

最佳答案 你是对的,但请注意,GetHeadTailPositions中的out值稍后将用作ToList,Count和GetEnumerator中的快照.

更令人担心的是并发队列might hold on to values indefinitely.当私有字段ConcurrentQueue< T> ._ numSnapshotTakers不为零时,它会阻止对条目进行归零或将它们设置为值类型的默认值.

Stephen Toub在ConcurrentQueue<T> holding on to a few dequeued elements年发表了这篇博文:

For better or worse, this behavior in .NET 4 is actually “by design.” The reason for this has to do with enumeration semantics. ConcurrentQueue<T> provides “snapshot semantics” for enumeration, meaning that the instant you start enumerating, ConcurrentQueue<T> captures the current head and tail of what’s currently in the queue, and even if those elements are dequeued after the capture or if new elements are enqueued after the capture, the enumeration will still return all of and only what was in the queue at the time the enumeration began. If elements in the segments were to be nulled out when they were dequeued, that would impact the veracity of these enumerations.

For .NET 4.5, we’ve changed the design to strike what we believe to be a good balance. Dequeued elements are now nulled out as they’re dequeued, unless there’s a concurrent enumeration happening, in which case the element isn’t nulled out and the same behavior as in .NET 4 is exhibited. So, if you never enumerate your ConcurrentQueue<T>, dequeues will result in the queue immediately dropping its reference to the dequeued element. Only if when the dequeue is issued someone happens to be enumerating the queue (i.e. having called GetEnumerator on the queue and not having traversed the enumerator or disposed of it yet) will the null’ing out not happen; as with .NET 4, at that point the reference will remain until the containing segment is removed.

从源代码中可以看出,获取枚举器(通过通用GetEnumerator< T>或非泛型GetEnumerator),调用ToList(或使用ToList的ToArray)或TryPeek可能导致在删除项目后仍保留引用.不可否认,TryDequeue(调用ConcurrentQueue< T> .Segment.TryRemove)和TryPeek之间的竞争条件可能很难激起,但它就在那里.

点赞