Analyzer rule
Spec: LC008 - Sync-over-Async Blocker
EF Core LINQ performance analyzer and Roslyn analyzer for catching query issues at compile time.
Spec: LC008 - Sync-over-Async Blocker
Goal
Detect synchronous EF Core query and save operations inside an async context when an async counterpart exists.
The Problem
Synchronous calls like db.Users.ToList() block the current thread while waiting for the database response. In a web server, this leads to “Thread Starvation,” where all available threads are stuck waiting, preventing the server from handling other incoming requests.
LC008 focuses on database I/O that happens directly inside async control flow. It reports mapped synchronous methods when they execute against EF-backed sources:
- query materializers:
ToList,ToArray,ToDictionary, andToHashSet - query terminals:
First,FirstOrDefault,Single,SingleOrDefault,Last,LastOrDefault,Count,LongCount,Any,All,Min,Max,Sum, andAverage - context/set operations:
SaveChanges,Find,ExecuteUpdate, andExecuteDelete
Example Violation
public async Task<List<User>> GetUsersAsync()
{
// Violation: Blocks the thread. Should use ToListAsync().
return db.Users.ToList();
}
public async Task<int> CountUsersAsync()
{
// Violation: Count() blocks the async method.
return db.Users.Count();
}
public async Task SaveAsync()
{
UpdateEntities();
// Violation: synchronous database save inside async flow.
db.SaveChanges();
}
The Fix
Use the asynchronous counterpart and await it.
public async Task<List<User>> GetUsersAsync()
{
// Correct: Frees up the thread while waiting
return await db.Users.ToListAsync();
}
public async Task<int> CountUsersAsync()
{
return await db.Users.CountAsync();
}
public async Task SaveAsync()
{
UpdateEntities();
await db.SaveChangesAsync();
}
Async Counterpart Families
LC008 uses a fixed sync-to-async map. If a method has no mapped async counterpart, LC008 does not guess at one and does not report it as a sync-over-async violation.
| Sync call | Suggested async call |
|---|---|
ToList, ToArray, ToDictionary, ToHashSet |
ToListAsync, ToArrayAsync, ToDictionaryAsync, ToHashSetAsync |
First, FirstOrDefault, Single, SingleOrDefault, Last, LastOrDefault |
matching *Async terminal |
Count, LongCount, Any, All, Min, Max, Sum, Average |
matching *Async aggregate |
SaveChanges |
SaveChangesAsync |
Find |
FindAsync |
ExecuteUpdate, ExecuteDelete |
ExecuteUpdateAsync, ExecuteDeleteAsync |
When an EF API truly has no async equivalent, the rule’s advice is not to wrap it in Task.Run. Keep that work outside the hot async request path, move the surrounding workflow to a synchronous boundary, or document the deliberate blocking call with a suppression.
Async Context Boundaries
LC008 reports inside:
async Task,async ValueTask, and other async methods- async lambdas
- non-async lambdas and capturing local functions nested inside an async method
LC008 intentionally ignores sync-looking LINQ operators inside IQueryable expression trees, such as query-expression subqueries in let, where, or projection clauses. Those calls are translated as SQL subqueries instead of blocking the async method directly.
public async Task QueryAsync()
{
var query =
from user in db.Users
let matches = db.Users.Where(inner => inner.Id == user.Id).ToList()
select new { user, matches };
await Task.Delay(1);
}
The async-context walk treats a static local function as a hard synchronous boundary: it cannot capture the enclosing async context, await is illegal inside it, and a diagnostic there would be unfixable, so a sync EF call inside a static local function declared in an async method stays quiet. A capturing non-static local function or lambda inside an async method still flags because it is refactoring debt the rule deliberately surfaces, and an async static local function flags too because it can await the async counterpart.
public async Task LoadAsync()
{
var users = Load(db);
await Task.Delay(1);
static List<User> Load(AppDbContext db)
{
return db.Users.ToList(); // no LC008: static local synchronous boundary
}
}
Fixer Behavior
The fixer is intentionally narrow. It replaces the method name with the mapped async name and wraps the invocation in await only when await is legal at that syntax location.
db.Users.ToList()becomesawait db.Users.ToListAsync().db.SaveChanges()becomesawait db.SaveChangesAsync().- When the sync call feeds a following member, element, invocation, or null-conditional access, the fixer parenthesizes the awaited result:
db.Users.ToList().Countbecomes(await db.Users.ToListAsync()).Count, anddb.Users.FirstOrDefault()?.Namebecomes(await db.Users.FirstOrDefaultAsync())?.Name. - Query-expression subqueries that are part of the provider expression do not report and therefore do not offer a fix.
- Diagnostics inside non-async lambdas or non-async local functions can report, but the fixer stays quiet because inserting
awaitthere would not compile without refactoring the delegate/local-function shape. - The fixer does not add cancellation tokens or choose overloads; pass tokens explicitly after the rewrite when the surrounding code has one.
Analyzer Logic
- Reports only mapped sync methods on EF Core
DbContext,DbSet, orIQueryablesources. - Does not report plain in-memory
IEnumerablework or synchronous methods outside async contexts. - Does not report sync-looking operators that are part of an
IQueryableexpression tree and will be translated by the provider.