1

After browsing different await vs Task.Run questions on SO my takeaway is that await is better for I/O operations and Task.Run for CPU-intensive operations. However the code with await seems to always be longer than Task.Run. Below is an example method of how I am creating a context object with await in my app:

public async AppContext CreateAppContext()
{
    var context = new AppContext();

    var customersTask = _dataAccess.GetCustomers();
    var usersTask = _dataAccess.GetUsers();
    var reportsTask = _dataAccess.GetReports();

    context.Customers = await customersTask;
    context.Users = await usersTask;
    context.Reports = await reportsTask;

    return context;     
}

If I was to rewrite this with Task.Run I could do

public async AppContext CreateAppContext()
{
    var context = new AppContext();
    await Task.WhenAll(new[]
    {
        Task.Run(async () => { context.Customers  = await _dataAccess.GetCustomers(); }),
        Task.Run(async () => { context.Users = await _dataAccess.GetUsers(); }),
        Task.Run(async () => { context.Reports = await _dataAccess.GetReports(); });
    })
            
    return context; 
}

The difference is not major when I create an object with 3 properties but I have objects where I need to initialize 20+ properties in this manner which makes the await code a lot longer (nearly double) than Task.Run. Is there a way for me to initialize the object using await with code that is not a lot longer than what I can do with Task.Run?

  • 1
    _”is that await is better for I/O operations and Task.Run for CPU-intensive operations”_ - that’s an oversimplification. –  Jan 03 '23 at 10:19
  • 1
    note that whether this *improves* things depends hugely on how `_dataAccess` is implemented; is it designed for concurrency? are the 3 resources independent? in many cases, I might expect this to either fail due to concurrency (on a shared connection, for example), or to take *just as long* (or potentially: longer), if you're now hitting a single underlying resource in parallel rather than sequentially – Marc Gravell Jan 03 '23 at 10:22
  • As a side note, the code in your question is imperfect because it omits the important `Task.WhenAll`. For an explanation see this question: [Why should I prefer single 'await Task.WhenAll' over multiple awaits?](https://stackoverflow.com/questions/18310996/why-should-i-prefer-single-await-task-whenall-over-multiple-awaits) – Theodor Zoulias Jan 03 '23 at 15:43
  • Somewhat related: [Avoid checking condition twice when assigning from task awaited with Task.WhenAll()](https://stackoverflow.com/questions/69416885/avoid-checking-condition-twice-when-assigning-from-task-awaited-with-task-whenal) – Theodor Zoulias Jan 03 '23 at 15:55

3 Answers3

3

Personally, I prefer an asynchronous factory pattern over that kind of code; but if you need to do concurrent asynchronous work like that multiple times, then you'll probably want to write your own helper method.

The BCL-provided WhenAll works best when either all tasks have no results, or when all tasks have the same type of result. One fairly common approach to help WhenAll work with different types of tasks is to return a tuple of results, which can then be deconstructed into different variables if desired. E.g.,

public static class TaskEx
{
  public static async Task<(T1, T2, T3)> WhenAll<T1, T2, T3>(Task<T1> task1, Task<T2> task2, Task<T3> task3)
  {
    await Task.WhenAll(task1, task2, task3);
    return (await task1, await task2, await task3);
  }
}

// Usage
public async AppContext CreateAppContext()
{
  var context = new AppContext();

  (context.Customers, context.Users, conext.Reports) =
      await TaskEx.WhenAll(
          _dataAccess.GetCustomers(),
          _dataAccess.GetUsers(),
          _dataAccess.GetReports());

  return context;     
}

You can even define a tuple GetAwaiter extension method if you want, to make it more implicit:

// Usage
public async AppContext CreateAppContext()
{
  var context = new AppContext();

  (context.Customers, context.Users, conext.Reports) =
      await (
          _dataAccess.GetCustomers(),
          _dataAccess.GetUsers(),
          _dataAccess.GetReports());

  return context;     
}

There are a couple of disadvantages to these approaches, though. First, you have to define as many overloads as you need. Second, the multiple-assignment code is not very nice; it's fine for 2 or 3 properties, but would get ugly (IMO) if done for much more than that.

So I think what you really want is a custom delegate form of WhenAll. Something like this should work:

public static class TaskEx
{
  public static async Task WhenAll(params Func<Task>[] tasks)
  {
    return Task.WhenAll(tasks.Select(action => action()));
  }
}

// Usage
public async AppContext CreateAppContext()
{
  var context = new AppContext();

  await TaskEx.WhenAll(
      async () => context.Customers = await _dataAccess.GetCustomers(),
      async () => context.Users = await _dataAccess.GetUsers(),
      async () => conext.Reports = await _dataAccess.GetReports());

  return context;     
}

Since this solution avoids dealing with the different result types entirely, multiple overloads aren't needed.

Stephen Cleary
  • 437,863
  • 77
  • 675
  • 810
1

Is there a way for me to initialize the object using await with code that is not a lot longer than what I can do with Task.Run?

If you want to run all tasks in parallel - in short no, you cant shorten number of lines. Also note that those two snippets are not fully functionally equivalent - see Why should I prefer single await Task.WhenAll over multiple awaits.

You can simplify (and maybe even improve performance a bit) your Task.WhenAll approach by introducing a method which will await and assign. Something along these lines:

public async AppContext CreateAppContext()
{
    var context = new  AppContext();
    await Task.WhenAll(
        AwaitAndAssign(val => context.Customers = val, _dataAccess.GetCustomers()),
        AwaitAndAssign(val => context.Users = val, _dataAccess.Users()),
        AwaitAndAssign(val => context.Reports = val, _dataAccess.GetReports())
        );
    return context;

    async Task AwaitAndAssign<T>(Action<T> assign, Task<T> valueTask) =>
        assign(await valueTask);
}
Guru Stron
  • 102,774
  • 10
  • 95
  • 132
1

If you really want to keep your general pattern (which I'd avoid - it would be much better to do all the work and then assign all the results at the same time; look into the return value of Task.WhenAll), all you need is a simple helper method:

static async Task Assign<T>(Action<T> assignment, Func<Task<T>> getValue)
  => assignment(await getValue());

Then you can use it like this:

await Task.WhenAll
  (
    Assign(i => context.Customers = i, _dataAccess.GetCustomers),
    Assign(i => context.Users = i, _dataAccess.GetUsers),
    Assign(i => context.Reports = i, _dataAccess.GetReports)
  );

There's many other ways to make this even simpler, but this is the most equivalent to your Task.Run code without having to involve another thread indirection just to do an assignment. It also avoids the very common mistake when you happen to use the wrong Task.Run overload and get a race condition (as Task.Run returns immediately instead of waiting for the result).

Also, you misunderstood the "await vs. Task.Run" thing. There's actually not that much difference between await and Task.Run in your code - mainly, it forces a thread switch (and a few other subtle things). The argument is against using Task.Run to run synchronous code; that wastes a thread waiting for a thing to complete, your code doesn't.

Do keep in mind that WhenAll comes with its own complications, though. While it does mean you don't have to worry about some of the tasks ending up unobserved (and not waited on!), it also means you have to completely rethink your exception handling, since you're going to get an AggregateException rather than anything more specific. If you're relying on error handling based on identifying exceptions, you need to be very careful. Usually, you don't want AggregateException to leak out of methods - it's very difficult to handle in a global manner; the only method that knows the possibilities of what can happen is the method that calls the WhenAll. Hopefully.

It's definitely a good idea to run parallel operations like this in a way that cannot produce dangerous and confusing side-effects. In your code, you either get a consistent object returned, or you get nothing - that's exactly the right approach. Be wary of this approach leaking into other contexts - it can get really hard to debug issues where randomly half of the operations succeed and other half fails :)

Luaan
  • 62,244
  • 7
  • 97
  • 116
  • 1
    +1. Regarding the propagation of multiple exceptions, this question is relevant: [I want await to throw AggregateException, not just the first Exception](https://stackoverflow.com/questions/18314961/i-want-await-to-throw-aggregateexception-not-just-the-first-exception). – Theodor Zoulias Jan 03 '23 at 16:01