TL;DR
The reason why you get the warning is because
Task.Run(() => DoThings(10)); // warning here
returns a Task, and since your ServePage
method is marked as async, the compiler believes that you should await the result of the Task
Detail
You're mixing two very different paradigms, which coincidentally both involve Task
, viz:
Task.Run()
, which is generally useful for parallelizing CPU bound work by utilizing multiple cores which may be available
async / await
, which is useful for waiting for I/O bound operations to complete, without blocking (wasting) a thread.
So for instance, if you wanted to do 3 x CPU bound operations concurrently, and since Task.Run
returns a Task
, what you could do is:
public Page ServePage() // If we are CPU bound, there's no point decorating this as async
{
var taskX = Task.Run(() => CalculateMeaningOfLife()); // Start taskX
var taskY = Task.Run(() => CalculateJonSkeetsIQ()); // Start taskY
var z = DoMoreHeavyLiftingOnCurrentThread();
Task.WaitAll(taskX, taskY); // Wait for X and Y - the Task equivalent of `Thread.Join`
// Return a final object comprising data from the work done on all three tasks
return new Page(taskX.Result, taskY.Result, z);
}
The above is likely to utilise up to three threads, which could do the CPU bound work concurrently if there are sufficient cores to do so. Note however that using multiple threads concurrently will reduce the scalability of your system, since fewer simultaneous pages can be served without context switching.
This is in contrast to async / await
, which is generally used to free up threads while waiting for I/O bound calls to complete. Async is commonly used in Api and Web apps to increase scalability as the thread is released for other work while the IO bound work happens.
Assuming DoThings
is indeed I/O bound, we can do something like:
public async Task<string> DoThings(int foo) {
var result = await SomeAsyncIo(foo);
return "done!";
}
Async work can also be done in parallel:
public async Task<Page> ServePage() {
var task1 = DoThings(123); // Kick off Task 1
var task2 = DoThings(234); // Kick off Task 2 in parallel with task 1
await Task.WhenAll(task1, task2); // Wait for both tasks to finish, while releasing this thread
return new Page(task1.Result, task2.Result); // Return a result with data from both tasks
}
If the I/O bound work takes a reasonable amount of time, there's a good chance there's a point during the await Task.WhenAll
when ZERO threads are actually running - See Stephen Cleary's article.
There's a 3rd, but very dangerous option, which is fire and forget. Since method DoThings
is already marked as async
, it already returns a Task
, so there's no need at all to use Task.Run
at all. Fire and forget would look as follows:
public Page ServePage() // No async
{
#pragma warning disable 4014 // warning is suppresed by the Pragma
DoThings(10); // Kick off DoThings but don't wait for it to complete.
#pragma warning enable 4014
// ... other code
return new Page();
}
As per @JohnWu's comment, the 'fire and forget' approach is dangerous and usually indicates a design smell. More on this here and here
Edit
Re:
there is nuance to this that escapes me over and over, such as that calling an async method that returns Task from a synchronous method fires-and-forgets execution of the method. (That's the very last code sample.) Am I understanding that correctly?
It's a bit difficult to explain, but irrespective of whether called with, or without the await
keyword, any synchronous code in an invoked async
method before the first await will be executed on the caller's thread, unless we resort to hammers like Task.Run
.
Perhaps this example might help the understanding (note that we're deliberately using synchronous Thread.Sleep
and not await Task.Delay
to simulate CPU bound work and introduce latency which can be observed)
public async Task<Page> ServePage()
{
// Launched from this same thread,
// returns after ~2 seconds (i.e. hits both sleeps)
// continuation printed.
await DoThings(10);
#pragma warning disable 4014
// Launched from this same thread,
// returns after ~1 second (i.e. hits first sleep only)
// continuation not yet printed
DoThings(10);
// Task likely to be scheduled on a second thread
// will return within few milliseconds (i.e. not blocked by any sleeps)
Task.Run(() => DoThings(10));
// Task likely to be scheduled on a second thread
// will return after 2 seconds, although caller's thread will be released during the await
// Generally a waste of a thread unless also doing CPU bound work on current thread, or unless we want to release the calling thread.
await Task.Run(() => DoThings());
// Redundant state machine, returns after 2 seconds
// see return Task vs async return await Task https://stackoverflow.com/questions/19098143
await Task.Run(async () => await DoThings());
}
public async Task<string> DoThings(int foo) {
Thread.Sleep(1000);
var result = await SomeAsyncIo(foo);
Trace.WriteLine("Continuation!");
Thread.Sleep(1000);
return "done!";
}
There's one other important point to note - in most cases, there are no guarantees that the continuation code AFTER an await will be executed on the same thread as that before the await
. The continuation code is re-written by the compiler into a Task, and the continuation task will be scheduled on the thread pool.