The problem
Whilst I have mostly got the hang of async/await, I still find unexplored corners of the subject that catch me out. I came across one the other day that was (in retrospect) so obvious that I’m surprised it never caught me out until then. This blog post is an attempt to explain the problem and solution, so that when I want to remember the hows and whys in a few months, I will have something to read!
As my regular reader will know, I am not under any illusions that anyone else reads my blog, nor that anyone else find it useful. I use it as a place to make notes of things that I’m likely to want to find again. If it helps anyone else, well that’s just a bonus!
The actual background to the use case is irrelevant, so I’ll illustrate with a trivial example. Suppose we have a C# method as follows…
void Match(Action action) { action(); }
As it stands, this is pretty useless, but in my code, Match
is a method on a monad-type object that handles service calls, and similar. The actual code is more complex, but the simple version above is enough to show the problem.
With regular, synchronous usage, this works as expected. However, when you pass an async lambda to the method, things go slightly awry…
Console.WriteLine($"{DateTime.Now.ToLongTimeString()} - Main - Start"); Match(async () => { Console.WriteLine($"{DateTime.Now.ToLongTimeString()} - Lambda - start"); await Task.Delay(1000); Console.WriteLine($"{DateTime.Now.ToLongTimeString()} - Lambda - end"); }); Console.WriteLine($"{DateTime.Now.ToLongTimeString()} - Main - End"); // Match method as above, but with logging void Match(Action action) { Console.WriteLine($"{DateTime.Now.ToLongTimeString()} - Match - start"); action(); Console.WriteLine($"{DateTime.Now.ToLongTimeString()} - Match - end"); }
The Console.WriteLine
statements are purely there for the purposes of this blog post, to make it clear what went wrong.
When this runs, we get the following output…
18:18:39 – Main – Start
18:18:39 – Match – start
18:18:39 – Lambda – start
18:18:39 – Match – end
18:18:39 – Main – End
18:18:40 – Lambda – end
As you can see, due to the delay inside the lambda, the Match
method ends before the lambda does. As I generally use Match
as the final step in a process, it’s never caught me out before. However, the issue that stung me recently was because I was expecting the lambda code to have completed by the time the call to Match
had ended. As it wasn’t, the subsequent code did not run correctly.
Looking at it now, it’s obvious why this happens, but it took me some time to get it clear, and to work out how to fix it. In all fairness, the Match
method was being called on an object that was produced by an awaited method, and it was all rather more complex than the simple code shown above.
A major issue that I faced was that I was using Match
all over the place, and did not want to break my existing code, so overloads and new methods weren’t an option I wanted to consider.
The wrong fix
My first thought as to add a call to Task.Delay
right after the call to Match
, but I knew this was wrong even as I typed it.
Before anyone wonders why on Earth I might think of doing that, it’s worth pointing out that the code in question was interacting with the Google Maps API, via some unpleasant JavaScript calls. Sadly, there have been times when I’ve needed to use
Task.Delay
, as the Google Maps API doesn’t always seem to behave the way I would expect it to, and adding a small delay sorted it out.
The right fix
For those (including a future me) who just want to know how I fixed it, the new version of Match
is simply…
void Match(Delegate action) { object result = action.DynamicInvoke(); if (result is Task task) { task.GetAwaiter().GetResult(); } }
This first runs the action, which will result in null, or a Task
, depending on whether the action is sync or async. We then check if result
is not null, meaning that we were passed an async lambda, in which case we await the actual result (which by definition will be void
, as result
is a Task
).
An explanation of the fix
The path to this final code wasn’t easy. My first thought was to change the Action
passed as a parameter to be a Task
-based one. However, the various permutations of this idea that I tried either didn’t compile, or had the same issue as the original code. The trick was using a Delegate
.
When you change the parameter type from Action
to Delegate
in the Match
method, there’s a fundamental difference in how C# handles the passed lambda expressions, particularly async ones…
With an Action parameter
When you pass an async lambda to a method expecting an Action
, the compiler adapts it by creating a closure that runs the lambda and discards the returned Task
. This means that…
Match(async () => { await Task.Delay(1000); });
…gets transformed by the compiler into something like…
Match(() => { var task = SomeAsyncMethod(); // Task is ignored/discarded return; });
Because of this adaptation, the Match
method never sees the Task returned by the async lambda – it only sees a void-returning delegate.
With a Delegate parameter
As Delegate
is the base class for all delegate types, the compiler preserves the original delegate type when passing it to a parameter of type Delegate
.
When you pass an async lambda to a method with a Delegate
parameter, the lambda retains its Func
type…
Match(async () => { await Task.Delay(1000); });
The Match
method receives a Func
delegate, not an adapted Action
.
When you call action.DynamicInvoke()
, it returns whatever the delegate returns, including a Task
from an async lambda.
This difference is crucial because with Delegate
, we can…
- Execute the delegate using
DynamicInvoke()
- Check if the result is a
Task
- If it is, await it with
GetAwaiter().GetResult()
This allows us to handle both synchronous and asynchronous lambdas with a single method, which wasn’t possible with the Action parameter type where the asynchronous nature of the lambda would be lost due to the compiler’s type adaptation.
Be First to Comment