Lesson 3: async and await under the hood
Sections
Reading settings
Natural voice recorded for this course, the same in every browser.
No voice for this language in this browser.
More voices
Code and English terms
Off: the page’s voice reads everything, with no pause between two voices.
No voice for this language in this browser.
More voices
More voices: Microsoft Edge has natural online voices, and Windows (Settings › Time & language › Speech), macOS and Android install others.
You have written async and await for years, and you know the rules of thumb: don’t block on async code, return ValueTask on hot paths, add ConfigureAwait(false) in libraries. This lesson opens the method the compiler actually emits for await, then checks each rule against a program: what a call allocates, on which thread the code after await runs, which exception you catch, and where a cancellation goes. Two pieces of Guitar Alchemist code serve as case studies: a Try helper that deadlocks under a synchronization context and swallows cancellation, and a cache that keeps a thread-pool thread asleep for every value.
GA links point to commit a826864; runtime links point to commit 4271d88 of dotnet/runtime, tagged v10.0.12.
Running the lesson’s program
Section titled “Running the lesson’s program”bash code/csharp-advanced/check.sh # every lesson, compared with expected/dotnet run --project code/csharp-advanced/Advanced -c Release -- l3 # this lesson only, after check.shThe code is Advanced/Lesson3.cs, and the method decompiled below is in Snippets/Async.cs. The one line starting with # depends on the processor count; the lesson quotes it from the author’s machine, an Intel Core Ultra 9 285K with 24 cores, and from the CI runners.
What the compiler does with await
Section titled “What the compiler does with await”Here is a small async method with two awaits and a local variable that lives across them:
public static async Task<int> AddLaterAsync(int left, int right){ await Task.Yield(); var sum = left + right; await Task.Delay(1); return sum;}There is no async in the IL. The compiler turns the method body into a state machine: a type that implements IAsyncStateMachine, whose MoveNext method runs the body up to the next await that isn’t finished yet, records where it stopped, and returns. check.sh decompiles the Release build with ilspycmd at C# 4 language level, before async existed, so that ILSpy can’t rebuild the awaits (expected/l3-state-machine.txt). The method itself has become a stub that creates the state machine and starts it:
[AsyncStateMachine(typeof(<AddLaterAsync>d__0))]public static Task<int> AddLaterAsync(int left, int right){ <AddLaterAsync>d__0 stateMachine = default(<AddLaterAsync>d__0); stateMachine.<>t__builder = AsyncTaskMethodBuilder<int>.Create(); stateMachine.left = left; stateMachine.right = right; stateMachine.<>1__state = -1; stateMachine.<>t__builder.Start(ref stateMachine); return stateMachine.<>t__builder.Task;}The parameters and the local sum have become fields, because they must survive between two calls to MoveNext. The state field, <>1__state, says where to resume: -1 at the start, 0 after the first await, 1 after the second, -2 when finished. AsyncTaskMethodBuilder<int> creates and completes the Task<int> the caller gets. And MoveNext contains the body, cut at each await:
private void MoveNext(){ int num = <>1__state; int result; try { TaskAwaiter awaiter; YieldAwaitable.YieldAwaiter awaiter2; if (num != 0) { if (num == 1) { awaiter = <>u__2; <>u__2 = default(TaskAwaiter); num = (<>1__state = -1); goto IL_00d5; } awaiter2 = Task.Yield().GetAwaiter(); if (!awaiter2.IsCompleted) { num = (<>1__state = 0); <>u__1 = awaiter2; <>t__builder.AwaitUnsafeOnCompleted(ref awaiter2, ref this); return; } } else { awaiter2 = <>u__1; <>u__1 = default(YieldAwaitable.YieldAwaiter); num = (<>1__state = -1); } awaiter2.GetResult(); <sum>5__2 = left + right; awaiter = Task.Delay(1).GetAwaiter(); if (!awaiter.IsCompleted) { num = (<>1__state = 1); <>u__2 = awaiter; <>t__builder.AwaitUnsafeOnCompleted(ref awaiter, ref this); return; } goto IL_00d5; IL_00d5: awaiter.GetResult(); result = <sum>5__2; } catch (Exception exception) { <>1__state = -2; <>t__builder.SetException(exception); return; } <>1__state = -2; <>t__builder.SetResult(result);}Each await x follows the same pattern: get an awaiter with x.GetAwaiter(), and check IsCompleted. If the operation is already finished, the code goes on without leaving MoveNext: that’s the fast path, and it costs no more than a method call. Otherwise, it saves the state and the awaiter in fields, asks the builder to call MoveNext again when the awaiter completes, and returns to its caller, which gets a Task that isn’t finished yet. GetResult() returns the value or rethrows the exception of the awaited operation. An exception anywhere in the body goes to SetException, which faults the task instead of propagating up the stack.
stateDiagram-v2 [*] --> Start: AddLaterAsync calls builder.Start Start --> Suspended0: Task.Yield not completed, state = 0, return Suspended0 --> Resumed0: the awaiter completes, MoveNext on a pool thread Resumed0 --> Suspended1: Task.Delay(1) not completed, state = 1, return Resumed0 --> Done: Task.Delay(1) already completed Suspended1 --> Done: the awaiter completes, MoveNext again Done --> [*]: state = -2, SetResult(sum)The program reads the same type with reflection (Lesson3.cs#L68-L79):
== The state machine behind AsyncMachine.AddLaterAsync (reflection)[AsyncStateMachine(typeof(<AddLaterAsync>d__0))], a struct implementing IAsyncStateMachine public Int32 <>1__state public AsyncTaskMethodBuilder<Int32> <>t__builder public Int32 left public Int32 right private Int32 <sum>5__2 private YieldAwaitable.YieldAwaiter <>u__1 private TaskAwaiter <>u__2AddLaterAsync(2, 3).Result = 5In Release, the state machine is a struct: it lives on the caller’s stack while the method runs synchronously, and costs nothing if no await has to wait. The first time one does, the builder copies it into a heap object, an AsyncStateMachineBox<TStateMachine> that is also the Task returned to the caller (AsyncTaskMethodBuilderT.cs#L215-L228). In Debug, the compiler emits a class instead, so that the debugger and Edit and Continue can work with it; check.sh decompiles the Debug build of the same method too:
private sealed class <AddLaterAsync>d__0 : IAsyncStateMachineThe mechanics are described in the C# language specification on async functions and, in much more depth, in Stephen Toub’s How async/await really works in C#.
What the state machine rules out
Section titled “What the state machine rules out”Because locals become fields of a type that can end up on the heap, a few things you can write in a synchronous method don’t compile in an async one. Every snippet below is compiled by CompileFail/Program.cs, like in lesson 1. A ref, in or out parameter would become a field holding a reference into the caller’s stack:
public static async Task NextAsync(ref int fret){ await Task.Yield(); fret++;}l3_ref_parameter.cs(4,48): error CS1988: Async methods cannot have ref, in or out parametersA Span<T> can’t be a field of an ordinary type (lesson 1). Since C# 13, an async method may declare a span local, as long as it isn’t used after an await. This one is, three times:
public static async Task<int> SumAsync(int[] frets){ Span<int> window = frets.AsSpan(0, 3); await Task.Yield(); return window[0] + window[1] + window[2]; // the span would outlive the stack frame}l3_span_across_await.cs(8,16): error CS4007: Instance of type 'System.Span<int>' cannot be preserved across 'await' or 'yield' boundary.l3_span_across_await.cs(8,28): error CS4007: Instance of type 'System.Span<int>' cannot be preserved across 'await' or 'yield' boundary.l3_span_across_await.cs(8,40): error CS4007: Instance of type 'System.Span<int>' cannot be preserved across 'await' or 'yield' boundary.This error is reported late, when the compiler rewrites the method into its state machine: a checker that stops at GetDiagnostics() doesn’t see it, which is why CompileFail emits every snippet. Finally, a lock belongs to a thread, and the code after an await may run on another one:
public async Task TuneAsync(){ lock (_gate) { await Task.Delay(10); // the continuation may run on another thread }}l3_await_in_lock.cs(10,13): error CS1996: Cannot await in the body of a lock statementTo hold a lock across an await, use SemaphoreSlim.WaitAsync, which isn’t tied to a thread.
Task or ValueTask: what completing synchronously costs
Section titled “Task or ValueTask: what completing synchronously costs”When an async method finishes without waiting, the builder still has to return a finished Task<int>. SetResult calls Task.FromResult, which returns a cached task for null, true, false and the integers from -1 to 8 (Task.cs#L5386-L5416, TaskCache.cs#L18-L21), and allocates a new one for anything else. A ValueTask<int> is a struct that holds either the result or a Task, so a synchronous result needs no allocation. The program measures one call of each, with await Task.CompletedTask as the only await (Lesson3.cs#L85-L104):
== Completing synchronously: bytes allocated by one callTaskOf(5) 0 (Task<int> results from -1 to 8 are cached)TaskOf(500) 72ValueTaskOf(500) 0Task.FromResult(true) 072 bytes is a Task<int> object. It is small, but a method called millions of times, a cache lookup or a buffered read that is usually synchronous, pays it on every call. When the method does wait, both kinds allocate the state machine box, and ValueTask saves nothing. BenchmarkDotNet’s Benchmarks/AsyncBenchmarks.cs measures the four cases:
cd code/csharp-advanceddotnet run -c Release --project Benchmarks -- --filter "*AsyncBenchmarks*"On the author’s machine (BenchmarkDotNet 0.15.8, .NET 10.0.12, Intel Core Ultra 9 285K, Windows 11):
| Method | Mean | Error | StdDev | Ratio | RatioSD | Gen0 | Allocated | Alloc Ratio |
|---|---|---|---|---|---|---|---|---|
| TaskCompletedSynchronously | 8.417 ns | 0.1833 ns | 0.5317 ns | 1.00 | 0.09 | 0.0038 | 72 B | 1.00 |
| ValueTaskCompletedSynchronously | 2.264 ns | 0.0717 ns | 0.1662 ns | 0.27 | 0.03 | - | - | 0.00 |
| TaskAfterYield | 784.608 ns | 9.1445 ns | 8.5537 ns | 93.60 | 6.27 | 0.0048 | 96 B | 1.33 |
| ValueTaskAfterYield | 829.301 ns | 12.2729 ns | 11.4801 ns | 98.94 | 6.67 | 0.0048 | 104 B | 1.44 |
- Completing synchronously,
ValueTask<int>allocates nothing and runs in about a quarter of the time: the 72-byteTask<int>and its initialization are gone. - After a real
await Task.Yield(), both allocate the box, 96 bytes for theTaskversion and 104 for theValueTaskone, whose box is a different type that implementsIValueTaskSourceinstead of deriving fromTask. Marking the method with the attribute[AsyncMethodBuilder(typeof(PoolingAsyncValueTaskMethodBuilder<>))]makes it reuse those boxes from a pool (PoolingAsyncValueTaskMethodBuilder<TResult>); the course hasn’t measured it, to verify. Both take about 800 ns, most of it spent queueing the continuation to the thread pool and switching threads.ValueTaskis not faster there, and was slightly slower in this run.
So ValueTask pays off only where the synchronous path is the common one.
ValueTask has a price in usage, not in bytes. A ValueTask may be backed by a pooled IValueTaskSource that is reused as soon as its result has been read, so its API reference lists what you must never do with one: await it twice, call AsTask() twice, or read .Result or .GetAwaiter().GetResult() before the operation has completed. The same rules are in the type’s source comments (ValueTask.cs#L30-L53). Return ValueTask from methods that usually complete synchronously and are awaited directly; keep Task as the default everywhere else (Understanding the whys, whats, and whens of ValueTask).
SynchronizationContext: where the code after await runs
Section titled “SynchronizationContext: where the code after await runs”When an await has to wait, the awaiter captures SynchronizationContext.Current and, when the operation completes, posts the rest of the method to that context. In a console application or ASP.NET Core there is none, and the continuation runs on a thread-pool thread. In WPF, Windows Forms, .NET MAUI or a Blazor component, the context is the UI thread’s: code after await can touch the UI again. ConfigureAwait(false) says “don’t capture it”.
The course program has no UI, so it builds a context of its own: SingleThreadContext, a dedicated thread named ui that runs the callbacks posted to it one after the other, like a UI message loop. ui.Run posts an async function to that thread and waits, from the main thread, until it has finished (Lesson3.cs#L106-L136):
== SynchronizationContext: where the code after await runsbefore await: on ui Trueafter await Task.Delay: on ui Trueafter ConfigureAwait(false): on ui False, pool thread TrueThe timer that completes Task.Delay fires on a pool thread, yet the first continuation came back to the ui thread through the captured context. After ConfigureAwait(false), it stayed on the pool thread.
The classic deadlock
Section titled “The classic deadlock”Now the function running on the ui thread blocks on async code, with .Wait() or .Result, the way an event handler that can’t be async sometimes does:
ui.Run(() =>{ var captured = DelayThenAdd(captureContext: true); Line($"awaits with the context, .Wait(1 s): completed {captured.Wait(TimeSpan.FromSeconds(1))}"); var free = DelayThenAdd(captureContext: false); Line($"awaits with ConfigureAwait(false), .Wait(10 s): completed {free.Wait(TimeSpan.FromSeconds(10))}"); var gaTry = Try.OfAsync(() => DelayThenAdd(captureContext: false)); Line($"GA Try.OfAsync(...), .Wait(1 s): completed {gaTry.Wait(TimeSpan.FromSeconds(1))}"); return Task.CompletedTask;});
static async Task<int> DelayThenAdd(bool captureContext){ await Task.Delay(10).ConfigureAwait(captureContext); return 2 + 3;}== Blocking on async code from the ui threadawaits with the context, .Wait(1 s): completed Falseawaits with ConfigureAwait(false), .Wait(10 s): completed TrueGA Try.OfAsync(...), .Wait(1 s): completed FalsesequenceDiagram participant UI as ui thread participant Pool as thread pool UI->>UI: DelayThenAdd(captureContext: true) returns an unfinished Task UI->>UI: .Wait() blocks the ui thread Pool->>UI: 10 ms later, the timer posts the continuation to the ui context Note over UI: the continuation waits in the queue of a thread that is waiting for itThe first task never completes: its continuation is queued to the ui thread, which is blocked waiting for that very task. Without a timeout, the application would freeze. With ConfigureAwait(false), the continuation runs on the pool and the task completes.
The third line is GA’s Try.OfAsync, which wraps an operation’s result or exception in a Try<T>:
public static async Task<Try<T>> OfAsync<T>(Func<Task<T>> operation){ try { var result = await operation(); return Try<T>.Success(result); } catch (Exception ex) { return Try<T>.Failure(ex); }}The operation passed in is careful, and uses ConfigureAwait(false). It doesn’t help: OfAsync’s own await captures the context, and deadlocks the same way. ConfigureAwait(false) protects only the await it is written on, so a library must put it on every await, at every level. That’s the actual rule behind the folklore, spelled out in the ConfigureAwait FAQ: application code that needs its context doesn’t use it; general-purpose library code, which can’t know who calls it, does. Better still, don’t block on async code at all.
Exceptions: await rethrows one, the task keeps them all
Section titled “Exceptions: await rethrows one, the task keeps them all”A task can fail with several exceptions, when it combines several operations. Task.WhenAll waits for all of them and faults with all of their exceptions; await then throws only the first one, not an AggregateException like .Wait() does (Lesson3.cs#L138-L164):
== Exceptions: await rethrows the first one, the Task keeps them allTask.Exception holds 2: first, second; await threw InnerExceptions[0]: TrueConfigureAwaitOptions.SuppressThrowing: no exceptionThe program sorts the messages before printing them: the order of InnerExceptions is the order in which the tasks failed, and it varies from one run to the next. When you need every failure, catch the exception and read task.Exception.InnerExceptions. The second line shows ConfigureAwaitOptions.SuppressThrowing, added in .NET 8: the await waits for the task to finish and ignores its outcome, useful to wait for a background task you are shutting down. It exists only for Task, not Task<T>, which would have no result to return.
Cancellation
Section titled “Cancellation”Cancellation in .NET is cooperative: a CancellationTokenSource sets a flag, and every operation that received its token checks the flag or registers a callback. A cancelled operation throws OperationCanceledException, or its subclass TaskCanceledException, carrying the token (Lesson3.cs#L166-L196):
== CancellationTask.Delay(10 s, token cancelled after 50 ms): TaskCanceledException, e.CancellationToken == token Truelinked token after parent.Cancel(): IsCancellationRequested TrueTask.Delay(10 s).WaitAsync(50 ms): TimeoutExceptionGA Try.OfAsync(cancelled task): IsFailure True, TaskCanceledException, no exception thrown- Comparing
e.CancellationTokenwith your own token tells a cancellation you asked for from one that came from somewhere else, such as an HTTP client’s timeout. CancellationTokenSource.CreateLinkedTokenSourcecreates a token cancelled when any of its parents is: the request’s token combined with a per-operation timeout.WaitAsyncstops waiting after a timeout, and throwsTimeoutException. It doesn’t stop the operation, which keeps running without anyone to observe it: pass a token to the operation itself when it accepts one.
The last line is GA’s Try.OfAsync again. Its catch (Exception ex) also catches OperationCanceledException, and turns the cancellation into an ordinary failure. The caller that cancelled gets a Try in the failure state instead of an exception, the code that follows the call keeps running as if a normal error had happened, and a framework such as ASP.NET Core, which recognizes a cancelled request by its OperationCanceledException, never sees it. Code that catches every exception should let cancellation through, with catch (Exception ex) when (ex is not OperationCanceledException).
IAsyncEnumerable<T> and [EnumeratorCancellation]
Section titled “IAsyncEnumerable<T> and [EnumeratorCancellation]”An async method that returns IAsyncEnumerable<T> can both await and yield return, and is consumed with await foreach. The compiler generates one state machine for both. The consumer passes its token with WithCancellation(token); that token reaches the iterator’s parameter only if the parameter is marked [EnumeratorCancellation] (Lesson3.cs#L198-L231):
static async IAsyncEnumerable<int> Frets([EnumeratorCancellation] CancellationToken token = default){ for (var fret = 0; fret < 12; fret++) { await Task.Delay(1, token); yield return fret; }}The consumer cancels after the third fret:
== IAsyncEnumerable: the token reaches the iterator through [EnumeratorCancellation]frets 0 1 2, then TaskCanceledExceptionWithout the attribute, the compiler only warns. Exercise 3 shows what that warning means at run time.
l3_missing_enumerator_cancellation.cs(4,47): warning CS8425: Async-iterator 'Frets.AllAsync(CancellationToken)' 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 unconsumedTime without sleeping threads: GA’s LazyWithExpiration
Section titled “Time without sleeping threads: GA’s LazyWithExpiration”GA’s LazyWithExpiration<T> computes a value on first use and computes it again once it has expired. It measures the expiration by starting a task that sleeps:
if (!_lazyObject.IsValueCreated){ Task.Factory.StartNew(() => { Thread.Sleep(_expirationTime); _expired = true; });}Task.Factory.StartNew runs the delegate on the thread pool, and Thread.Sleep holds that thread for the whole expiration time, doing nothing. The pool creates threads on demand up to its minimum, the number of processors by default, and beyond that adds them only gradually (The managed thread pool). The program creates 64 values that expire after one second, reads them, then measures how long a trivial Task.Run(() => 0) waits for a thread (Lesson3.cs#L233-L255):
== GA LazyWithExpiration: one sleeping thread-pool thread per expiring valuesum of the 64 values: 2016# thread pool threads: 3 before, 25 after; a Task.Run(() => 0) took 2016 msThe two 2016s are a coincidence: 0 + 1 + … + 63 = 2016, and the wait happened to last 2,016 ms. With 24 cores, the sleepers took the pool’s threads for about two seconds before the unrelated work item got one. On the CI runners, with 3 or 4 cores, the same Task.Run waited 11,001 ms on Linux, 9,878 ms on Windows and 11,722 ms on macOS. In a web server, that’s every request stalled: thread-pool starvation, caused here by a cache.
Waiting for time to pass needs a timer, not a thread; and a cache that only needs to know whether a value is old doesn’t even need a timer. It can compare timestamps when the value is read. TimeProvider, added in .NET 8, makes the clock injectable, so a test can advance time instead of sleeping. The course’s ExpiringLazy<T> does that, with a ManualClock for the test (the Microsoft.Extensions.TimeProvider.Testing package provides a complete FakeTimeProvider):
public sealed class ExpiringLazy<T>(Func<T> create, TimeSpan expiration, TimeProvider clock){ private readonly Lock _lock = new(); private (T Value, DateTimeOffset CreatedAt)? _entry;
public T Value { get { lock (_lock) { var now = clock.GetUtcNow(); if (_entry is not { } entry || now - entry.CreatedAt >= expiration) { entry = (create(), now); _entry = entry; } return entry.Value; } } }}== An expiring cache without a sleeping thread: TimeProvidert=0: Value 1t=4 min: Value 1t=6 min: Value 2The value is computed once, kept at 4 minutes, and computed again at 6 minutes, in a test that runs in no time. The lock is a System.Threading.Lock, which lock uses directly since C# 13; lesson 10 comes back to it.
If you know Spring and Reactor
Section titled “If you know Spring and Reactor”Reactor answers this lesson’s question the other way round. C# rewrites sequential code into a state machine so that it can suspend; Reactor asks you to describe the work as a chain of operators, and runs it when someone subscribes. Both moments allocate, and not the same way: assembling .map(...) creates one Publisher object, once, while each subscription creates one subscriber object per operator (FluxMap builds a MapSubscriber in subscribeOrReturn). The Reactor course runs both.
| C# | Reactor |
|---|---|
the compiler generates a state machine for each async method |
no code generation: operators are objects, and subscribe builds a second chain, of subscribers |
a Task is hot: it is already running, and awaiting it twice gives the same result |
a pipeline is cold: each subscriber runs it again. cache() turns it hot; Mono.just(lookUp()) is the trap, because its argument ran at assembly, and Mono.defer is the fix |
ValueTask saves the allocation of a synchronous completion |
the equivalent saving is fusion: Fuseable lets adjacent operators share one queue instead of one per stage, and a scalar source is replaced by a cheaper operator at assembly. Reactor publishes no figure for it |
await resumes on the captured SynchronizationContext |
nothing captures anything. publishOn changes the thread for the operators below it; subscribeOn changes the thread the whole chain subscribes on, and only the closest one counts |
ConfigureAwait(false) opts out of a capture you never asked for |
publishOn opts in to a thread change you did ask for |
| blocking under a single-threaded context deadlocks | block() from a thread marked NonBlocking throws instead of deadlocking; BlockHound catches the blocking calls that check misses |
AsyncLocal<T> flows with the execution context |
Context and ContextView travel with the subscription, written downstream and read upstream |
a CancellationToken you pass down by hand and check |
cancel() travels up the subscription on its own — as a request to stop eventually: the Reactive Streams rules say a subscriber must still be ready for items already requested (rule 2.8) |
await rethrows the first exception, and the task keeps them all |
an error is terminal: it ends the sequence, and an error-handling operator doesn’t resume it, it starts a new sequence in its place |
IAsyncEnumerable<T> and [EnumeratorCancellation] |
Flux<T>, where the consumer asks with request(n) |
TimeProvider to make time testable |
StepVerifier.withVirtualTime, which installs a virtual-time scheduler — hence its Supplier: the pipeline must be built inside the lambda |
Put the two deadlocks side by side. In C#, blocking deadlocks because a context you never asked for captured the continuation, and the fix is to stop blocking. In Reactor, blocking fails because you are on a thread that refuses to block, and the fix is Mono.fromCallable(...).subscribeOn(Schedulers.boundedElastic()) joined with flatMap — again, not blocking. The rule survives the translation; only the error message changes.
What does change is the price of blocking. Virtual threads (JEP 444, final in Java 21) make a blocked thread almost free, and JEP 491 removed the synchronized pinning that came with them, so the thread-per-request style is viable again for code that waits. The Reactor course weighs the two models; .NET has no equivalent, and async remains the only way to wait without holding a thread.
Exercises
Section titled “Exercises”ThreeDelaysAsyncawaitsTask.Delay(1)three times in a row. How many awaiter fields does its state machine have? Predict, then check with reflection orilspycmd.- Fix GA’s
Try.OfAsyncso that it doesn’t deadlock when called and blocked on from theuithread, and lets cancellation propagate. Check both with the program’sSingleThreadContextand a cancelled task. - Remove
[EnumeratorCancellation]fromFrets, keepWithCancellation(cts.Token)and the cancellation after the third fret, and run the loop again. What happens, and why?
Solutions
-
One. The compiler needs one field per awaiter type whose value must survive a suspension, not one per
await: the threeawaits store the same kind ofTaskAwaiterin the same field, and each one clears it after use so the task can be collected (Lesson3.cs#L25-L30).AddLaterAsynchad two fields because it awaited two different awaiter types.1. ThreeDelaysAsync: 1 awaiter field (TaskAwaiter <>u__1), result 3 -
Add
ConfigureAwait(false)to theawait, and excludeOperationCanceledExceptionfrom thecatchwith an exception filter (Lesson3.cs#L260-L275):public static async Task<Try<T>> OfAsync<T>(Func<Task<T>> operation){try{var result = await operation().ConfigureAwait(false);return Try<T>.Success(result);}catch (Exception ex) when (ex is not OperationCanceledException){return Try<T>.Failure(ex);}}2. TryFixed.OfAsync(...) on the ui thread, .Wait(10 s): completed TrueTryFixed.OfAsync(cancelled task): TaskCanceledException, e.CancellationToken == token TrueThe fixed version completes on the
uithread, and the cancellation reaches the caller with its own token. -
The loop runs to the end, frets 0 to 11, and completes normally.
WithCancellationpasses the token toGetAsyncEnumerator; without the attribute, the generated enumerator has nowhere to put it, and the iterator’stokenparameter keeps its default value,CancellationToken.None. Cancelling does nothing, which is exactly what warning CS8425 says. Since the program disables that warning on purpose (Lesson3.cs#L56-L66), nothing else tells you.3. without [EnumeratorCancellation]: frets 0 1 2 3 4 5 6 7 8 9 10 11, then completed
Key takeaways
Section titled “Key takeaways”- An
asyncmethod compiles to a stub and a state machine: locals become fields, eachawaitchecksIsCompleted, and a suspension saves the state, registersMoveNextas the continuation and returns. - In Release the state machine is a struct, copied to the heap only on the first real suspension. An async method that completes synchronously allocates only its
Task<T>, and nothing for cached results or withValueTask<T>. ValueTasksaves an allocation on synchronous paths, and must be awaited exactly once.awaitresumes on the capturedSynchronizationContext. Blocking on async code from a single-threaded context deadlocks unless everyawaitbelow it usesConfigureAwait(false), which is why libraries write it everywhere and applications shouldn’t block at all.awaitrethrows the first exception; the task keeps all of them.- Cancellation is cooperative. Don’t catch
OperationCanceledExceptionalong with other exceptions, pass the token to the operation rather than only toWaitAsync, and mark an async iterator’s token[EnumeratorCancellation]. - Never wait for time with
Thread.Sleepon the thread pool: compare timestamps from aTimeProvider, or useTask.Delayor a timer.
Sources
Section titled “Sources”- Microsoft Learn: Asynchronous programming with async and await, Async functions in the C# specification, Cancellation in managed threads, Generate and consume async streams, The managed thread pool, What is TimeProvider?.
- The .NET blog, by Stephen Toub: How async/await really works in C#, ConfigureAwait FAQ, Understanding the whys, whats, and whens of ValueTask.
- dotnet/runtime at
v10.0.12(commit4271d88):AsyncTaskMethodBuilderT.cs,Task.cs,TaskCache.cs,ValueTask.cs. - Guitar Alchemist at
a826864:Try.cs,LazyWithExpiration.cs.