Analyzer rule
LC062: Blocking on an EF Core async call
LC062 flags .Result, .Wait() and .GetAwaiter().GetResult() on EF Core async calls, which starve the thread pool and deadlock under a SynchronizationContext.
- Default severity
- Warning
- Code fix
- Available
- Category
- Performance
- Domain
- Execution & Async
Change the severity in .editorconfig:
[*.cs]
dotnet_diagnostic.LC062.severity = error
LC062: Blocking on an EF Core async call
In Plain Terms
You phone the kitchen, then hold the line and refuse to let anyone else use the phone until the food is ready. The kitchen needs that phone to tell you the food is ready.
Goal
Detect code that blocks on the task of an EF Core async operation with .Result, .Wait() or .GetAwaiter().GetResult().
The Problem
ToListAsync(), SaveChangesAsync() and the other EF Core async methods return a task that finishes on a continuation. Blocking on that task (sync-over-async) holds the calling thread while the database works, and the continuation needs a thread of its own to finish the task:
- Under load, every blocked request holds a thread-pool thread and needs a second one to finish, so the pool starves and requests time out. See asynchronous programming in the EF Core performance guide.
- Under a
SynchronizationContext(WinForms, WPF, classic ASP.NET), the continuation is posted back to the thread that is blocked waiting for it, and the call deadlocks. See EF Core async programming.
// Violations: each one blocks the thread until EF Core finishes.
var users = db.Users.ToListAsync().Result;
db.SaveChangesAsync().Wait();
var user = db.Users.FirstOrDefaultAsync(u => u.Id == id).GetAwaiter().GetResult();
The Fix
Await the task. If the calling code cannot be async, call the synchronous EF Core method instead, which does the same work without a second thread:
// In async code
var users = await db.Users.ToListAsync(cancellationToken);
await db.SaveChangesAsync(cancellationToken);
// In code that cannot be async
var users = db.Users.ToList();
db.SaveChanges();
LC008 is the reverse case: a synchronous EF Core call such as ToList() inside an async method.
Analyzer Logic
ID: LC062
Category: Performance
Severity: Warning
Reports .Result on Task<T> or ValueTask<T>, any Task.Wait(...) overload, and .GetAwaiter().GetResult() (also after ConfigureAwait(...)), in any method, when the task comes from one of these EF Core async operations directly, through ConfigureAwait(...) or ValueTask.AsTask(), or through a local assigned once from it:
- A query operator in
EntityFrameworkQueryableExtensionsorRelationalQueryableExtensions(ToListAsync,FirstOrDefaultAsync,CountAsync,ForEachAsync,ExecuteDeleteAsync,ExecuteUpdateAsync, …). SaveChangesAsync, including an override in the application’s context, andFindAsync,AddAsyncandAddRangeAsyncon aDbContextorDbSet<T>.FindAsyncandAddAsyncreturn aValueTask.- The async methods of
DatabaseFacade(BeginTransactionAsync,EnsureCreatedAsync, …) andRelationalDatabaseFacadeExtensions(MigrateAsync,ExecuteSqlRawAsync,ExecuteSqlAsync,ExecuteSqlInterpolatedAsync, …).
When it stays quiet (non-goals)
- A task already awaited, or proven complete, before the blocking access:
await task;,await Task.WhenAll(t1, t2);,task.Wait();(which reports itself), or a check such astask.IsCompletedSuccessfully ? task.Result : .... Any other use of the local before the access, and anyawaitbetween the assignment and the access, count as possibly completing it; a zero-timeout poll such astask.Wait(0)does not, so a latertask.Resultstill reports, except in the true branch of anifor?:whose condition is that poll, also written astask.Wait(0) == true,!= false,is trueor one side of&&(and in the right operand of that&&):if (task.Wait(0)) users = task.Result;stays quiet, as doif (ready && task.Wait(0)) users = task.Result;andif (task.IsCompleted) users = task.Result;. A== false,!= trueor||condition does not guard the access. - Tasks that do not come straight from EF Core:
Task.FromResult, the application’s own...Asyncmethods,Task.Run(() => db.Users.ToListAsync()).Result, fields and properties that hold a task, and locals assigned more than once or copied from another local. - Queries over an in-memory collection wrapped with
AsQueryable(): the EF Core async operators throw on those instead (LC060’s case). - A task stored in a local outside the lambda that blocks on it.
task.Wait(0),task.Wait(TimeSpan.Zero)and other zero timeouts (default,default(TimeSpan),new TimeSpan(),new TimeSpan(0),TimeSpan.FromMilliseconds(0)and the otherTimeSpan.FromXxx(0)factories, a constant 0), which only check whether the task has finished. Any other timeout blocks and is reported.
Code Fix
In an async method, lambda or local function, the blocking access becomes await on the task: db.Users.ToListAsync().Result becomes await db.Users.ToListAsync(), db.SaveChangesAsync().Wait() becomes await db.SaveChangesAsync(), and task.Result becomes await task. A member access on the result gets parentheses: (await db.Users.ToListAsync()).Count. Comments between the task and the blocking access, as in db.Users.ToListAsync() /* why */ .Result, move after the new expression; a // comment keeps a line break after it.
There is no synchronous fix (ToListAsync().Result to ToList()), because it cannot be proven to do the same thing: a SaveChangesInterceptor or DbCommandInterceptor that implements only the async callbacks, an application override of only the async method (auditing, soft delete), and the evaluation of arguments the synchronous method does not take all run only on the async path, and the fixer cannot see which interceptors are registered. In code that cannot be async, call the synchronous method yourself once you have checked those.
No fix is offered:
- Outside async code, including a non-async lambda inside an async method, and inside a
lockbody, whereawaitis not allowed. - Inside the
tryblock of atrystatement, in the same member, that has any catch clause, typed, untyped or filtered:.Resultand.Wait()throwAggregateExceptionandawaitdoes not, so the rewrite can change which handler runs. Atrywith only afinallykeeps the fix. - When a
ref structvalue, such as aSpan<T>local, or areforref readonlylocal, is used after the blocking access, or anywhere inside aref struct, wherethiscan be read implicitly: such a value cannot live across anawait. - For
Wait(timeout), which returns whether the task finished. - When a preprocessor directive (
#pragma,#if,#nullable, …) sits inside the blocking expression.
The fixer compiles the rewritten document and offers nothing when the rewrite would add an error or change the type of the value.
Test Cases
Violations
var users = db.Users.ToListAsync().Result;
db.SaveChangesAsync().Wait();
var user = db.Users.FindAsync(id).Result;
var task = db.Users.CountAsync(); var count = task.Result;
Valid
var users = await db.Users.ToListAsync(ct);
var task = db.Users.ToListAsync(); await task; var users = task.Result;
var users = new List<User>().AsQueryable().ToListAsync().Result;
var n = Task.FromResult(1).Result;