Sync-over-Async
Before you begin: Functions, threads, and the basic purpose of async and await.
Sync-over-async means synchronously waiting for an operation designed to complete asynchronously. Instead of returning control while the operation is unfinished, the caller blocks. That can waste a worker thread; in some environments it can also prevent the awaited work from making progress. 1
The important distinction is between suspending an operation and blocking the thread that operation is running on.
Original version: hide an asynchronous operation inside a blocking call
These examples use a tiny asynchronous stand-in rather than a network request, so the return value is predictable.
public static class Reader
{
private static async Task<string> FetchAsync()
{
await Task.Yield();
return "ready";
}
public static string Read()
{
return FetchAsync().GetAwaiter().GetResult();
}
}Read promises a string immediately, so it blocks until FetchAsync finishes. In an ordinary console test this can succeed. That does not establish that it is safe in a UI event handler.
For example, a UI thread can block inside Read while FetchAsync needs to resume on that same thread. The UI thread is waiting for the operation, and the operation is waiting for the UI thread. Replacing .Result with .GetAwaiter().GetResult() changes exception handling, not the fact that the wait blocks.
import asyncio
async def fetch():
await asyncio.sleep(0)
return "ready"
def read():
return asyncio.run(fetch())read creates and runs an event loop, then returns a string after the coroutine finishes. This is valid at a synchronous program boundary. It is not a general-purpose wrapper for use inside asynchronous code: asyncio.run cannot run inside an already running event loop in the same thread. 2
Python’s particular failure differs from the C# UI deadlock. The common mistake is pretending that a synchronous wrapper can be inserted anywhere without considering who is responsible for running asynchronous work.
Revised version: let asynchronous callers await
public static class Reader
{
public static async Task<string> ReadAsync()
{
await Task.Yield();
return "ready";
}
}string result = await Reader.ReadAsync();
Console.WriteLine(result);The caller awaits a task. When it is incomplete, the method can suspend rather than holding the thread in a blocking wait. The surrounding call chain must accommodate that asynchronous result too.
import asyncio
async def read_async():
await asyncio.sleep(0)
return "ready"
async def main():
result = await read_async()
print(result)asyncio.run(main())There is one runner at the program boundary. Inside it, main awaits the operation instead of starting another event loop. The same arrangement works when an application framework already supplies the outer event loop: its asynchronous handler should await read_async.
What this does not mean
Not every synchronous wait deadlocks. A completed task needs no wait, and execution context matters. Thread-pool starvation and UI deadlock are also different failure modes.
Nor does adding async make CPU-intensive work nonblocking. Code still runs until it reaches a suspension point, and ordinary blocking calls still block. Use genuinely asynchronous I/O where available; move suitable CPU work off a UI thread deliberately, rather than wrapping every call in more tasks.
At an unavoidable synchronous boundary, understand the platform and library’s documented behavior. A successful console example is not evidence that every synchronization context is safe.
Sources
- Microsoft — Asynchronous programming scenarios, particularly awaiting rather than blocking.
- Python — Running an asyncio program, documenting the restriction on nested runners.
- Stephen Toub — Await, and UI, and deadlocks, explaining the UI-thread dependency cycle.