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
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.