Tasks and Async (C#.NET)
A brief note on Async
Good Asynchronous code enables responsive interfaces and more efficient use of resources. Good asynchronous programming releases control of the CPU resources by a thread or process to be used elsewhere. In a .NET web server environment, asynchronous code allows for better scalability to handle sudden traffic spikes by allowing threads to be shared by more than one request in progress at a time.
Did you know...
...the default thread pool in .NET only creates two new threads a second if it gets a traffic increase?
Async code allows threads to be shared between simultaneous responses. This makes two new threads a second translate to MORE than two new simultaneous requests.
What is a Task?
Here are some ways to describe a Task.
- Tasks are an abstraction of a thread - by awaiting and creating Tasks we are indicating where programming could possibly suspend a thread or introduce a new one for better efficiency.
- Tasks represent work that may or may not be done yet.
- Similarly to how functions represent instructions to do work, a Task represents a worker that executes those instructions.
- It's Mickey's magic broom created to independently carry out a job.
Unlike actual Threads where we create, run and suspend them manually, .NET will manage Tasks for us in a way that attempts to be fair and avoid starvation. This removes much of the complexity of async programming.
A Simple Await
A Task represents work that may or may not be done yet. A Task can have "await" called on it, causing the calling code to pause, wait for the child Task to complete (and it's result to be returned), and then to resume. This is conceptually similar to how a normal function call would be handled, except the function call might be run on a separate thread.
public async Task<string> processProject(int id){
//fetch project from DB asyncronously
Project project = await ProjectDAL.GetProjectAsync(id);
return project.title;
}
Awaiting a Task that is already complete will return the result instantly without pausing the calling code. This applies even if the Task has already been awaited.
Awaiting a Task that has not completed, will cause the calling/parent function to be suspended so that any other ready-to-run Tasks in the system can take over it's unused resources.
Running a Task in the Background
The first pattern we looked is great for enabling a web-server to run more unrelated requests at once, increasing overall response time. The following patterns enable us to describe how a singe request can be executed faster, by providing opportunities for parallelism and so is also relevant to stand-alone applications.
Whether or not a Task is actually run in parallel with other processing is up to the Task Scheduler. However, Task.Run does allow us to supply hints to the scheduler to encourage this. This is not discussed here.
Not immediately calling await on a task allows the calling code/Task to continue execution without delay, and the child Task to be executed as the Task Scheduler sees fit (usually pretty quickly).
Task projectAsync = ProjectDAL.GetProjectAsync(pid); //no await here
//below code continues at same time as above is running
Bitmap rendering = Renderer.GaussianMap(newdata);
//wait for project task to complete if it hasn't already,
//then store result to new variable
var project = await projectAsync;
project.rendering = rendering;
Fire and Forget
If a very slow background Task's result is unimportant, and it is acceptable for it to fail silently, we can use a "Fire and Forget" pattern by omitting the await completely.
In the below example the sendEmailAsync Task is started and never has "await" called on it. Assuming no errors, the email will get sent. It may be sent well after we have exited the UpdateOld function. If there are errors, they are ignored.
public static Task UpdateOld(Project p){
_ = sendEmailAsync(p.ManagerEmail, subject="Long time no see!");
await ProjectDAL.SaveProjectAsync(p);
}
NOTE: The underscore character is a discard; it will chuck out the returned task. This will stop warnings coming up about whether you intended to not wait on a task, which will appear in Visual Studio if you do not assign the task anywhere.
FURTHER NOTE: In an .NET MVC context, if you're doing authorisation checks in your fire-and-forget Task, you need to ensure that the HttpContext is simulated inside the Task as well. This is because the real context will be destroyed once the web request returns, and the fire-and-forget task might still try and use it. This mostly applies if you're using "Claims".
Multiple Tasks
In the previous example, the SaveProjectAsync and sendEmailAsync Tasks were created at roughly the same time, and we didn't need to know if email succeeded or not.
If we need to make sure both our tasks complete before we can continue, we simply await both, one after the other.
Task<Project> projectAsync = ProjectDAL.GetProjectAsync(pid); //no await here
//below code continues at same time as above is running
Task<Bitmap> renderingAsync = Renderer.GaussianMapAsync(newdata);
var project = await projectAsync;
project.rendering = await rendering;
In the above example, the order we await the tasks is unimportant to execution time because neither of the Tasks depend on the other, even though the result of one will use the result of the other.
A side note: Lambdas
In next section, I'm going to start using lambdas - if you're not familiar with them, they're pretty straightforward, and super cool. Read more.
Chaining Tasks
In the next example, the rendering task requires the project before it can start, so we can't start both at once like before. We also want to calculate pi for some reason.
Task<Project> projectAsync = ProjectDAL.GetProjectAsync(pid);
Task<Bitmap> renderingAsync = Renderer.GaussianMapAsync(await projectAsync);
//below code will run simultaneously to rendering
//once "await projectAsync" is done.
var pi = slowPiCalculator(40000);
Bitmap rendering = await renderingAsync; //wait for result of renderingAsync
The pi calculation will start at the same time as the rendering. It will not start with projectAsync because we have an await on projectAsync before the pi calculation.
If we move the pi calculation to begin when ProjectDAL does, the pi calculation will return earlier, but now the rendering will not start until pi is calculated:
Task<Project> projectAsync = ProjectDAL.GetProjectAsync(pid); //no await here
//Gaussian map will start once project is returned
//below code will run simultaneously to rendering
var pi = slowPiCalculator(40000);
Task<Bitmap> renderingAsync = Renderer.GaussianMapAsync(await projectAsync);
Bitmap rendering = await renderingAsync; //wait for result of renderingAsync
Ideally we want the pi calculation to be independent to project and bitmap such that the rendering is not held up by the pi calculation, yet still have the dependency between project and bitmap preserved. This can be done with ContinueWith:
Task<Project> projectAsync = ProjectDAL.GetProjectAsync(pid); //no await
Task<Task<Bitmap>> bitmapAsyncAsync = projectAsync.ContinueWith(
async (Task<Project> p)=>
Renderer.GaussianMapAsync((await p).newdata));
//below code will run simultaneously to rendering
var pi = slowPiCalculator(40000);
Bitmap rendering = await await bitmapAsyncAsync;
NOTE: Because we are guaranteed the task is completed when the ContinueWith callback lambda runs, and that the previous task/thread has already surrendered the context, it is safe to use "p.Result" instead of "await p" in the above example. In many other situations, .Result can cause deadlock.
Task.Run
A better option than using a ContinueWith might be to move the pi calculation to it's own Task. This allows us to use a much simpler, easy to follow pattern. You can run non-async code inside a Task with the help of Task.Run()
.
Task piTask = Task.Run(()=>slowPiCalculator(40000));
Project project = await ProjectDAL.GetProjectAsync(pid);
Bitmap rendering = await Renderer.GaussianMapAsync(project.newdata);
//below code will run simultaneously to rendering
var pi = await piTask;
There's more to talk about with Tasks. Check out my follow up article on handling more tasks, handling exceptions and cancelling tasks.