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 taskinstead oftask.Resultortask.Wait(). -
await Task.WhenAll(…)/await Task.WhenAny(…)instead ofTask.WaitAll/Task.WaitAny. -
await Task.Delay(…)instead ofThread.Sleep(…)inside anasyncbody. -
When the value really is already available, use the non-blocking accessors — check
IsCompleted, or keep the value in aValueTaskreturned 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 ofpublic T Run(…), thenawait. 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.