52

I'm trying to play a bit with C#'s async/await/continuewith. My goal is to have to have 2 tasks which are running in parallel, though which task is executing a sequence of action in order. To do that, I planned to have a List<Task> that represent the 2 (or more) tasks running in parallel, and to use ContinueWith on each of the Task My problem is that the callback in continue with seems not to be executed while the await taskList has already returned.

In order to summarize, here's a sample to illustrate what I'm expecting to happen:

class Program
{
    static public async Task Test()
    {
        System.Console.WriteLine("Enter Test");
        await Task.Delay(100);
        System.Console.WriteLine("Leave Test");
    }

    static void Main(string[] args)
    {
        Test().ContinueWith(
        async (task) =>
        {
            System.Console.WriteLine("Enter callback");
            await Task.Delay(1000);
            System.Console.WriteLine("Leave callback");
        },
        TaskContinuationOptions.AttachedToParent).Wait();
        Console.WriteLine("Done with test");
    }
}

The expected output would be

Enter Test
Leave Test
Enter callback
Leave callback
Done with test

However, the output is

Enter Test
Leave Test
Enter callback
Done with test

Is there a way to make the Task on which ContinueWith is called wait for the provided function to complete before being considered as done? ie. .Wait will wait for both tasks to be completed, the original one, and the one which is returned by ContinueWith

svick
  • 236,525
  • 50
  • 385
  • 514
