No Blocking Async Call

ID

csharp.no_blocking_async_call

Severity

critical

Remediation Complexity

trivial

Remediation Risk

low

Remediation Effort

low

Resource

Reliability

Language

CSharp

Tags

async, azure, concurrency, deadlock, reliability

Description

Reports the blocking ways of consuming a task: reading .Result, calling .Wait(), Task.WaitAll / Task.WaitAny, and the .GetAwaiter().GetResult() idiom. Each of them parks the calling thread until the task finishes, and under a single-threaded synchronization context that wait can never end. A blocking Thread.Sleep inside an async body is reported for the same reason — it holds a thread that the method was written to be able to give back.

An Azure Function that blocks is reported with a message of its own, because the remediation is more specific there. A method carrying a [FunctionName("…​")] binding attribute (in-process model) or a [Function("…​")] one (isolated worker model) is a function host entry point, and the host runs it under a synchronization context on a thread pool shared with every other function in the app. The fix does not have to propagate anywhere: the function signature itself becomes async Task and the call is awaited.

Two situations cannot deadlock and are not reported. Blocking on a task started on the thread pool, Task.Run(…​) or Task.Factory.StartNew(…​), is safe: that work captured no synchronization context, so its continuation does not need the blocked thread back. And the entry point of a console application, static Main, runs without a synchronization context at all. Both exemptions hold inside an Azure Function as well: recognising the function context changes the message, never what is reported.

A blocking access is also not reported when it sits in the one branch that only runs once the same task is already known to have finished: an if testing task.Status == TaskStatus.RanToCompletion (in either operand order) or task.IsCompleted, or a case TaskStatus.RanToCompletion: of a switch (task.Status). A task that has already completed cannot block, whatever member reads it. The guard is matched against the same receiver, so a check on a different task does not suppress this one.

Rationale

An await suspends the method and hands the thread back to whatever scheduled it. A blocking wait does the opposite: it keeps the thread and stops it. Beyond giving up the scalability that motivated making the operation asynchronous, this creates a deadlock that is easy to reproduce in production and hard to reproduce in tests.

The mechanism is the synchronization context. On a desktop UI thread, or inside a classic ASP.NET request, the continuation of an await is posted back to the one thread that owns the context. If that thread is the thread now sitting inside Wait(), it never returns to its message pump, so the continuation is never dispatched, so the task never completes, so the wait never ends. The wait is blocking the very work whose result it is waiting for.

Two secondary costs come with it. Exceptions arrive wrapped in an AggregateException instead of being rethrown in their original shape, which breaks catch clauses written for the real exception type. And on a thread-pool-backed host — an Azure Function, a background service — a blocked thread is a thread the pool cannot reuse, so a burst of requests exhausts the pool and latency collapses for everything else running there.

The Azure Functions case earns its own message because that host makes both costs worse at once. The pool is shared across every function in the function app, so one blocking function degrades the others; and the function host does install a synchronization context, so the deadlock above is reachable from an ordinary HTTP trigger rather than only from a desktop UI thread. The remediation is also shorter than usual — a function has no caller to propagate to, so making it async Task and awaiting is the whole change.

using System.Threading;
using System.Threading.Tasks;

public class OrderSync
{
    public int TotalBlocking(Task<int> pending)
    {
        return pending.Result;                       // FLAW — blocks; deadlocks on a UI thread
    }

    public void FlushBlocking(Task pending)
    {
        pending.Wait();                              // FLAW — same wait, statement form
    }

    public string ReadBlocking(Task<string> pending)
    {
        return pending.GetAwaiter().GetResult();     // FLAW — unwrapped, still blocking
    }

    public async Task PauseAsync()
    {
        Thread.Sleep(100);                           // FLAW — holds the thread of an async method
        await Task.Delay(1);
    }

    public async Task<int> TotalAwaited(Task<int> pending)
    {
        return await pending;                        // OK — the thread is released while waiting
    }

    public int PoolWork()
    {
        return Task.Run(() => Compute()).Result;     // OK — pool work captures no context
    }

    public void WaitForSignal(ManualResetEventSlim gate)
    {
        gate.Wait();                                 // OK — a synchronization primitive, not a task
    }

    public int ReadWhenRanToCompletion(Task<int> pending)
    {
        if (pending.Status == TaskStatus.RanToCompletion)
        {
            return pending.Result;                   // OK — the same task is already known to be done
        }
        return -1;
    }
}

public class OrderFunctions
{
    [FunctionName("SyncOrders")]
    public void SyncOrders(Task pending)
    {
        pending.Wait();                              // FLAW — blocks a shared function host thread
    }

    [Function("ImportOrders")]
    public int ImportOrders(Task<int> queued)
    {
        return queued.Result;                        // FLAW — same defect, isolated worker model
    }

    [Function("ExportOrders")]
    public async Task<int> ExportOrdersAsync(Task<int> queued)
    {
        return await queued;                         // OK — the function itself is asynchronous
    }
}

Remediation

Make the caller asynchronous and await the task. The change propagates up the call chain until it reaches a point that can be asynchronous by itself — a controller action, an event handler, an async Task Main. That propagation is the point, not an obstacle: it is what keeps the thread free.

  • await task instead of task.Result or task.Wait().

  • await Task.WhenAll(…​) / await Task.WhenAny(…​) instead of Task.WaitAll / Task.WaitAny.

  • await Task.Delay(…​) instead of Thread.Sleep(…​) inside an async body.

  • When the value really is already available, use the non-blocking accessors — check IsCompleted, or keep the value in a ValueTask returned by a synchronous fast path.

  • Where the call chain genuinely cannot be made asynchronous, keep the synchronous work synchronous rather than wrapping it in a task and blocking on it.

  • In an Azure Function, change the function signature itself: public async Task<T> Run(…​) instead of public T Run(…​), then await. The function host supports asynchronous functions in both the in-process and the isolated worker model, and there is no caller to propagate to.

ConfigureAwait(false) reduces the deadlock exposure of library code but is not a fix for a blocking wait: the thread is still held.