Agent Skill
2/7/2026

async

Stephen Cleary's async/await and concurrency patterns

O
objective
0GitHub Stars
2Views
npx skills add Objective-Arts/lens

SKILL.md

Nameasync
DescriptionStephen Cleary's async/await and concurrency patterns

name: async description: "Async/await and concurrency patterns"

Cleary: Async Done Right

Stephen Cleary's core belief: Async is about scalability, not performance. It frees threads to do other work while waiting. Get the patterns right, and async code is as simple as sync code.

The Foundational Principle

"Async all the way down. Don't block on async code."

The moment you block (.Result, .Wait()), you lose all benefits and risk deadlocks.


Core Principles

1. Async All the Way

Async must propagate through the entire call stack.

Not this:

// WRONG: Blocking on async
public string GetData()
{
    return GetDataAsync().Result;  // DEADLOCK RISK!
}

public string GetData()
{
    return GetDataAsync().GetAwaiter().GetResult();  // Still blocking
}

This:

// RIGHT: Async all the way up
public async Task<string> GetDataAsync()
{
    return await httpClient.GetStringAsync(url);
}

// Caller is also async
public async Task ProcessAsync()
{
    var data = await GetDataAsync();
    // ...
}

Why blocking deadlocks: In UI/ASP.NET contexts, await captures a synchronization context. When you block with .Result, the thread waits. When the async operation completes, it tries to resume on that same thread—which is blocked waiting. Deadlock.

2. Avoid async void

async void is fire-and-forget with no error handling.

Not this:

// WRONG: Exceptions vanish, caller can't await
async void ProcessData()
{
    await Task.Delay(1000);
    throw new Exception("Oops");  // Crashes the process!
}

This:

// RIGHT: Return Task for error propagation
async Task ProcessDataAsync()
{
    await Task.Delay(1000);
    throw new Exception("Oops");  // Caller can catch
}

The one exception: Event handlers must be async void:

// Event handlers - the only valid async void
private async void Button_Click(object sender, EventArgs e)
{
    try
    {
        await DoWorkAsync();
    }
    catch (Exception ex)
    {
        // Handle here since caller can't
        ShowError(ex.Message);
    }
}

3. Use CancellationToken

Every async operation should be cancellable.

public async Task<Data> FetchDataAsync(CancellationToken cancellationToken = default)
{
    // Pass token to all async calls
    var response = await httpClient.GetAsync(url, cancellationToken);

    // Check for cancellation in loops
    foreach (var item in items)
    {
        cancellationToken.ThrowIfCancellationRequested();
        await ProcessItemAsync(item, cancellationToken);
    }

    return result;
}

// Usage
var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30));
try
{
    var data = await FetchDataAsync(cts.Token);
}
catch (OperationCanceledException)
{
    // Handle timeout/cancellation
}

Cancellation patterns:

// Timeout
var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));

// Manual cancellation
var cts = new CancellationTokenSource();
cts.Cancel();

// Linked tokens (cancel if any source cancels)
var linked = CancellationTokenSource.CreateLinkedTokenSource(token1, token2);

// Register cleanup
cancellationToken.Register(() => CleanupResources());

4. ConfigureAwait(false) in Libraries

Library code shouldn't capture the synchronization context.

// APPLICATION code: Keep context (UI updates, HttpContext)
public async Task UpdateUIAsync()
{
    var data = await GetDataAsync();  // Default: captures context
    label.Text = data;                // Runs on UI thread
}

// LIBRARY code: Don't need context
public async Task<string> GetDataAsync()
{
    var response = await httpClient.GetAsync(url)
        .ConfigureAwait(false);  // Don't capture context

    return await response.Content.ReadAsStringAsync()
        .ConfigureAwait(false);
}

Rule of thumb:

  • Application code: Omit ConfigureAwait (keep context)
  • Library code: Always .ConfigureAwait(false)

5. Prefer Task.WhenAll for Concurrency

Don't await in loops when operations are independent.

Not this:

// SEQUENTIAL: Each awaits before next starts
var results = new List<Data>();
foreach (var id in ids)
{
    var data = await FetchAsync(id);  // One at a time
    results.Add(data);
}

This:

// CONCURRENT: All start immediately
var tasks = ids.Select(id => FetchAsync(id));
var results = await Task.WhenAll(tasks);

Error handling with WhenAll:

var tasks = ids.Select(id => FetchAsync(id)).ToList();

try
{
    var results = await Task.WhenAll(tasks);
}
catch
{
    // Only first exception thrown
    // Get all exceptions:
    var exceptions = tasks
        .Where(t => t.IsFaulted)
        .Select(t => t.Exception);
}

6. Use ValueTask for Hot Paths

When methods often complete synchronously, avoid Task allocation.

// Task: Always allocates
public async Task<int> GetValueAsync()
{
    if (cache.TryGetValue(key, out var value))
        return value;  // Still allocates Task<int>

    return await FetchFromDatabaseAsync();
}

