3

我一直在寻找的.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

从我对线程安全的理解中,这个操作很容易发生数据竞争。我将解释我的理解,然后我认为是'错误'。当然,我认为这在我的心理模型中比在图书馆中更可能是一个错误,我希望这里有人能指出我出错的地方。

...

所有给定的字段(headtailhead.Lowtail.High)是挥发性。在我的理解这给出了两个保证:

  • 每次所有四个字段被读取,就必须读取顺序
  • 编译器可能没有的Elid任何的读取和CLR/JIT必须采取措施为了防止这些值

的“高速缓存”从我读给定方​​法中,发生以下情况:

  1. ConcurrentQueue的内部状态的初始读取被执行(THA t是head,tail,head.Lowtail.High)。
  2. 执行单个忙等待旋
  3. 然后,该方法再次,并检查读出的内部状态的任何变化
  4. 如果状态已改变,则转到步骤2,重复
  5. 返回读取状态一旦它被认为是'稳定的'

现在假设全部正确,我的“问题”是这样的:上述状态的读取不是原子的。我没有看到阻止半写入状态的读取(例如,写入器线程已更新head但尚未tail)。

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

但是,我真的不知道什么时候旋转一下然后再读一遍。你真的要在一次旋转的时间里“捕捉”正在进行的改变吗?它试图“防范”什么?换句话说:如果整个状态读取的目的是为原子的,我不认为该方法做了什么帮助,如果没有,那么究竟是什么该方法在做什么?

回答

2

你是对的,但请注意GetHeadTailPositions的输出值在ToList,CountGetEnumerator之后被用作快照。

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

斯蒂芬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(或ToArray其使用ToList)或TryPeek可能引起的引用被保持均匀去除物品后。不可否认,TryDequeue(其中称为ConcurrentQueue<T>.Segment.TryRemove)和TryPeek之间的竞争条件可能很难挑起,但它在那里。

+0

那么最终呢,这个方法的“双重检查”基本上没有意义么?或者我误解了? – Xenoprimate

+2

可能需要的唯一检查就是'_index',它可以避免在'Segment._next'链中返回不指向'_tail'的'_head'。我说*可能*,因为在'_head'的易失性读取之后,不应该可以观察到具有'_tail' **的易失性读取,因为在不改变Segment._next '链,并在最后加上严格增加的指数。其他检查有一定程度的稳定性(请参阅while语句前的注释)。 – acelent