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.