Chaining Continuation Actions in Concurrent Programming

This tutorial shows how to manage multiple asynchronous jobs with chained continuation actions/callback methods.

Problem

You have a set of independent jobs running asynchronously. You could await those calls and perform further processing on them, but it will effectively make them run synchronously. If you have some continuation actions (or callbacks) dependent on the return values of those methods, you might be wasting time waiting for all the jobs to finish before you perform any continuation actions on them.

Let’s look at this with a simple example.

var job1 = DoJobAsync();
var job2 = DoOtherJobAsync();
// just as an example, you can make your synchronous method run on separate thread too:
var job3 = Task.Factory.StartNew(() => SynchronousJob1());
// await all tasks before processing continuation actions:
await Task.WhenAll(job1, job2, job3); // <--- THIS IS OUR ISSUE
// continuation actions:
var contActJob1 = ProcessJob1Stuff(job1.Result);
var contActJob2 = ProcessJob2Stuff(job2.Result);
var contActJob3 = ProcessJob3Stuff(job3.Result);
await Task.WhenAll(contActJob1, contActJob2, contActJob3);

There’s an issue with the above snippet though. We’re running job1, job2 and job3 asynchronously, and the continuation actions also run async, but the problem with this code is that all the initial jobs have to go through the funnel of awaiting right in the middle of processing (annotated above).

It would be perfect if we could make this code run completely asynchronously, despite the job completion order.

Solution #1: Task.ContinueWith

Here, we’re taking advantage of a ContinueWith method, by chaining the callback method after the initial task has been completed. You can chain as many calls as you want, but you’ll have to deal with a slightly ugly syntax to access the job results like contActJob1.Result.Result.Result etc:


// JOB 1 PROCESSING WITH CALLBACK:
var job1 = DoJobAsync();
var contActJob1 = job1.ContinueWith(async t =>
{
    return await ProcessJob1Stuff(job1.Result);
});
// JOB 2 PROCESSING WITH CALLBACK:
var job2 = DoOtherJobAsync();
var contActJob2 = job2.ContinueWith(async t =>
{
    await ProcessJob2Stuff(t.Result);
});
// JOB 3 PROCESSING WITH CALLBACK:
var contActJob3 = Task.Factory.StartNew(() => SynchronousJob1()).ContinueWith(async t =>
{
    await ProcessJob3Stuff(t.Result);
});
// MAKE SURE ALL TASKS ARE COMPLETED BEFORE PROCESSING FURTHER:
await Task.WhenAll(contActJob1.Result, contActJob2.Result, contActJob3.Result);

Solution #2: Task.WhenAny + Cursor

Slightly long-winded and perhaps reinventing the wheel, but might provide a bit more control over the task management.

This approach will await completion of any job and then delegate the result to the callback method based on the conditional switch statement:

// start the initial set of jobs:
var job1 = DoJobAsync();
var job2 = DoOtherJobAsync();
var job3 = Task.Factory.StartNew(() => SynchronousJob1());
// add the jobs to dictionary setting Tasks as keys for ease of access:
var tasks = new Dictionary<Task<string>, string>()
{
    { job1, nameof(job1) },
    { job2, nameof(job2) },
    { job3, nameof(job3) },
};
// introduce result collection:
var finalTasks = new List<Task>();
// iterate over all the tasks in the dictionary:
while (tasks.Any())
{
    // await any job and grab the completion task:
    var cursor = await Task.WhenAny<string>(tasks.Keys);
    // cursor is the index (task) from the task list,
    // so we can use it to access the continuation method
    var typeOfResult = tasks[cursor];
    var res = cursor.Result;
    switch (typeOfResult)
    {
        case "job1":
            finalTasks.Add(ProcessJob1Stuff(res));
            break;
        case "job2":
            finalTasks.Add(ProcessJob2Stuff(res));
            break;
        case "job3":
            finalTasks.Add(ProcessJob3Stuff(res));
            break;
        default:
            throw new Exception("UNKNOWN JOB TYPE");
    }
    // remove the cursor from the list of tasks
    tasks.Remove(cursor);
}
await Task.WhenAll(finalTasks);

Conclusion

I feel like the problem solved in this article represents many real-life scenarios where software developers simply await all the asynchronous calls as soon as they’re being called, causing their application to perform significantly poorer than it could with a few simple tweaks and a bit of understanding of a purpose of async/await and asynchronous programming in general.

Default image
Pawel Flajszer
Articles: 10