Home > OS >  Which is the fastest way to tell if a Channel<T> is empty?
Which is the fastest way to tell if a Channel<T> is empty?

Time:07-18

I am consuming a Channel<object> in an await foreach loop, and on each iteration I want to know if the channel is empty. Channels don't have an IsEmpty property, so I am seeing two ways to get this information: the Count property and the TryPeek method:

await foreach (var item in channel.Reader.ReadAllAsync())
{
    ProcessItem(item);
    if (channel.Reader.Count == 0) DoSomething();
}
await foreach (var item in channel.Reader.ReadAllAsync())
{
    ProcessItem(item);
    if (!channel.Reader.TryPeek(out _)) DoSomething();
}

My question is: Which is the most performant way to get the IsEmpty information? Is it one of the above, or is it something else?

I should mention that my channel is unbounded, but I am open to switching to a bounded channel (Channel.CreateBounded<object>(Int32.MaxValue)) in case this would be beneficial performance-wise.

My unbounded channel is configured with the SingleReader option equal to false (the default value).

Another detail that might be important: most of the time the check for emptiness will be negative. The producer of the channel tends to write hundreds of items in frequent bursts. The DoSomething method triggers such a burst.

CodePudding user response:

The only real option is TryPeek because it's the only method available in all three channel flavors, Bounded, Single-Reader Unbounded and multi-reader Unbounded. Single-Reader unbounded channels don't have a Count implementation.

Where available, Count delegates to the underlying collection's count (bounded, unbounded. In a Bounded channel it's a Dequeue while in an Unbounded channel it's a ConcurrentQueue.

The ConcurrentQueue.Count is very expensive that has to calculate the available items on the fly and the cheap IsEmpty method actually calls TryPeek.

public bool IsEmpty =>
    // IsEmpty == !TryPeek. We use a "resultUsed:false" peek in order to avoid marking
    // segments as preserved for observation, making IsEmpty a cheaper way than either
    // TryPeek(out T) or Count == 0 to check whether any elements are in the queue.
    !TryPeek(out _, resultUsed: false);

For bounded channels, Count returns the Dequeue's Count which is stored in a field. The extra locks around this add a bit of overhead, although nothing like ConcurrentQueue's Count. Locking delays will depend on actual usage. Multiple readers will result in more locking.

public override int Count
{
    get
    {
        BoundedChannel<T> parent = _parent;
        lock (parent.SyncObj)
        {
            parent.AssertInvariants();
            return parent._items.Count;
        }
    }
}

In conclusion, TryPeek is the only option available in all channels and the fastest option in unbounded channels. In bounded channels Count may be faster depending on actual usage.

CodePudding user response:

I measured the performance of both approaches, with both unbounded and bounded channels, using a home-made benchmark. Essentially I filled a channel with 1,000 elements, and retrieved the IsEmpty information 20,000,000 times in a loop. Here are the results:

Platform: .NET 6.0.0-rtm.21522.10
Unbounded.Reader.Count: 1,000
Bounded.Reader.Count:   1,000
LoopsCount:             20,000,000

Unbounded-Count    Duration: 706 msec
Unbounded-TryPeek  Duration: 360 msec
Bounded-Count      Duration: 470 msec
Bounded-TryPeek    Duration: 506 msec

Online demo.

So it seems that for my unbounded channel the .TryPeek(out _) is the faster approach. There is no need to switch to a bounded channel. The performance is pretty good regardless though.

It should be noted that in my particular use case, it is possible to obtain the IsEmpty information essentially for free, by switching from the await foreach loop to a nested while loop like this:

while (await channel.Reader.WaitToReadAsync())
{
    while (channel.Reader.TryRead(out var item))
    {
        ProcessItem(item);
    }
    DoSomething();
}

Each time the inner while loop completes, the channel is temporarily empty.

The ChannelReader<T>.ReadAllAsync method is implemented internally with a similar nested while loop. So I shouldn't lose anything by replicating the same pattern.

  • Related