Use Awaitable Overload
ID |
csharp.use_awaitable_overload |
Severity |
high |
Remediation Complexity |
medium |
Remediation Risk |
low |
Remediation Effort |
medium |
Resource |
Code Smell |
Language |
CSharp |
Tags |
async, code_smell, concurrency, scalability |
Description
Reports a synchronous call made inside an asynchronous body when the same receiver evidently offers an
awaitable counterpart. The blocking call holds the thread for the whole duration of the operation,
which cancels the benefit of the surrounding async for the entire method.
Rationale
An async method exists to give its thread back while an operation is in flight. A synchronous call in
the middle of it does the opposite: the thread stays parked in the call until the I/O finishes, and it
is unavailable for anything else in the meantime. One such call is enough — the method is only as
asynchronous as its most blocking step.
The failure is quiet and non-linear. Under light traffic the thread pool has spare threads and nothing looks wrong. Once concurrency reaches the pool size the pool starts injecting new threads at its own deliberately slow rate, and every request queues behind the blocked ones. Latency does not degrade gradually, it steps. Because the blocking call itself is fast on an idle developer machine, this is normally discovered in production rather than in a test.
Detection requires the receiver’s type to resolve, and takes one of two paths. Either the receiver is
a type declared in the same file that also declares a method with the same name plus the Async
suffix and a compatible parameter list — same count, or one extra trailing CancellationToken (the
standard TAP overload shape) — read off the declaration, not assumed from the name alone; or the
member is one of a curated set of standard library members whose awaitable counterpart is part of the
platform: the stream read, write, flush and copy family, the stream reader and writer, the whole-file
helpers, and the database command and connection members.
The signature check on the first path matters: two methods can share the Foo/FooAsync naming
convention while doing unrelated things, for example a synchronous GetRate(int, int) in-memory
calculation next to an unrelated GetRateAsync(int) that looks up a different value by id. Matching
by name alone would report the synchronous call as if GetRateAsync were its counterpart.
What this deliberately misses: a receiver from any other package, even where the counterpart certainly exists, because "a method with that name probably exists" is not worth reporting on. In-memory streams are also left out, since they perform no I/O and block nothing. A synchronous call made from an asynchronous facade over its own synchronous core is not reported either — that is the intended shape. And the asynchronous context is the innermost function, not the enclosing method, so a call inside a synchronous lambda is left alone: a lambda that cannot await cannot apply the fix.
using System.IO;
using System.Threading.Tasks;
public class Files
{
public async Task<string> Blocking(string path)
{
StreamReader reader = new StreamReader(path);
string body = reader.ReadToEnd(); // FLAW — holds the thread for the whole read
await Task.Delay(1);
return body;
}
public async Task<string> Awaited(string path)
{
StreamReader reader = new StreamReader(path);
return await reader.ReadToEndAsync(); // OK
}
public string Synchronous(string path)
{
StreamReader reader = new StreamReader(path);
return reader.ReadToEnd(); // OK, the caller is synchronous and cannot await
}
}
Remediation
Call the awaitable counterpart and await it. The signature usually differs slightly — a return type wrapped in a task, an extra parameter for a cancellation token — so check the counterpart’s documentation rather than assuming the argument list is identical.
Where the counterpart does not exist and the work is genuinely synchronous and long-running, the options in order of preference are: leave the method synchronous and let its caller decide how to schedule it; or, on a thread-pool host, push the work onto a worker with an explicit offload. Wrapping synchronous work in a task and awaiting it inside a request is not an improvement — it occupies a pool thread just the same and adds a scheduling hop.