EnumeratorCancellation: CancellationToken parameter from the generated IAsyncEnumerable.GetAsyncEnumerator will be unconsumed
Introduction
If you're lucky enough to be using moderately new tech at work, or you just love trying out all the new goodies, you've probably had a chance to play around with IAsyncEnumerable<T>It does not take long until you come across CS8425 compiler warning, specifically if you're using yield and await keywords, and letting compiler do the heavy lifting of generating an implementation for you.
CS8425
Async-iterator member has one or more parameters of type 'CancellationToken' but none of them is decorated with the 'EnumeratorCancellation' attribute, so the cancellation token parameter from the generated 'IAsyncEnumerable<>.GetAsyncEnumerator' will be unconsumed
Async-iterator member has one or more parameters of type 'CancellationToken' but none of them is decorated with the 'EnumeratorCancellation' attribute, so the cancellation token parameter from the generated 'IAsyncEnumerable<>.GetAsyncEnumerator' will be unconsumed
Don't know about you, but I didn't really understand what this warning actually means the first time I saw it. And the second time too. 😁
But hey - as application developers we've authored quite a lot of unreadable error messages ourselves, we have read hundreds of such messages, and we have happily skipped over most of them.
So, being conscious of our estimates/deadlines/time boxes (choose your poison) we just add EnumeratorCancellation attribute, and continue crunching code.
Or not.
Personally, I feel uncomfortable with authoring code that I don't understand, well, at least at some level 😀
So, let's set aside some time and find out what's really going on.
Why read this?
You're interested in this topic, and you want to know more. Perfect!While I am sure you can reach the same conclusions yourself, I believe I can save an hour of our time by doing the basic research for you, and simply presenting the results.
Disclaimer
Technology moves fast, so what is true today, will be false soon enough.
What is described here is valid as of July 2020, .NET Core 3.1.301, C#8
I'm using Microsoft (R) Build Engine version 16.6.0+5ff7b0c9e for .NET Core
I'm using Microsoft (R) Build Engine version 16.6.0+5ff7b0c9e for .NET Core
Assumptions
I assume basic familiarity with IAsyncEnumerable, I won't cover the what and the why here. There's too much blog posts about that already. Let's stay focused and save our time :-)
I won't cover the how of the IAsyncEnumerable itself, as well - see Iterating with Async Enumerables in C# 8
I won't cover the how of the IAsyncEnumerable itself, as well - see Iterating with Async Enumerables in C# 8
Sample code
This post is hands on, so we'll be looking into the code a lot.
Sample code needs to be simple, and focused on the problem at hand: IAsyncEnumerable and EnumeratorCancellation.
This sample code is NOT trying to demonstrate SOLID code, design patterns, testable code, reliable, robust, scalable, modular architecture.
These topics are very important. Period.
These topics are very important. Period.
In order to go deep, though, we need to be razor sharp.
So, in sample code below - the Produce method is responsible for generating IAsyncEnumerable, using yield keyword, while supporting cooperative cancellation. Please notice the methods accepts a CancellationToken. Consume method is simply iterating over the values produced.
Unconsumed, consumed, what's the difference
So, now let's build this code twice: with and without the EnumeratorCancellation attribute, see what the compiler has generated for us, compare, and look for differences.I'm simply building the sample code, and then using a decompiler (dotPeek, or ILSpy, or any other) on the dll.
You can take a look at the changes yourself by looking at revisions on gist, but I will highlight the most important difference here.
Sometimes a picture is worth a thousand words, and if you just had the "Aha!" moment, congrats! You can stop reading. If, not, then please let me explain.
Unconsumed without EnumeratorCancellation
The following assignment:
produceD2.cancellationToken = this.CE3__cancellationToken;basically tells us to use cancellation token passed to the Produce method inside the IAsyncStateMachine and IAsyncEnumerator implementations.
That looks like the desired result, right. So, when is that a problem?
Let's answer this first: who calls the GetAsyncEnumerator and when?
I'm sure you know all about deferred execution in IEnumerable<T>, and IAsyncEnumerable<T> is no different - the producer code is not executed until someone calls a MoveNextAsync on IAsyncEnumerator. That "someone" could be a foreach loop, and a number of LINQ methods.
In our code, the consume method uses await foreach to loop through the sequence - that's where the call to GetAsyncEnumerator will be made.
Cool, so the execution is deferred, and real values have not been produced yet, so we should be able to cancel using code like below:
Enough! What does not work, then?
Let's try an cancel the consumption like this:
But why? We just passed a valid cancellation token to the consumer!
Please welcome, the EnumeratorCancellation!
Still not convinced?
Now, you might argue that it's stupid to expect that the consumer will somehow magically cancel himself. All cancellation in .NET is cooperative, meaning that it is the developer who should do the hard work of actually implementing cancellation by checking the state of CancellationToken inside his algorithm, just like we did with the producer.I hear you, and I'm thinking exactly the same, and yet, somehow, with consuming IASyncEnumerables - this is not the default approach anymore - take a look at this MSDN article by Stephen Toub.
The code for consuming is something like:
await foreach (int item in RangeAsync(10, 3).WithCancellation(token))
Console.Write(item + " ");
EnumeratorCancellation to the rescue
To make the code above run as expected (or rather fail as expected) with an OperationCancelledException when the consumerCancellationTokenSource has been cancelled, all we need to do is add EnumeratorCancellation attribute to the CancellationToken in the Produce method, like this:
The addition of EnumeratorCancellation attribute tells the compiler to declare a CancellationTokenSource field on the enumerator that, under certain conditions, will be set to a linked token source using CreateLinkedTokenSource, combining the CancellationToken passed to IAsyncEnumerable<T>.GetAsyncEnumerator on the consuming side, with the CancellationToken passed to the Produce method.
This linked CancellationTokenSource will provide a CancellationToken that will be used in the implementation of the IAsyncStateMachine.MoveNext(), which is, basically, the compiler - generated version of our Produce method.
public static async IAsyncEnumerable<int> Produce([EnumeratorCancellation]CancellationToken cancellationToken)I'm adding a full listing below, so you can try and run this. I will explain what's cooking in a moment.
The addition of EnumeratorCancellation attribute tells the compiler to declare a CancellationTokenSource field on the enumerator that, under certain conditions, will be set to a linked token source using CreateLinkedTokenSource, combining the CancellationToken passed to IAsyncEnumerable<T>.GetAsyncEnumerator on the consuming side, with the CancellationToken passed to the Produce method.
This linked CancellationTokenSource will provide a CancellationToken that will be used in the implementation of the IAsyncStateMachine.MoveNext(), which is, basically, the compiler - generated version of our Produce method.
Below is a de-compiled code snippet showing compiler-generated GetAsyncEnumerator, with some minor re-namings for readability's sake. Please note this is the Producer side.
ProducerCancellationToken is the token passed to the Produce method.
You can see there is a couple of optimizations here.
Before we discuss them, a brief reminder - CancellationToken is a value type, new CancellationToken() is the same as default(CancellationToken) and the same as CancellationToken.None. Tokens are considered equal if, and only if they belong to the same CancellationTokenSource.
See CancellationToken sources on GitHub.
So, the optimizations are:
See CancellationToken sources on GitHub.
So, the optimizations are:
- If producer did not supply a CancellationToken, use the one supplied by consumer
- If consumer did not supply a CancellationToken, or it's the same as the token given by producer, use the one from producer
- Otherwise, create a linked token source, that will be canceled whenever producer or consumer tokens are cancelled.
WithCancellation extension method breakdown
Looking at TaskAsyncEnumerableExtensions implementation, we can see that .WithCancellation extension method just wraps the IAsyncEnumerable<T> with a ConfiguredCancelableAsyncEnumerable structure where it passes the CancellationToken method argument. That structure also has a GetAsyncEnumerator method, that proxies the call to GetAsyncEnumerator method of the wrapped enumerable, passing a CancellationToken as an argument.There's no problem is software development that can't be solved by adding one more layer of abstraction, see FTSE.
A word about the context
For the same of simplicity, we put everything in one assembly, in one class even.
Of course, it's not like that in real life. Producer, Consumer, and the Orchestrator (here, Program, generally - any code that binds Producer and Consumer together) oftentimes are in different assemblies, repositories, code bases, controlled by different organizations, or open source.
Of course, it's not like that in real life. Producer, Consumer, and the Orchestrator (here, Program, generally - any code that binds Producer and Consumer together) oftentimes are in different assemblies, repositories, code bases, controlled by different organizations, or open source.
The underlying mechanics stay exactly the same, though.
And that is why, it is even more important to have the EnumeratorCancellation attribute in place, on the producer side, especially for library authors.
As a producer, you must remember that by omitting EnumeratorCancellation you might cause the code in consumer or orchestrator to break, or behave in an unexpected way.
Just imagine a less experienced developer trying to understand what's wrong with the producer's library. And then requesting a fix in a library that another organization owns. What would the ETA be? A week? A month? A quarter? By that time the consumer will have to work around the issue with some sub-optimal code.
Summary
Don't ignore CS8425 and make sure to decorate CacellationToken with the EnumeratorCancellation attribute when writing code that generates IASyncEnumerable sequences. Having that attribute in place will allow the consumer of your code to cancel gracefully, with a simple extension method WithCancellation. And, of course, do implement cooperative cancellation inside your IASyncEnumerable.Feedback
Hey, I love to learn new stuff, and I'd love to learn from you too. So, if there's anything I missed on this article, or anything I got wrong - please let me know! Let's make this post accurate and useful reference material. Thanks a lot!
Comments
Post a Comment