Skip to main content

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

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

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

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.
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 most important thing to note here is that CancellationToken in the GetAsyncEnumerator method argument is really, well, unconsumed. There are no usages whatsoever.
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:


And it does work as expected:

Enough! What does not work, then? 
Let's try an cancel the consumption like this:

When we run this, it, unexpectedly, executes smoothly, without OperationCancelledException.
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 + " ");
In the spirit of keeping things simple for the developer, and delegating all the heavy lifting to the compiler it makes sense, and that has pretty much been the trend so far.

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

Here, consumerCancellationToken refers to the token passed to the Consume method. I will explain how exactly it finds it's way from Consume to the call to GetAsyncEnumerator shortly.
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:
  • 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. 
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

Popular posts from this blog

Serilog with Application Insights: Correlating logs with other telemetry by using Operation Id

Despite the odds, Serilog and Application Insights are a fairly common combination. Let's dive in and find out if such partnership is well justified. Introduction Serilog  is an extremely popular structured logging library, with powerful DSL and serialization features. The  benefits of structured logging  are well known an widely appreciated, so if you're not convinced yet, do spend some time to read up on the topic. Application Insights  is a very popular APM SaaS offering from Microsoft on Azure, especially in the .NET world. Motivation Now, you might wonder -  why put those two together ? After all, not all great tech plays together well. And I completely agree with that. In fact, when starting a greenfield application, Elastic Search seems to be a better choice for storing and searching structured logs data. One of the obvious benefits would be the data ingestion pipeline speed. But logs are only part of the story, however. When we look at the APM in general, El

Elastic Index Lifecycle Management

Elastic Stack is quite capable of running blazing fast queries against your data. However, the more data you have, the more time it will take to query it. Most of the times, however, Elastic Stack will not be the mechanism you use for long time data retention. Consequently you will have to get rid of the old data. Let's find out how. Brief overview of steps required Pick a naming convention Create index lifecycle management policy Create an index template that connects the alias and the ilm policy Create an index  Associate an  alias  with the index Let's dig into the details. Pick a naming convention Depending on the size of your company, your topology, team and org structure, you will probably have a Elastic Stack deployment that is shared between several teams and used by multiple services (distributed tracing benefits).  Namespaces is a useful notion that we can leverage while naming ELK objects, even though the notion itself is not directly suppo