Async concurrency review
Skill tunahanaliozturk/secure-dotnet-skills/skills/async-concurrency-review
Aegis — 12 judgment-style agent skills for secure, production-grade .NET on Azure (security, design, performance, concurrency, observability). Works with Claude Code, Codex, Cursor, Gemini.
npx -y skills add tunahanaliozturk/secure-dotnet-skills --skill async-concurrency-reviewAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
One thing to look at
- 1 stars1 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.
What its author says it does
Copied from the file, not written here
Use when reviewing asynchronous and concurrent .NET code — async/await correctness, deadlocks, cancellation propagation, and thread safety of shared state.
SKILL.md
12.1 KB, as published. Nobody here has run it
Async / Concurrency Review
Directs the agent to walk .NET / ASP.NET Core async and concurrent code through concrete correctness lenses — sync-over-async deadlock risk, cancellation propagation, shared-state safety, fire-and-forget hazards, and parallel-execution patterns — surfacing defects with precise fixes.
When to use
- A PR adds or modifies
async/awaitcode, background work, or concurrent operations in a .NET service. - Code uses
Task.WhenAll,Parallel.ForEachAsync, or any form of parallelism over shared resources. - A service is deadlocking, hanging under load, or losing exceptions silently.
- A
DbContext,staticfield, or other non-thread-safe resource is accessed from multiple threads or concurrent tasks.
Process
- Map the async call chains and shared mutable state. Trace each
asyncmethod from its entry point (controller action,IHostedService, message handler) to its leaves. Note any non-asynccall sites that block onTaskresults, any shared objects accessed across concurrent paths, and any fire-and-forget launches. - Check for sync-over-async and deadlock risk. Find every
.Result,.Wait(), andGetAwaiter().GetResult()call. For each, determine whether a synchronization context is present (classic ASP.NET, WinForms, WPF, MAUI all have one; ASP.NET Core does not by default). Even where deadlock is not imminent, blocking wastes a thread-pool thread for the full I/O duration — identify and convert toawait. - Check cancellation propagation. Confirm that every method doing I/O (EF Core queries,
HttpClientcalls, file I/O,Task.Delay) accepts aCancellationTokenparameter, passes it downstream, and — where the work is a loop — checksct.ThrowIfCancellationRequested()(or equivalent) at each iteration boundary. - Check thread safety of shared state. For each object that could be reached from two concurrent tasks or threads, verify it is thread-safe. Flag
DbContextinstances captured acrossTask.WhenAllbranches (throwsInvalidOperationException: "A second operation was started on this context before a previous operation completed"),staticmutable fields, and unsynchronized shared collections. Where alockis present, verify it does not span anawait— that is a compile error in C# but the intention (mutual exclusion around async work) must be redirected toSemaphoreSlim. - Check fire-and-forget patterns. Find
_ = DoAsync(), unawaited method calls, andTask.Run(() => ...)whose result is discarded. Determine how exceptions are observed. A fire-and-forget task whose exception is never observed will be silently swallowed (.NET 4.5+); the work may also outlive the request or host shutdown. Additionally, fire-and-forget work that captures scoped services (e.g. a scopedDbContext) frequently FAILS withObjectDisposedExceptionwhen the request scope is disposed — so the failure mode is often a crash, not just an untracked success. Replace with a safe background pattern: channel +IHostedServiceconsumer, orIBackgroundTaskQueue. - Recommend fixes with correct .NET APIs. For each finding, name the concrete replacement:
awaitover.Result;SemaphoreSlim.WaitAsyncoverlock+await;IDbContextFactory<TContext>for per-operation scoped contexts;Channel<T>orIHostedServicefor safe background work;Parallel.ForEachAsyncwith aParallelOptions.MaxDegreeOfParallelismfor throttled parallel I/O.
.NET / Azure checks
-
Sync-over-async:
.Result/.Wait()/GetAwaiter().GetResult(). All three block the calling thread until theTaskcompletes. In environments with aSynchronizationContext— classic ASP.NET, WinForms, WPF — this causes a deadlock:awaitcaptures the context and tries to resume on it, but.Result/.Wait()is holding the context's single permitted thread, so the continuation can never run. ASP.NET Core does not install a single-threadedSynchronizationContext, so the classic deadlock does not occur there; however, blocking still wastes a thread-pool thread for the full I/O duration, reducing throughput under load. The fix in all environments is to goasyncall the way to the entry point. -
async voidoutside event handlers.async voidmethods are only legitimate for event handlers (Button.Click +=,ICommand.Executeimplementations) where the delegate signature requiresvoid. For all other methods,async voidhas two defects: (1) exceptions thrown after the firstawaitare raised on the thread-pool synchronization context and are unobserved — in .NET 6+ they crash the process viaUnhandledException; (2) callers cannotawaitthe method, so they have no way to know when it completes or whether it succeeded. Replace withasync Task. -
ConfigureAwait(false)in library code. In a library (a NuGet package or shared class library consumed by multiple app types),await someTaskwithout.ConfigureAwait(false)captures the caller'sSynchronizationContextand resumes on it. In a classic ASP.NET or UI host, this can cause deadlock when combined with.Resultupstream, and always incurs a context-switch overhead. Call.ConfigureAwait(false)on everyawaitin library code that does not need to resume on the original context. In ASP.NET Core application code (controllers, Razor pages, minimal API handlers),.ConfigureAwait(false)is not required because ASP.NET Core does not install a blocking single-threaded context — omitting it is fine and reduces noise. -
CancellationTokenaccepted and propagated through the chain. Every method that performs I/O —DbContextqueries,HttpClientcalls,Task.Delay, file reads — must accept aCancellationTokenparameter and pass it to every downstream async call. A token that is accepted but not passed toToListAsync(ct),SendAsync(request, ct), orTask.Delay(ms, ct)provides no cancellation benefit and misleads callers. For long-running loops, callct.ThrowIfCancellationRequested()at the top of each iteration, or usect.IsCancellationRequestedwith a graceful break, so that cancellation is honored promptly rather than only between I/O calls. -
Fire-and-forget swallowing exceptions. A discarded
Task(_ = DoAsync(), an un-awaited call, orTask.Run(...)whose result is not stored and awaited) means any exception thrown after the firstawaitis silently lost — it is placed on the task and never observed. The work also continues past request completion, pastIApplicationLifetime.ApplicationStopping, and past host shutdown. The safe pattern is aChannel<T>(unbounded or bounded) written to by the request handler and drained by aBackgroundService(IHostedService) consumer that observes exceptions and respectsCancellationTokenon shutdown. -
Shared mutable state without synchronization. Objects accessed from multiple concurrent tasks without synchronization produce data races.
Dictionary<TKey,TValue>is not thread-safe — concurrent reads during a write can corrupt its internal state; useConcurrentDictionary<TKey,TValue>.staticmutable fields (counters, caches, configuration that mutates) must be protected withInterlocked,lock, or a thread-safe type. Non-thread-safe state machines or domain objects must be confined to a single task at a time. -
DbContextnot thread-safe acrossTask.WhenAll.DbContextis explicitly documented as not thread-safe; concurrent operations on the same instance throwInvalidOperationException: "A second operation was started on this context instance before a previous operation completed. This is usually caused by different threads concurrently using the same instance of DbContext."A common mistake is capturing a single injectedDbContextin a closure and then fanning it out acrossTask.WhenAll. The correct pattern isIDbContextFactory<TContext>(registered viaAddDbContextFactory<TContext>): callawait factory.CreateDbContextAsync(ct)inside each parallel branch,awaitits work, and dispose it — each branch owns a fully independent context and connection. -
lockcannot wrapawait. C# prohibitsawaitinside alockblock at the compiler level (CS1996). The intent — mutual exclusion around an async critical section — must be fulfilled bySemaphoreSliminstead:await semaphore.WaitAsync(ct)before the critical section andsemaphore.Release()in afinallyblock after it.SemaphoreSliminitialized to(1, 1)provides the same mutual-exclusion semantics aslockfor async code. -
Task.WhenAll/Parallel.ForEachAsyncwith throttling. Unbounded parallelism —Task.WhenAllover a large sequence without a concurrency cap — can exhaust the thread pool, open too many database connections, or overwhelm a downstream service. UseParallel.ForEachAsync(introduced in .NET 6) withParallelOptions { MaxDegreeOfParallelism = N, CancellationToken = ct }to process a sequence with bounded concurrency. For a batch of known tasks, useSemaphoreSlimas a gate: acquire before launching each task, release inside the task body. Always pair parallelism with aCancellationTokenso the fan-out can be aborted on shutdown or timeout.
Red flags
| Signal | Why it matters |
|---|---|
.Result, .Wait(), or GetAwaiter().GetResult() in a request handler or service method | Blocks a thread-pool thread for the full I/O duration; deadlocks in any host with a single-threaded SynchronizationContext (classic ASP.NET, WinForms, WPF). Go async all the way. |
async void on a method that is not an event handler | Exceptions after the first await are unobserved and crash the process (UnhandledException) or silently disappear. Callers cannot await it. Return async Task. |
lock block containing an await expression | Does not compile (CS1996); the intent (async mutual exclusion) requires SemaphoreSlim.WaitAsync + Release in finally instead. |
A single DbContext instance captured across Task.WhenAll branches | Concurrent operations on DbContext throw InvalidOperationException: "A second operation was started…". Use IDbContextFactory<TContext> to create one context per parallel branch. |
_ = DoAsync() or an un-awaited Task-returning call | Exceptions are silently swallowed; the work outlives the request and ignores host-shutdown signals. Replace with a Channel<T> + IHostedService consumer. |
A method performing I/O with no CancellationToken parameter | Cancellation signals from the HTTP request or host shutdown are not honored — the operation runs to completion even after the caller has given up, wasting resources. Accept and propagate CancellationToken. |
static mutable field written from multiple tasks or threads | Data races on non-atomic types corrupt state silently. Protect with Interlocked, lock, or replace with ConcurrentDictionary / IMemoryCache. |
Task.WhenAll over an unbounded sequence without a concurrency cap | Can open hundreds of database connections or HTTP connections simultaneously, overwhelming the downstream resource. Use Parallel.ForEachAsync with MaxDegreeOfParallelism or a SemaphoreSlim gate. |
CancellationToken accepted by a method but not forwarded to ToListAsync, SendAsync, or Task.Delay | The token is accepted but ignored — cancellation has no effect on the I/O. Pass the token to every async call in the chain. |
ConfigureAwait(false) absent in a shared library that also has .Result callers upstream | The missing .ConfigureAwait(false) captures the caller's SynchronizationContext; combined with a .Result upstream, this creates a deadlock in classic ASP.NET or UI hosts. |
Example
See examples/async-concurrency-review/.
Related skills
- dotnet-performance-review — use for broader performance review covering allocations, LINQ, caching, and serialization.
- resilience-review — use to review timeout and cancellation handling on downstream calls.