Would async/await provide benefit over Task for intertwined statements?

171 views Asked by At

I have a method (called via an AJAX request) that runs at the end of a sequence. In this method I save to the database, send emails, look up a bunch of info in other APIs/databases, correlate things together, etc.. I just refactored the original method into a second revision and used Tasks to make it asynchronous, and shaved off up to two seconds in wall time. I used Tasks mainly because it seemed easier (I'm not that experienced in async/await yet) and some tasks depend on other tasks (like task C D and E all depend on results from B, which itself depends on A). Basicalll all of the tasks are started at the same time (processing just zips down the to the Wait() call on the email task, which in one way or another requires all the others to complete. I generally do something like this except with something like eight tasks:

public thing() {
    var FooTask<x> = Task<x>.Factory.StartNew(() => {
        // ...
        return x;
    });
    var BarTask<y> = Task<y>.Factory.StartNew(() => {
      // ...
      var y = FooTask.Result;
      // ...
      return y + n;
    }
    var BazTask<z> = Task<z>.Factory.StartNew(() => {
      var y = FooTask.Result;
      return y - n;
    }
    var BagTask<z> = Task<z>.Factory.StartNew(() => {
      var g = BarTask.Result;
      var h = BazTask.Result;
      return 1;
    }
    // Lots of try/catch aggregate shenanigans.
    BagTask.Wait();
    return "yay";
}

Oh I also need to roll back previous things if something breaks, like remove a database row if the email fails to send, so there are a few levels of try/catches in there. Anyway, all of this works (amazingly, it all worked on the first try). My question is whether this sort of method would benefit from being rewritten to use async/await rather than Tasks. If so, how would the multiple-dependency scenario play out without re-running an async method that was already ran or awaited by another method? I guess some shared variable?

Update:

The // ... lines were supposed to indicate that the task was doing something, like looking up DB records. Sorry if that wasn't clear. About half of the tasks (there are 8 total) can take up to maybe five seconds to run, if the contexts aren't warmed up, and the other half of the tasks just collect/assemble/process/use that data.

1

There are 1 answers

4
Stephen Cleary On BEST ANSWER

I also need to roll back previous things if something breaks, like remove a database row if the email fails to send, so there are a few levels of try/catches in there.

You'll find that async/await (paired with Task.Run instead of StartNew) will make your code much cleaner:

var x = await Task.Run(() => {
  ...
  return ...;
});
var y = await Task.Run(() => {
  ...
  return x + n;
});
var bazTask = Task.Run(() => {
  ...
  return y - n;
});
var bagTask = Task.Run(async () => {
  ...
  var g = y;
  var h = await bazTask;
  return 1;
}
await bagTask;
return "yay";

You also have the option of using Task.WhenAll if you want to await multiple tasks completing. Error handling in particular is cleaner with await since it doesn't wrap exception in AggregateException.

However

called via an AJAX request

This is a bit of a problem. Both StartNew and Task.Run should be avoided on ASP.NET.

shaved off up to two seconds in wall time

Yes, parallel processing on ASP.NET (which is what the code is currently doing) will make individual requests execute faster, but at the expense of scalability. The server will be unable to handle as many requests if it is doing parallel processing on each one.

save to the database, send emails, look up a bunch of info in other APIs/databases

These are all I/O-bound operations, not CPU-bound. So the ideal solution is to create truly-async I/O methods and then just call them using await (and Task.WhenAll if necessary). By "truly-async", I mean calling the underlying asynchronous APIs (e.g., HttpClient.GetStringAsync instead of WebClient.DownloadString; or Entity Framework's ToListAsync instead of ToList, etc). Using StartNew or Task.Run is what I call "fake asynchrony".

Once you have asynchronous APIs, your top-level method really becomes simple:

X x = await databaseService.GetXFromDatabaseAsync();
Y y = await apiService.LookupValueAsync(x);
Task<Baz> bazTask = databaseSerivce.GetBazFromDatabaseAsync(y);
Task<Bag> bagTask = apiService.SecondaryLookupAsync(y);
await Task.WhenAll(bazTask, bagTask);
Baz baz = await bazTask;
Bag bag = await bagTask;
return baz + bag;