Await Anything
These extensions make everyday values work with await and await foreach. Reach for them when the code you want to write is "wait for this thing" and the thing is not already a Task: a delay, a shutdown signal, a batch of tasks, a lock, a child process or a timer.
At a glance
| Method | What it does |
|---|---|
await timeSpan | Waits for the delay, like Task.Delay. |
await cancellationToken | Waits until cancellation is requested. Does not throw. |
await tasks (IEnumerable<Task<T>>) | Waits for every task and returns the results in input order. |
await tasks (IEnumerable<Task>) | Waits for every task. |
await waitHandle | Waits for a ManualResetEvent, AutoResetEvent or Semaphore without blocking a thread. |
await (task1, task2, ...) | Waits for 2 to 5 tasks concurrently and returns a typed tuple. |
await lazyTask | Starts a Lazy<Task<T>> or Lazy<Task> on the first await and shares it afterwards. |
LockAsync | Waits for a SemaphoreSlim and returns a handle that releases it. |
Lock | The blocking version of LockAsync. |
await semaphore | Same as await semaphore.LockAsync(). |
await asyncEnumerable | Reads an IAsyncEnumerable<T> to the end into a List<T>. |
await process | Waits for a Process and returns its exit code. |
WaitForExitCodeAsync | Waits for a Process with a cancellation token and returns its exit code. |
await foreach over a TimeSpan | A periodic timer: yields 1, 2, 3, ... every interval until you break. |
Every() | The same periodic timer as an IAsyncEnumerable<long>. |
Every(cancellationToken) | A periodic timer that ends quietly when the token is cancelled. |
How it works
The C# compiler does not require await x to be a Task. It only requires that x has a method named GetAwaiter() that returns an awaiter (an object with IsCompleted, OnCompleted and GetResult). An extension method counts. So once the library declares this method:
public static TaskAwaiter GetAwaiter(this TimeSpan delay)
{
return Task.Delay(delay).GetAwaiter();
}
this line compiles, and means await Task.Delay(TimeSpan.FromSeconds(2)):
await 2.Seconds();
await foreach works the same way. The compiler looks for a GetAsyncEnumerator() method, and since C# 9 an extension method is accepted. That is how await foreach (long tick in 5.Seconds()) becomes a periodic timer.
Every awaitable on this page hands back a standard TaskAwaiter or TaskAwaiter<T>, so exceptions, cancellation and stack traces behave exactly as they do for a normal Task.
The extensions live in the namespace of the type they extend, so you rarely need a using:
| Extension class | Namespace | Extends |
|---|---|---|
AwaitableExtensions | System.Threading.Tasks | TimeSpan, CancellationToken, task sequences, WaitHandle, Lazy<Task>, SemaphoreSlim, IAsyncEnumerable<T> |
TaskTupleExtensions | System.Threading.Tasks | tuples of 2 to 5 tasks |
SemaphoreSlimExtensions | System.Threading | SemaphoreSlim (LockAsync, Lock) |
ProcessExtensions | System.Diagnostics | Process |
System.Threading and System.Threading.Tasks are in the default implicit usings of SDK-style projects. System.Diagnostics is not, but you already import it to use Process.
These awaiters capture the current synchronization context, like a plain await task. There is no ConfigureAwait(false) form. In library code that must not resume on the caller's context, call the underlying API instead, for example await Task.Delay(delay).ConfigureAwait(false).
Delays and signals
await timeSpan
Awaiting a TimeSpan waits for that long. It is Task.Delay(delay) with less noise. It reads best with the duration helpers from Durations, such as Seconds() and Milliseconds().
public async Task<Invoice> SubmitWithPauseAsync(Invoice invoice)
{
InvoiceResult first = await _gateway.SubmitAsync(invoice);
if (first.IsAccepted)
{
return first.Invoice;
}
await 500.Milliseconds(); // same as await Task.Delay(500)
InvoiceResult second = await _gateway.SubmitAsync(invoice);
return second.Invoice;
}
await TimeSpan.FromSeconds(2); // not only the helpers
await TimeSpan.Zero; // completes at once
- A negative
TimeSpanthrowsArgumentOutOfRangeExceptionwithParamName"delay". Timeout.InfiniteTimeSpan(minus one millisecond) is allowed byTask.Delayand waits forever.- There is no way to pass a cancellation token. When the wait must be cancellable, use
await Task.Delay(delay, cancellationToken).
For retries with exponential backoff, use Retry.ExecuteAsync from Tasks instead of a hand-written pause.
await cancellationToken
Awaiting a CancellationToken waits until cancellation is requested, then completes normally. It does not throw OperationCanceledException. Use it for "run until shutdown" code, such as a hosted service that starts something and then idles until the host stops.
public sealed class PriceFeedService : BackgroundService
{
private readonly IPriceFeed _feed;
public PriceFeedService(IPriceFeed feed)
{
_feed = feed;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
await _feed.SubscribeAsync();
await stoppingToken; // waits here until the host stops; does not throw
await _feed.UnsubscribeAsync();
}
}
CancellationToken cancelled = new CancellationToken(canceled: true);
await cancelled; // completes at once, without throwing
- A token that can never be cancelled, such as
CancellationToken.Noneordefault, never completes. The await hangs forever. - When the token is not cancelled yet, each await registers a callback on it. The callback stays registered until the token is cancelled, so await a long-lived token once (for example at the end of
ExecuteAsync), not inside a loop.
await waitHandle
Awaiting a WaitHandle waits for it to be signaled without blocking a thread. It uses ThreadPool.RegisterWaitForSingleObject and unregisters the wait once the handle is signaled. It works with handles that have no thread affinity: ManualResetEvent, AutoResetEvent and Semaphore.
using ManualResetEvent migrationsApplied = new ManualResetEvent(initialState: false);
Task acceptOrders = Task.Run(async () =>
{
await migrationsApplied; // no thread is blocked while waiting
await _orderQueue.StartAsync();
});
await _database.MigrateAsync();
migrationsApplied.Set();
await acceptOrders;
Waiting on an AutoResetEvent or a Semaphore takes the signal or a count, just as WaitOne() would:
using AutoResetEvent jobQueued = new AutoResetEvent(initialState: false);
jobQueued.Set();
await jobQueued;
bool stillSignaled = jobQueued.WaitOne(0); // → false
- A handle that is already signaled completes at once.
nullthrowsArgumentNullExceptionwithParamName"handle".- There is no timeout or cancellation parameter. The wait lasts until the handle is signaled.
A Mutex is rejected with ArgumentException (ParamName "handle"). A thread-pool thread would acquire it, and your code could never release it, because a mutex must be released by the thread that owns it. For async mutual exclusion use a SemaphoreSlim with LockAsync.
Many tasks at once
await a sequence of tasks
Awaiting an IEnumerable<Task<T>> waits for all of the tasks and returns a T[] in the same order as the sequence, whatever order the tasks finish in. It is await Task.WhenAll(tasks). Awaiting an IEnumerable<Task> does the same for tasks without results.
int[] orderIds = { 1001, 1002, 1003 };
Order[] orders = await orderIds.Select(id => _orderClient.GetOrderAsync(id));
int firstId = orders[0].Id; // → 1001 (input order, not completion order)
int[] quantities = { 1, 2, 3 };
int[] lineTotals = await quantities.Select(async quantity =>
{
await Task.Yield();
return quantity * 10;
});
// lineTotals → [10, 20, 30]
await overdueInvoices.Select(invoice => _mailer.SendReminderAsync(invoice));
- An empty sequence completes at once. For
Task<T>it returns an empty array. nullthrowsArgumentNullExceptionwithParamName"tasks".- If a task fails, the await throws that task's exception. With several failures it throws the one
Task.WhenAllreports first: forTask<T>the first failed task in sequence order; for plainTaskon .NET 10, the task that failed first in time. It still waits for every task to finish first. - The sequence is enumerated once. A lazy
Selectstarts its tasks at that moment, all of them at the same time.
To limit how many run at once, use SelectAsync(..., maxConcurrency: 4). To get every result and every error without throwing, use WhenAllSettled. Both are on the Tasks page.
await a tuple of tasks
Awaiting a tuple of 2 to 5 tasks waits for all of them and returns their results as a tuple with the right types. It is Task.WhenAll without reading each .Result by hand. Tuples of plain Task (no result) are supported too, also from 2 to 5.
(Customer customer, Order[] orders) = await (
_customers.GetAsync(customerId),
_orders.GetForCustomerAsync(customerId));
(int itemCount, string status, bool isPaid) = await (
Task.FromResult(3),
Task.FromResult("Shipped"),
Task.FromResult(true));
// itemCount → 3
// status → "Shipped"
// isPaid → true
await (_audit.WriteAsync(entry), _events.PublishAsync(orderPlaced));
- The tasks start when the tuple expression is evaluated, before the await. That is what makes them run concurrently. Do not use a tuple when one call needs the result of another.
- A
nulltask throwsArgumentNullExceptionwithParamName"tasks". - If a task fails, the await throws its exception, after every task has finished. With several failures it throws the one
Task.WhenAllreports first, which on .NET 10 is the task that failed first in time, not necessarily the first in tuple order. - For more than five tasks, use a sequence of tasks.
await lazyTask
Awaiting a Lazy<Task<T>> creates the task on the first await (which starts the work) and returns the same task to every later await. This is the usual "async lazy" pattern for expensive one-time loads. Lazy<Task> (no result) works the same way.
public sealed class PricingService
{
private readonly Lazy<Task<PriceList>> _priceList;
public PricingService(IPriceListClient client)
{
_priceList = new Lazy<Task<PriceList>>(() => client.DownloadAsync());
}
public async Task<decimal> GetPriceAsync(string sku)
{
PriceList prices = await _priceList; // the first call downloads; later calls reuse that task
return prices[sku];
}
}
int downloads = 0;
Lazy<Task<int>> taxRate = new Lazy<Task<int>>(() =>
{
downloads++;
return Task.FromResult(21);
});
int first = await taxRate; // → 21
int second = await taxRate; // → 21
// downloads → 1
nullthrowsArgumentNullExceptionwithParamName"lazyTask".- The default
Lazy<T>constructor is thread-safe (LazyThreadSafetyMode.ExecutionAndPublication), so concurrent first awaits still run the factory once.
A failed task is cached too. If the first load throws, every later await throws the same exception. If you need a retry after failure, replace the Lazy instance when the load fails.
Locks
C# does not allow await inside a lock statement. A SemaphoreSlim(1, 1) is the usual async lock, but WaitAsync plus try/finally plus Release is easy to get wrong. These methods return an IDisposable handle, so a using block does the release for you.
LockAsync
LockAsync(cancellationToken = default) waits for the semaphore and returns a handle. Disposing the handle releases the semaphore.
private readonly SemaphoreSlim _ledgerLock = new SemaphoreSlim(1, 1);
public async Task PostAsync(LedgerEntry entry, CancellationToken cancellationToken)
{
using (await _ledgerLock.LockAsync(cancellationToken))
{
// only one caller at a time here, and await is allowed inside
await _store.AppendAsync(entry, cancellationToken);
}
}
using SemaphoreSlim semaphore = new SemaphoreSlim(1, 1);
int countInside;
using (await semaphore.LockAsync())
{
countInside = semaphore.CurrentCount; // → 0
}
int countAfter = semaphore.CurrentCount; // → 1
- Disposing the handle twice releases the semaphore once. A stale handle disposed again later does not release the next holder's lock.
- If the token is cancelled before the semaphore is free, the call throws
OperationCanceledExceptionand the semaphore is not taken. nullthrowsArgumentNullExceptionwithParamName"semaphore".
SemaphoreSlim is not re-entrant. Calling LockAsync again on the same semaphore inside the using block waits forever.
Lock
Lock(cancellationToken = default) is the blocking version. It calls semaphore.Wait(cancellationToken) and returns the same kind of handle. Use it only in synchronous code that shares a semaphore with async code. In async code, prefer LockAsync.
public void ResetLedger()
{
using (_ledgerLock.Lock())
{
_store.Clear();
}
}
The handle, cancellation and null rules are the same as for LockAsync.
await semaphore
Awaiting a SemaphoreSlim is shorthand for await semaphore.LockAsync(). It returns the same release-once handle. It takes no cancellation token, so use LockAsync(cancellationToken) when the wait must be cancellable.
using (await _ledgerLock)
{
await _store.AppendAsync(entry, cancellationToken);
}
IDisposable handle = await _ledgerLock;
handle.Dispose();
handle.Dispose(); // no effect: released once
int count = _ledgerLock.CurrentCount; // → 1
null throws ArgumentNullException with ParamName "semaphore".
Streams and processes
await asyncEnumerable
Awaiting an IAsyncEnumerable<T> reads it to the end and returns a List<T> with every item in stream order. Use it when you want the whole result and do not need to process items one by one.
async IAsyncEnumerable<int> ReadInvoiceNumbersAsync()
{
for (int number = 1; number <= 3; number++)
{
await Task.Yield();
yield return number;
}
}
List<int> invoiceNumbers = await ReadInvoiceNumbersAsync(); // → [1, 2, 3]
- An empty stream returns an empty list.
nullthrowsArgumentNullExceptionwithParamName"source".- There is no cancellation. To cancel, enumerate instead:
await foreach (Order order in GetOrdersAsync().WithCancellation(token)).
The stream is read to its end. Never await an endless stream, such as the timer from Every(): the await never completes. Any type that implements IAsyncEnumerable<T> gets this behavior, so awaiting a large source (for example an EF Core DbSet<TEntity>) loads every item into memory.
await process
Awaiting a Process waits for it to exit and returns its exit code. It is useful in build tools, deployment scripts and tests that run a command-line program.
using Process version = Process.Start("dotnet", "--version");
int exitCode = await version; // → 0
ProcessStartInfo startInfo = new ProcessStartInfo("git", "pull --ff-only")
{
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
};
using Process? pull = Process.Start(startInfo);
int exitCode = await pull;
if (exitCode != 0)
{
throw new InvalidOperationException($"git pull failed with exit code {exitCode}.");
}
- The receiver is
Process?becauseProcess.Start(ProcessStartInfo)can returnnull. Anullprocess throwsArgumentNullExceptionwithParamName"process"when awaited. - A process that has already exited returns its exit code at once.
- There is no cancellation. Use
WaitForExitCodeAsyncwhen you need a timeout.
WaitForExitCodeAsync
WaitForExitCodeAsync(cancellationToken) waits for the process to exit and returns its exit code, with a cancellation token. The token has no default value; pass CancellationToken.None if you have none.
using Process build = Process.Start("dotnet", "build --configuration Release");
using CancellationTokenSource timeout = new CancellationTokenSource(TimeSpan.FromMinutes(5));
try
{
int exitCode = await build.WaitForExitCodeAsync(timeout.Token); // → 0 when the build succeeds
}
catch (OperationCanceledException)
{
build.Kill(entireProcessTree: true); // the process is still running; stop it yourself
}
- Cancelling stops the wait, not the process. After an
OperationCanceledExceptionthe process keeps running. - A token that is already cancelled throws
OperationCanceledExceptionif the process is still running. nullthrowsArgumentNullExceptionwithParamName"process", synchronously, when the method is called.
On the .NET 10 build this calls Process.WaitForExitAsync, which also waits for redirected output read through OutputDataReceived to finish. The netstandard2.1 build (used on .NET 8 and 9) waits for the Exited event, so the last output events may still be arriving when the await completes.
Periodic timers
await foreach over a TimeSpan
A TimeSpan can be the source of an await foreach. Each time the interval elapses, the loop body runs with the tick number: 1, 2, 3, and so on. The loop never ends on its own, so break when you are done. The first tick comes after one full interval, not at once.
await foreach (long tick in 5.Seconds())
{
ShipmentStatus status = await _carrier.GetStatusAsync(trackingNumber);
if (status == ShipmentStatus.Delivered || tick == 12)
{
break; // delivered, or one minute has passed
}
}
List<long> ticks = new List<long>();
await foreach (long tick in 15.Milliseconds())
{
ticks.Add(tick);
if (ticks.Count == 3)
{
break;
}
}
// ticks → [1, 2, 3]
The interval rules are the same as for Every().
Every()
Every() returns the same periodic timer as an IAsyncEnumerable<long>. await foreach (long tick in interval) and await foreach (long tick in interval.Every()) behave the same. The named form says what it does, and as an IAsyncEnumerable<long> it works with WithCancellation.
await foreach (long tick in 1.Minutes().Every().WithCancellation(stoppingToken))
{
await _reports.RefreshDashboardAsync();
}
// reached when stoppingToken is cancelled; no exception
- The interval is checked when you call the method, not when the loop starts. Zero, a negative value,
Timeout.InfiniteTimeSpanor more thanint.MaxValue - 1milliseconds (about 24.8 days) throwsArgumentOutOfRangeExceptionwithParamName"interval". - Cancelling through
WithCancellationends the loop quietly, the same asEvery(cancellationToken).
Every(cancellationToken)
Every(cancellationToken) is a periodic timer that ends quietly when the token is cancelled. The loop simply stops; no OperationCanceledException is thrown. It is made for the main loop of a BackgroundService.
public sealed class InventorySyncService : BackgroundService
{
private readonly IInventorySync _inventory;
public InventorySyncService(IInventorySync inventory)
{
_inventory = inventory;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
await foreach (long tick in 30.Seconds().Every(stoppingToken))
{
await _inventory.SyncAsync(stoppingToken);
}
// reached when the host stops
}
}
using CancellationTokenSource cancellation = new CancellationTokenSource();
List<long> ticks = new List<long>();
await foreach (long tick in 15.Milliseconds().Every(cancellation.Token))
{
ticks.Add(tick);
if (ticks.Count == 2)
{
cancellation.Cancel();
}
}
// ticks → [1, 2]
- A token that is already cancelled yields no ticks.
- Only the wait between ticks ends quietly. If your loop body passes the same token to other calls, those calls can still throw
OperationCanceledException. - The interval rules are the same as for
Every().
The .NET 10 build uses PeriodicTimer. Ticks do not drift, and a slow loop body skips ticks rather than catching up. The netstandard2.1 build (used on .NET 8 and 9) waits Task.Delay(interval) after each loop body, so the time spent in the body adds to the gap between ticks.
See also
- Tasks: timeouts, retries, fallbacks, bounded concurrency and
WhenAllSettled. - Durations:
Seconds(),Milliseconds()and the otherTimeSpanhelpers used above. - foreach and deconstruction: the same compiler pattern for
foreach,await foreachover aTextReader, and deconstruction. - Conventions: namespaces, null handling and exception rules shared by the whole library.