// ValueTask: No allocation for sync path
public async ValueTask<int> GetValueAsync()
{
    if (cache.TryGetValue(key, out var value))
        return value;  // No allocation!

    return await FetchFromDatabaseAsync();
}

ValueTask rules:

  • Don't await multiple times
  • Don't use .Result/.Wait()
  • Don't use with Task.WhenAll (convert to Task first)
  • Only use when sync completion is common

7. Avoid Task.Run in ASP.NET

ASP.NET is already async. Task.Run just moves work to another thread.

Not this:

// WRONG in ASP.NET: Pointless thread hop
public async Task<IActionResult> GetData()
{
    var data = await Task.Run(() => ProcessData());  // Why?
    return Ok(data);
}

This:

// If ProcessData is CPU-bound and slow, just call it
public IActionResult GetData()
{
    var data = ProcessData();  // Direct call
    return Ok(data);
}

// If it's I/O-bound, make it async
public async Task<IActionResult> GetData()
{
    var data = await ProcessDataAsync();
    return Ok(data);
}

When Task.Run is appropriate:

  • UI applications: Keep UI responsive during CPU work
  • Wrapping sync I/O that can't be made truly async

Common Patterns

Async Initialization

public class Service
{
    private readonly AsyncLazy<Database> _db;

    public Service()
    {
        _db = new AsyncLazy<Database>(() => InitializeDatabaseAsync());
    }

    public async Task<Data> GetDataAsync()
    {
        var db = await _db;
        return await db.QueryAsync();
    }
}

// AsyncLazy implementation
public class AsyncLazy<T> : Lazy<Task<T>>
{
    public AsyncLazy(Func<Task<T>> factory)
        : base(() => Task.Run(factory)) { }

    public TaskAwaiter<T> GetAwaiter() => Value.GetAwaiter();
}

Async Disposal

public class Connection : IAsyncDisposable
{
    public async ValueTask DisposeAsync()
    {
        await CloseAsync();
        GC.SuppressFinalize(this);
    }
}

// Usage with await using
await using var connection = new Connection();
await connection.SendAsync(data);
// DisposeAsync called automatically

Async Streams

public async IAsyncEnumerable<Item> GetItemsAsync(
    [EnumeratorCancellation] CancellationToken cancellationToken = default)
{
    await foreach (var batch in GetBatchesAsync(cancellationToken))
    {
        foreach (var item in batch)
        {
            yield return item;
        }
    }
}

// Consuming
await foreach (var item in GetItemsAsync(cancellationToken))
{
    await ProcessAsync(item);
}

Semaphore for Throttling

private readonly SemaphoreSlim _semaphore = new(maxConcurrency: 10);

public async Task<Data> FetchWithThrottleAsync(string url)
{
    await _semaphore.WaitAsync();
    try
    {
        return await httpClient.GetAsync(url);
    }
    finally
    {
        _semaphore.Release();
    }
}

Anti-Patterns to Avoid

Sync Over Async

// NEVER DO THIS
public Data GetData()
{
    return GetDataAsync().Result;         // Blocks, deadlock risk
    return GetDataAsync().GetAwaiter().GetResult();  // Still blocks
    Task.Run(() => GetDataAsync()).Result;  // Thread pool exhaustion
}

Async Over Sync

// MISLEADING: Looks async, isn't
public Task<Data> GetDataAsync()
{
    return Task.FromResult(ComputeDataSync());  // Blocks caller's thread
}

// If you must wrap sync code, be explicit
public Task<Data> GetDataAsync()
{
    return Task.Run(() => ComputeDataSync());  // At least caller isn't blocked
}

Fire and Forget Without Handling

// BAD: Exception disappears
_ = DoSomethingAsync();

// BETTER: Handle errors
_ = DoSomethingAsync().ContinueWith(t =>
{
    if (t.IsFaulted)
        logger.LogError(t.Exception);
});

// BEST: Use a proper fire-and-forget helper
BackgroundTask.Run(async () =>
{
    await DoSomethingAsync();
});

The Cleary Test

Before committing async code, ask:

  1. Async all the way? No .Result, .Wait(), or .GetAwaiter().GetResult()?
  2. No async void? Except for event handlers?
  3. CancellationToken passed? Through the entire call chain?
  4. ConfigureAwait(false)? In library code?
  5. Concurrent where possible? Using WhenAll for independent operations?
  6. ValueTask appropriate? For hot paths with sync completion?

When to Apply

ScenarioApply Cleary
Any async/await codeYes
Cancellation patternsYes
Concurrent operationsYes
Deadlock debuggingYes
Thread synchronization (locks)Partially - async locks differ
Parallel CPU-bound workNo - use TPL/PLINQ

Source Material

  • "Concurrency in C# Cookbook" (2nd Edition, O'Reilly, 2019)
  • Blog: blog.stephencleary.com
  • AsyncEx library (authored by Cleary)
  • Microsoft async guidance contributions

"There is no thread." — Stephen Cleary (on understanding async)

Skills Info
Original Name:asyncAuthor:objective