Awaiting an async lambda passed as a parameter

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

Leave a Reply

Your email address will not be published.

This site uses Akismet to reduce spam. Learn how your comment data is processed.