之前程序中有如下代码:

private ConcurrentQueue<Tuple<DateTime, object>> recordq = new ConcurrentQueue<Tuple<DateTime, object>>();
private void Add2LogDetail(string symbol, object data)
{
    if (config.LogDic.Contains(symbol))
    {
        recordq.Enqueue(new Tuple<DateTime, object>(DateTime.Now, data));
        if (recordq.Count > 10000)
        {
            recordq.TryDequeue(out Tuple<DateTime, object> tmp);
            Logger.LogError("记录队列满,丢弃一笔:");
        }
    }
}

这段代码的主要记录指定股票的行情数据,将接收到的行情暂存在一个ConcurrentQueue里面,然后另外一个线程会从该队列里面获取数据然后持久化到本地磁盘。为了防止写线程出现异常而导致缓存队列被写爆,从而导致OOM,这里限制了recordq缓存的最大数据量为10000。这就涉及到需要频繁调用ConcurrentQueue的Count方法。然而这个Count方法的算法复杂度可能需要仔细研究一下。

在普通的在普通的非线程安全集合(如 List<T> 或 Queue<T>)中,Count 通常只是简单地返回一个内部维护的 int 字段,时间复杂度是严格的 O(1)。然而,当在并发集合中,为了追求极致的高吞吐量,设计的取舍可能会发生变化。本文通过分析 ConcurrentQueue<T> 的源码,分析ConcurrentQueue的Count实现,并尝试回答为什么它不使用简单的 Interlocked 计数。

ConcurrentQueue的内部结构


要理解Count,首先要理解ConcurrentQueue的数据结构,ConcurrentQueue不是一个简单的单链表,他是一个由环形数组组成了链表,它的内部是由多个Segment组成,每个Segment是一个固定大小的数组。

  • 当队列不断Enqueue时,如果当前的Segment满了,就会分配一个新的Segment并链接到队尾。‘
  • 当不断Dequeue时,如果一个Segment空了,就会被垃圾回收。

这种设计是为了减少垃圾回收的压力,并利用数组的CPU缓存友好型。

Count的源码分析


在 ConcurrentQueue<T> 的.NET Framework版本的源码中.NET Core中的实现有些不一样),Count 属性的实现如下:

/// <summary>
/// Gets the number of elements contained in the <see cref="ConcurrentQueue{T}"/>.
/// </summary>
/// <value>The number of elements contained in the <see cref="ConcurrentQueue{T}"/>.</value>
/// <remarks>
/// For determining whether the collection contains any items, use of the <see cref="IsEmpty"/>
/// property is recommended rather than retrieving the number of items from the <see cref="Count"/>
/// property and comparing it to 0.
/// </remarks>
public int Count
{
    get
    {
        //store head and tail positions in buffer, 
        Segment head, tail;
        int headLow, tailHigh;
        GetHeadTailPositions(out head, out tail, out headLow, out tailHigh);

        if (head == tail)
        {
            return tailHigh - headLow + 1;
        }

        //head segment
        int count = SEGMENT_SIZE - headLow;

        //middle segment(s), if any, are full.
        //We don't deal with overflow to be consistent with the behavior of generic types in CLR.
        count += SEGMENT_SIZE * ((int)(tail.m_index - head.m_index - 1));

        //tail segment
        count += tailHigh + 1;

        return count;
    }
}

这里面重点在GetHeadTailPositions,获取一个一致的快照,得到相关的头部和尾部segment以及headLow和tailHigh。

/// <summary>
/// Store the position of the current head and tail positions.
/// </summary>
/// <param name="head">return the head segment</param>
/// <param name="tail">return the tail segment</param>
/// <param name="headLow">return the head offset, value range [0, SEGMENT_SIZE]</param>
/// <param name="tailHigh">return the tail offset, value range [-1, SEGMENT_SIZE-1]</param>
private void GetHeadTailPositions(out Segment head, out Segment tail,
    out int headLow, out int tailHigh)
{
    head = m_head;
    tail = m_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 != m_head || tail != m_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.m_index > tail.m_index)
    {
        spin.SpinOnce();
        head = m_head;
        tail = m_tail;
        headLow = head.Low;
        tailHigh = tail.High;
    }
}

