Runtime: [Question] BoundedChannel(Reader/Writer).Try(Read/Write) are kinda allocatey

Created on 31 May 2018  路  2Comments  路  Source: dotnet/runtime

I have been playing around with System.Threading.Channels and have found it to be fantastic.

However when I was profiling I found that TryRead/TryWrite are allocating Tasks. (unless I'm reading this profiler report incorrectly.)

Since these are synchronous APIs I found this surprising.

Are the TryRead/TryWrite methods expected to allocate Tasks?

@stephentoub

I apologize if @-tting is not appropriate.

screenshot_3

area-System.Threading.Channels question tenet-performance

Most helpful comment

have found it to be fantastic.

Excellent.

Are the TryRead/TryWrite methods expected to allocate Tasks?

It depends on the situation.

If you're just using these methods and not the async ones, or if even if you're using the async ones as well but never try to ReadAsync/WaitToReadAsync from an empty channel or WriteAsync/WaitToWriteAsync to a full channel, then no, TryRead and TryWrite should never allocate.

However, if you also use the async methods like ReadAsync, WaitForReadAsync, etc., then you may get allocations from TryWrite and TryRead. Let's say you've used ReadAsync and the channel is empty. ReadAsync returns a ValueTask<int> that'll be completed in the future and you await that ValueTask<int>. Now you've essentially got a callback that needs to be invoked when data is written to the channel and transferred to the waiting reader, which means that TryWrite needs to invoke that continuation in one way shape or form. It effectively has two options: invoke it asynchronously, which involves queueing something to invoke it, or invoke it synchronously. The default behavior is to invoke it asynchronously, which involves allocating a task. You can see here, for example:
https://github.com/dotnet/corefx/blob/28121c077ec06124c2cec82e10704c9f2a05977c/src/System.Threading.Channels/src/System/Threading/Channels/BoundedChannel.cs#L384
where TryWrite has been called and there's a "blocked reader" (a ReadAsync that couldn't be satisfied by data already in the channel), and if you follow the call chain, you end up here:
https://github.com/dotnet/corefx/blob/28121c077ec06124c2cec82e10704c9f2a05977c/src/System.Threading.Channels/src/System/Threading/Channels/AsyncOperation.cs#L388-L393
so it's creating a task to invoke the continuation of that ValueTask.

Note that I said "by default" above. You can pass a BoundedChannelOptions instance to the CreateBounded method, and one of the properties on BoundedChannelOptions is AllowSynchronousContinuations. By default that's false. But if you set it to true, you're giving the channel permission to invoke continuations synchronously instead of asynchronously. In the current implementation, if you opt-in, the current implementation will use synchronous completions for both reading and writing in the unbounded channels, and for reading in the bounded channel. That means if you set AllowSynchronousContinuations to true, and use ReadAsync on an empty channel, and await that ReadAsync's returned ValueTask, and then TryWrite, you shouldn't see TryWrite allocate/queue a task, as TryWrite will instead just invoke the continuation synchronously. Bounded channel doesn't currently enable synchronous continuations for completing waiting writers purely as an implementation detail (that's why the property is worded the way it is, to give the implementation the flexibility), so if it's important to you, you're welcome to submit a PR to try to enable it; the trickiness is you need to make sure you're not invoking arbitrary code at points when you shouldn't, like when holding a lock.

All 2 comments

have found it to be fantastic.

Excellent.

Are the TryRead/TryWrite methods expected to allocate Tasks?

It depends on the situation.

If you're just using these methods and not the async ones, or if even if you're using the async ones as well but never try to ReadAsync/WaitToReadAsync from an empty channel or WriteAsync/WaitToWriteAsync to a full channel, then no, TryRead and TryWrite should never allocate.

However, if you also use the async methods like ReadAsync, WaitForReadAsync, etc., then you may get allocations from TryWrite and TryRead. Let's say you've used ReadAsync and the channel is empty. ReadAsync returns a ValueTask<int> that'll be completed in the future and you await that ValueTask<int>. Now you've essentially got a callback that needs to be invoked when data is written to the channel and transferred to the waiting reader, which means that TryWrite needs to invoke that continuation in one way shape or form. It effectively has two options: invoke it asynchronously, which involves queueing something to invoke it, or invoke it synchronously. The default behavior is to invoke it asynchronously, which involves allocating a task. You can see here, for example:
https://github.com/dotnet/corefx/blob/28121c077ec06124c2cec82e10704c9f2a05977c/src/System.Threading.Channels/src/System/Threading/Channels/BoundedChannel.cs#L384
where TryWrite has been called and there's a "blocked reader" (a ReadAsync that couldn't be satisfied by data already in the channel), and if you follow the call chain, you end up here:
https://github.com/dotnet/corefx/blob/28121c077ec06124c2cec82e10704c9f2a05977c/src/System.Threading.Channels/src/System/Threading/Channels/AsyncOperation.cs#L388-L393
so it's creating a task to invoke the continuation of that ValueTask.

Note that I said "by default" above. You can pass a BoundedChannelOptions instance to the CreateBounded method, and one of the properties on BoundedChannelOptions is AllowSynchronousContinuations. By default that's false. But if you set it to true, you're giving the channel permission to invoke continuations synchronously instead of asynchronously. In the current implementation, if you opt-in, the current implementation will use synchronous completions for both reading and writing in the unbounded channels, and for reading in the bounded channel. That means if you set AllowSynchronousContinuations to true, and use ReadAsync on an empty channel, and await that ReadAsync's returned ValueTask, and then TryWrite, you shouldn't see TryWrite allocate/queue a task, as TryWrite will instead just invoke the continuation synchronously. Bounded channel doesn't currently enable synchronous continuations for completing waiting writers purely as an implementation detail (that's why the property is worded the way it is, to give the implementation the flexibility), so if it's important to you, you're welcome to submit a PR to try to enable it; the trickiness is you need to make sure you're not invoking arbitrary code at points when you shouldn't, like when holding a lock.

This is an amazing answer. I thought I understood how awaiting worked, but now I'm not as sure.

I'll study the implementation some more.

Thank you for your time.

Was this page helpful?
0 / 5 - 0 ratings