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

时间:2017-01-09 16:30:23

标签: c# multithreading parallel-processing task-parallel-library tpl-dataflow

我正在研究.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. 一旦认为“稳定”
  6. ,就返回阅读状态

    现在假设这一切都是正确的,我的问题"因此:上述状态的读取不是原子的。我没有看到任何阻止读取半写状态的内容(例如,作者线程已更新head但尚未tail。)

    现在我有点意识到,在这样的缓冲区中半写状态不是世界末日 - 在所有headtail指针完全正常之后独立更新/读取,通常在CAS /旋转循环中。

    然而,我并没有真正看到旋转一次然后重读的重点。你是否真的要抓住'在进行单次旋转所需的时间内进行了改变?它试图保护什么'反对?换句话说:如果整个状态读取都是原子的,我不认为该方法可以做任何事情来帮助它,如果不是, 该方法究竟做了什么?

1 个答案:

答案 0 :(得分:2)

您是对的,但请注意,GetHeadTailPositions中的输出值稍后会用作ToListCountGetEnumerator中的快照。

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

Stephen Toub在ConcurrentQueue<T> holding on to a few dequeued elements中发表了关于此事的博客:

  

无论好坏,.NET 4中的这种行为实际上是“按设计”。其原因与枚举语义有关。 ConcurrentQueue&LT; T&GT;为枚举提供“快照语义”,意味着您开始枚举的瞬间,ConcurrentQueue&lt; T&gt;捕获队列中当前所有内容的当前头部和尾部,即使这些元素在捕获后出列,或者如果新元素在捕获后排队,枚举仍将返回全部且仅返回当时队列中的内容枚举开始了。如果细分中的元素在出列时被删除,那将影响这些枚举的准确性。

     

对于.NET 4.5,我们改变了设计,以达到我们认为的良好平衡。除非出现并发枚举,否则现在将出列的元素排除,除非发生并发枚举,在这种情况下,元素不会被清除,并且会显示与.NET 4中相同的行为。因此,如果您从不枚举ConcurrentQueue&lt; T&gt;,则队列将导致队列立即丢弃其对出队元素的引用。只有当发出出队时,有人恰好在列举队列(即在队列中调用了GetEnumerator并且没有遍历整个枚举器或者已经处理掉它),否则不会发生无效;与.NET 4一样,此时引用将保留,直到删除包含的段。

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