可以看到,这里面为了保证并发情况下的准确性,GetHeadTailPositions 方法内部会自旋重试,以确保在读取首尾位置时,队列没有发生剧烈的结构性变化。因为队列是一个动态延伸的链表(Segment 链)。如果没有锁,假如读取了 Head 的位置(比如索引 10),正准备去读 Tail 的位置时,极高并发下可能瞬间发生了 10000 次入队和 10000 次出队。这时再去读 Tail,拿到的可能是一个极其离谱的数值,两者一减,Count 甚至可能变成负数或超大错误值。 为了在不加锁的情况下获取“一致”的首尾快照,.NET 底层使用了无锁编程中经典的“读取-验证”循环(乐观并发控制),即重新读取一次各个变量,看它们有没有发生变化。 如果都没有发生变化,说明在读取尾部的这极短时间内, 队列没有发生“追尾”或者严重的段移动,这个快照是相对一致的,可以接受。否则自旋然后重试。

在极高并发的入队/出队场景下,GetHeadTailPositions中的while循环的验证可能会持续失败,导致这个 while 循环不断自旋(消耗 CPU)。这就是为什么 Count 在高并发下不仅慢,还会浪费 CPU 资源的核心原因。

从源码可以看出,如果队列并发程度很低,那么Count的复杂度是 O(1) 的。但如果并发程度很高,Count内部调用的GetHeadTailPositions 方法会自旋重试,以确保在读取首尾位置时,队列没有发生剧烈的结构性变化,这意味着在极高并发下,调用 Count 会导致性能损耗。

为什么要这样实现?


有一个很简单的办法来实现Count,那就是在非并发集合中常见的那样,内部维护一个变量count,入队时 Interlocked.Increment,出队时 Interlocked.Decrement,Count 马上就能变成完美的 O(1)。ConcurrentQueue中的Count之所以没这么实现,我猜想可能有如下原因:

  1. 缓存失效:现代 CPU 都有 L1/L2 缓存。当一个线程修改了一个变量(比如那个全局的 int count),CPU 必须通过缓存一致性协议(如 MESI)广播一条消息,使得其他所有核心中缓存了该变量的“缓存行(Cache Line)”全部失效。
  2. 串行化:ConcurrentQueue 的核心卖点是无锁(Lock-free)和高并发。它巧妙地利用了分离的头尾指针,使得出队和入队操作几乎互不干扰。如果引入了一个全局的 count 变量,那么所有正在执行入队和出队的线程,都会在物理硬件层面为了争夺这同一个 count 变量所在的缓存行而发生冲突。这实质上把本该并行执行的操作,在硬件级别变成了串行操作。

ConcurrentQueue的设计目的优先考虑的是告诉收发数据,而不是用来计数的,所以为了保证Enqueue和Dequeue的吞吐,牺牲了Count的性能。

一种实现方法


如果实在要获得ConcurrentQueue的数量,可以在外部定义一个int,然后使用Interlocked来原子递增和递减来实现计数:

ConcurrentQueue<int> _queue = new();
int _fastCount = 0;

public void Enqueue(int item)
{
    _queue.Enqueue(item);
    Interlocked.Increment(ref _fastCount);
}

public bool TryDequeue(out int item)
{
    if (_queue.TryDequeue(out item))
    {
        Interlocked.Decrement(ref _fastCount);
        return true;
    }
    return false;
}

优缺点也很明显,优点在于读取 _fastCount 是严格的 O(1) 操作,且不涉及任何遍历。如果你有一个监控线程每秒要读取几万次 Count,这种写法能避免底层 Segment 遍历的开销。缺点就是如前所述,在入队和出队时,都要访问共同的_fastCount对象,另外 Count 和内部的队列状态无法保持强一致性,因为操作_queue和操作_fastCount不是一个原子操作,比如在执行_queue.Enqueue后,正准备执行Interlock.Increment时,系统被挂起,此时队列里有元素,但_fastCount仍然是0。另外,在代码所有的地方,只要有操作Enqueue和Dequeue,都需要同步进行_fastCount的变更。

因此只有在需要频繁访问Count的情况下,这种做法才更好。

总结


在使用集合时,如果要判断是否为空,使用IsEmpty或者Any()方法,在大多数集合中,是否为空是一个极其快速的O(1)操作,以ConcurrentQueue为例,他只需要检查头尾指针的状态。绝对不要写if (queue.Count>0)这样的代码。另外,不是所有的集合的Count方法的时间复杂度都是相同的,主要要考虑的是集合内部实现的数据结构。最后,在并发系统中,由于时差的存在,当拿到Count的那一刻,队列的真实数量肯呢个已经发生改变,所以Count只能用作日志记录、监控指标或负载均衡的参考值。