C# / .NET/02core12 min

async/await, Task and CancellationToken

Asynchrony in .NET is a powerful tool, but it has its traps: deadlocks with .Result, missing cancellation, sneaky async void. This lesson gathers the ProfessNet house rules for async code in C#.

Task and async/await

An async method returns Task (no result) or Task<T> (with a result). await does not block the thread — it releases it for the duration of the I/O operation.

public async Task<ScanResult> RunScanAsync(string forest)
{
    var hosts = await _repository.GetHostsAsync(forest);
    var result = await _scanner.ScanAsync(hosts);
    return result;
}

Never .Result or .Wait()

Blocking on asynchronous code causes deadlocks (especially in an ASP.NET context) and wastes threads.

// bad — risk of deadlock, blocks the thread
var result = RunScanAsync(forest).Result;

// good — await all the way up
var result = await RunScanAsync(forest);

ProfessNet standard: the "async all the way" rule. If a method calls something asynchronous, it is itself async and propagates that to the caller. .Result and .Wait() are forbidden in application code.

CancellationToken propagates everywhere

Every public async method accepts a CancellationToken and passes it on. This makes long operations cancellable (request shutdown, timeout).

public async Task<ScanResult> RunScanAsync(
    string forest,
    CancellationToken cancellationToken = default)
{
    var hosts = await _repository.GetHostsAsync(forest, cancellationToken);
    cancellationToken.ThrowIfCancellationRequested();
    return await _scanner.ScanAsync(hosts, cancellationToken);
}

In ASP.NET Core you get the token from the action parameter:

[HttpPost("scan")]
public async Task<IActionResult> Scan(ScanRequest req, CancellationToken ct)
{
    var result = await _service.RunScanAsync(req.Forest, ct);
    return Accepted(result);
}

ProfessNet standard: CancellationToken is the last parameter of an async method, defaulting to default. We pass it to every subsequent async call.

async void only in event handlers

async void cannot be awaited and its exceptions cannot be caught — it is a source of silent failures. Everywhere except event handlers we use Task.

// bad — the exception disappears, you can't await it
public async void Process() { await DoWork(); }

// good
public async Task ProcessAsync() { await DoWork(); }

Parallelism: Task.WhenAll

We run independent operations concurrently.

var tasks = forests.Select(f => ScanForestAsync(f, ct));
ScanResult[] results = await Task.WhenAll(tasks);
ConstructWhen
awaita single async operation
Task.WhenAllmany operations concurrently
CancellationTokenevery public async method
ConfigureAwait(false)library code (without a UI/request context)

Tip: in libraries (not in ASP.NET) use ConfigureAwait(false) so you don't return to the original synchronization context — this is a small performance gain and protection against deadlocks.


Async all the way, zero .Result, a cancellation token in every method. Stick to this and async code in .NET will be efficient, cancellable and free of deadlocks.