.NET TPL Dataflow中的线程安全问题

4

我出于好奇查看了.NET TPL的“Dataflow”库的某些部分的实现,我发现了以下代码片段:

    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
据我所了解的线程安全,这个操作容易出现数据竞争。我将解释我的理解,然后是我认为的“错误”。当然,我觉得更可能是我的思维模型有误而不是库本身存在问题,希望这里的某个人能指出我哪里错了。
...
所有给定的字段(headtailhead.Lowtail.High)都是易失性的。在我看来,这提供了两个保证:
  • 每次读取所有四个字段时,它们必须按顺序读取
  • 编译器不能省略任何一次读取,CLR / JIT必须采取措施防止值被“缓存”
根据我对给定方法的理解,以下情况会发生:
  1. 执行ConcurrentQueue的内部状态的初始读取(即headtailhead.Lowtail.High)。
  2. 进行单个忙等待旋转
  3. 然后再次读取内部状态并检查是否有任何更改
  4. 如果状态已更改,请返回第2步并重复
  5. 在被认为是“稳定”的情况下返回读取状态
现在假设这一切都是正确的,我的“问题”是:上面状态的读取不是原子的。我没有看到任何对读取半写状态(例如写线程已更新head但尚未更新tail)的限制。
现在我有些意识到,在这样的缓冲区中,半写状态并不是世界末日——毕竟,headtail指针在CAS /自旋循环中通常可以独立更新/读取。
但是,我真的想知道旋转一次然后再次读取的目的是什么?你真的能在进行单次旋转的时间内“捕获”正在发生的更改吗?它试图“防护”什么?换句话说:如果整个状态读取需要是原子的,我认为该方法没有任何帮助,如果不是,那么该方法到底是在做什么?
1个回答

2

您说的没错,但需要注意的是,GetHeadTailPositions 的输出值稍后在 ToListCountGetEnumerator 中被用作快照。

更令人担忧的是,并发队列可能会无限期地持有值。当私有字段ConcurrentQueue<T>._numSnapshotTakers不为零时,它会防止将条目置为空或对于值类型将其设置为默认值。

Stephen Toub 在“ConcurrentQueue<T> holding on to a few dequeued elements”中写到:

不管好坏,.NET 4 中的这种行为实际上是“按设计”进行的。原因与枚举语义有关。ConcurrentQueue<T> 为枚举提供了“快照语义”,这意味着在你开始枚举的瞬间,ConcurrentQueue<T> 就会捕捉当前队列中头部和尾部的内容,即使这些元素在捕捉后被出队或者新元素在其之后入队,枚举仍将返回仅仅当时在队列中的所有元素。如果删除段中的元素,则会影响这些枚举的真实性。

对于 .NET 4.5,我们改变了设计,以达到我们认为是一个很好的平衡。出列的元素现在会被清空,除非有并发枚举正在进行。在此情况下,该元素不会被清空,将展示与 .NET 4 相同的行为。因此,如果您从未枚举过 ConcurrentQueue<T>,则出列将导致队列立即放弃对出列元素的引用。只有在执行出列时,有人刚好在枚举队列(即已对队列调用了 GetEnumerator,并且还没有遍历枚举器或处理掉它)时,清零操作不会发生;就像 .NET 4 一样,在那时,引用将保留,直到包含该段的部分被删除。

从源代码中可以看出,获取枚举器(通过通用的GetEnumerator<T>或非通用的GetEnumerator),调用ToList(或使用ToListToArray)或TryPeek可能会导致即使删除项目后仍保留引用。诚然,在TryDequeue(调用ConcurrentQueue<T>.Segment.TryRemove)和TryPeek之间的竞争条件可能很难引起,但它确实存在。


那么,最终来说,这个方法的“双重检查”基本上是没有意义的吗?还是我误解了? - Xenoprimate
2
唯一可能需要的检查是 _index,它避免了在 Segment._next 链中返回一个不指向 _tail_head。我说“可能”,因为在读取 _head 后读取 _tail 的 volatile 读取时,不应该能够观察到这一点,因为段从开头删除而不更改 Segment._next 链,并且以严格递增的索引添加到末尾。其他检查是为了某种稳定性措施(请参见 while 语句之前的注释)。 - acelent

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接