chouquette
  • 971
  • 1
  • 7
  • 12
  • 1
    Try to remove the `await` from `await Task.Delay(1000);` – Alessandro D'Andria Sep 09 '13 at 11:31
  • 1
    Indeed that worked. I removed the await, and the async since nothing is awaited. However I'm missing the reason for it to work. Do you have a theoretical explanation? – chouquette Sep 09 '13 at 11:34
  • Does the "Leave callback" appear after "Done with test"? – Francesco De Lisi Sep 09 '13 at 11:36
  • Because in your `Test` method the only part asynchronous, is the `await Task.Delay(100)` for the rest it run synchronously. All the inline code run synchronously because you are not "awaiting" the task. – Alessandro D'Andria Sep 09 '13 at 11:39
  • 3
    Calling `task.ContinueWith(async (task) => { .. })` actually returns Task, more on wrapped tasks [here](http://blogs.msdn.com/b/pfxteam/archive/2011/10/24/10229468.aspx). Is this exactly what you're looking for? – noseratio Sep 09 '13 at 11:43
  • It is, however as pointer in another answer I needed to unwrap to access the actual task I'm interested in waiting for – chouquette Sep 09 '13 at 11:48
  • 1
    I'd avoid using wrapped tasks where not necessary, [here's](http://stackoverflow.com/a/18697945/1768303) what I mean. – noseratio Sep 09 '13 at 12:13
  • Side note. Thanks for writing the question so clearly. This is a tricky corner-case but it's the _first_ search result for `continuewith async task`! – mdisibio Sep 13 '20 at 23:04

3 Answers3

57

When chaining multiple tasks using the ContinueWith method, your return type will be Task<T> whereas T is the return type of the delegate/method passed to ContinueWith.

As the return type of an async delegate is a Task, you will end up with a Task<Task> and end up waiting for the async delegate to return you the Task which is done after the first await.

In order to correct this behaviour, you need to use the returned Task, embedded in your Task<Task>. Use the Unwrap extension method to extract it.

Nekresh
  • 2,948
  • 23
  • 28
  • 2
    Nearly 7 years later and can confirm this is the necessary step, not something I could find anywhere in MS documentation. I was using ContinueWith as a poor-man's work queue, and weirdly while `queue = queue.ContinueWith(_ => asyncFunc());` seems to work as expected under Windows, on Linux it doesn't, and you definitely need `queue.ContinueWith(_ => asyncFunc()).Unwrap();`. Key word there though is 'seems' - even under Windows without Unwrap() it doesn't actually run the tasks in sequence, but there was enough delay between them for it to work well enough. – Dylan Nicholson Jul 06 '20 at 22:29
  • 2
    @DylanNicholson No kidding! I did dozens of tests on a Windows machine and my elaborate 'Continuations' _seemed_ to work fine. Did not become fully aware of the need for `Unwrap` until I deployed the same code to a Linux based Docker container. – mdisibio Sep 13 '20 at 22:50
  • 1
    https://learn.microsoft.com/en-us/dotnet/standard/parallel-programming/chaining-tasks-by-using-continuation-tasks#continuations-that-return-task-types – Jason C May 26 '21 at 01:33
  • Also; using the generic `ContinueWith<>` can help prevent unexpected behaviors at compile time, e.g. `task.ContinueWith(Continuation)` will fail to compile if `Continuation` is `async` and returns a `Task` instead of an `ExpectedResult`, instead of silently compiling but behaving unexpectedly. – Jason C May 26 '21 at 01:38
32

When you're doing async programming, you should strive to replace ContinueWith with await, as such:

class Program
{
  static public async Task Test()
  {
    System.Console.WriteLine("Enter Test");
    await Task.Delay(100);
    System.Console.WriteLine("Leave Test");
  }

  static async Task MainAsync()
  {
    await Test();
    System.Console.WriteLine("Enter callback");
    await Task.Delay(1000);
    System.Console.WriteLine("Leave callback");
  }

  static void Main(string[] args)
  {
    MainAsync().Wait();
    Console.WriteLine("Done with test");
  }
}

The code using await is much cleaner and easier to maintain.

Also, you should not use parent/child tasks with async tasks (e.g., AttachedToParent). They were not designed to work together.

Stephen Cleary
  • 437,863
  • 77
  • 675
  • 810
  • What if I wanted to fire and forget an async method, but still catch and exception, so I can log it? – niproblema Jan 23 '21 at 20:31
  • "Fire and forget" is problematic. The fact that you care about exceptions indicates that it is not truly fire and forget (since it's not forgotten). The proper solution to fire-and-forget depends on what exactly the code is doing. – Stephen Cleary Jan 23 '21 at 23:15
  • Hi Stephen, in my case I am happy with an exception, as long as I can log it. However I am not happy with awaiting for this long operation to end. – niproblema Jan 24 '21 at 07:15
  • @niproblema: "as long as I can log it" is not "forget". In this case, [the proper solution for request-extrinsic code is a durable queue with a background service](https://blog.stephencleary.com/2021/01/asynchronous-messaging-1-basic-distributed-architecture.html). – Stephen Cleary Jan 24 '21 at 14:10
5

I'd like to add my answer to complement the answer which has already been accepted. Depending on what you're trying to do, often it's possible to avoid the added complexity of async delegates and wrapped tasks. For example, your code could be re-factored like this:

class Program
{
    static async Task Test1()
    {
        System.Console.WriteLine("Enter Test");
        await Task.Delay(100);
        System.Console.WriteLine("Leave Test");
    }

    static async Task Test2()
    {
        System.Console.WriteLine("Enter callback");
        await Task.Delay(1000);
        System.Console.WriteLine("Leave callback");
    }

    static async Task Test()
    {
        await Test1(); // could do .ConfigureAwait(false) if continuation context doesn't matter
        await Test2();
    }

    static void Main(string[] args)
    {
        Test().Wait();
        Console.WriteLine("Done with test");
    }
}
noseratio
  • 59,932
  • 34
  • 208
  • 486
  • 2
    After getting burned by this `Task` behavior and the need for the obscure `Unwrap()` I'm going back and refactoring to simple code blocks rather than continuations. BUT, its worth noting that a) hundreds of examples from good .Net devs use continuation with nary an `Unwrap` and b) the [docs](https://learn.microsoft.com/en-us/dotnet/standard/parallel-programming/chaining-tasks-by-using-continuation-tasks) themselves promote `Continuation` as "relatively easy to use, but nevertheless powerful and flexible". This particular obscure corner-case is a bit frustrating... – mdisibio Sep 13 '20 at 22:58
  • @mdisibio, there're many simple one-liners where `ContinueWith` + `Unwrap` is justified. With this combo, it's easy to correctly propagate cancellation status. E.g.: https://stackoverflow.com/a/62607500/1768303 – noseratio Sep 14 '20 at 01:50
  • lol `Unwrap()` to `ContinueWith` is evolving as `ConfigureAwait(false)` is to `await` - easy to add, annoying when you forget to add it! – mdisibio Sep 15 '20 at 03:35
  • @mdisibio, more like forgetting `await` itself :) As to `ConfigureAwait(false)`, it's IMO much less relevant these days. My (unpopular) opinion is [to not dogmatically use it](https://www.reddit.com/r/csharp/comments/hk5uc5/how_do_you_deal_with_configureawaitfalse/fwsqf3k?utm_source=share&utm_medium=web2x&context=3). – noseratio Sep 15 '20 at 04:04
  • 1
    @mdisibio [Continuations That Return Task Types](https://learn.microsoft.com/en-us/dotnet/standard/parallel-programming/chaining-tasks-by-using-continuation-tasks#continuations-that-return-task-types) (from the MS overview doc on continuations) specifically describes when and why to use `Unwrap()`. The examples in other sections on that page also use `Unwrap` for all async continuations. – Jason C May 26 '21 at 01:29