Skip to main content

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​

MethodWhat it does
await timeSpanWaits for the delay, like Task.Delay.
await cancellationTokenWaits 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 waitHandleWaits 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 lazyTaskStarts a Lazy<Task<T>> or Lazy<Task> on the first await and shares it afterwards.
LockAsyncWaits for a SemaphoreSlim and returns a handle that releases it.
LockThe blocking version of LockAsync.
await semaphoreSame as await semaphore.LockAsync().
await asyncEnumerableReads an IAsyncEnumerable<T> to the end into a List<T>.
await processWaits for a Process and returns its exit code.
WaitForExitCodeAsyncWaits for a Process with a cancellation token and returns its exit code.
await foreach over a TimeSpanA 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:

AwaitableExtensions.cs (from the library)
public static TaskAwaiter GetAwaiter(this TimeSpan delay)
{
return Task.Delay(delay).GetAwaiter();
}

this line compiles, and means await Task.Delay(TimeSpan.FromSeconds(2)):

Your code
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 classNamespaceExtends
AwaitableExtensionsSystem.Threading.TasksTimeSpan, CancellationToken, task sequences, WaitHandle, Lazy<Task>, SemaphoreSlim, IAsyncEnumerable<T>
TaskTupleExtensionsSystem.Threading.Taskstuples of 2 to 5 tasks
SemaphoreSlimExtensionsSystem.ThreadingSemaphoreSlim (LockAsync, Lock)
ProcessExtensionsSystem.DiagnosticsProcess
note

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.

tip

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().

Pause before a second attempt
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;
}
Any TimeSpan works
await TimeSpan.FromSeconds(2); // not only the helpers
await TimeSpan.Zero; // completes at once
  • A negative TimeSpan throws ArgumentOutOfRangeException with ParamName "delay".
  • Timeout.InfiniteTimeSpan (minus one millisecond) is allowed by Task.Delay and waits forever.
  • There is no way to pass a cancellation token. When the wait must be cancellable, use await Task.Delay(delay, cancellationToken).
tip

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.

Keep a subscription open until shutdown
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();
}
}
An already cancelled token
CancellationToken cancelled = new CancellationToken(canceled: true);
await cancelled; // completes at once, without throwing
  • A token that can never be cancelled, such as CancellationToken.None or default, 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.

Start taking orders after migrations
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:

An AutoResetEvent signal is consumed
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.
  • null throws ArgumentNullException with ParamName "handle".
  • There is no timeout or cancellation parameter. The wait lasts until the handle is signaled.
warning

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.

Fetch several orders concurrently
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)
Results keep the input order
int[] quantities = { 1, 2, 3 };
int[] lineTotals = await quantities.Select(async quantity =>
{
await Task.Yield();
return quantity * 10;
});
// lineTotals → [10, 20, 30]
Tasks without results
await overdueInvoices.Select(invoice => _mailer.SendReminderAsync(invoice));
  • An empty sequence completes at once. For Task<T> it returns an empty array.
  • null throws ArgumentNullException with ParamName "tasks".
  • If a task fails, the await throws that task's exception. With several failures it throws the one Task.WhenAll reports first: for Task<T> the first failed task in sequence order; for plain Task on .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 Select starts its tasks at that moment, all of them at the same time.
tip

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.

Load a customer page concurrently
(Customer customer, Order[] orders) = await (
_customers.GetAsync(customerId),
_orders.GetForCustomerAsync(customerId));
Results come back in tuple order
(int itemCount, string status, bool isPaid) = await (
Task.FromResult(3),
Task.FromResult("Shipped"),
Task.FromResult(true));
// itemCount → 3
// status → "Shipped"
// isPaid → true
Tasks without results
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 null task throws ArgumentNullException with ParamName "tasks".
  • If a task fails, the await throws its exception, after every task has finished. With several failures it throws the one Task.WhenAll reports 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.

Download the price list once, on first use
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];
}
}
The factory runs once
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
  • null throws ArgumentNullException with ParamName "lazyTask".
  • The default Lazy<T> constructor is thread-safe (LazyThreadSafetyMode.ExecutionAndPublication), so concurrent first awaits still run the factory once.
warning

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.

One ledger write at a time
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);
}
}
The semaphore is held inside the block
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 OperationCanceledException and the semaphore is not taken.
  • null throws ArgumentNullException with ParamName "semaphore".
warning

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.

Synchronous access to the same lock
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.

The shortest form
using (await _ledgerLock)
{
await _store.AppendAsync(entry, cancellationToken);
}
Keeping the handle
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.

Read a whole async stream
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.
  • null throws ArgumentNullException with ParamName "source".
  • There is no cancellation. To cancel, enumerate instead: await foreach (Order order in GetOrdersAsync().WithCancellation(token)).
warning

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.

Run a command and check the exit code
using Process version = Process.Start("dotnet", "--version");
int exitCode = await version; // → 0
Fail a deployment step on a non-zero exit code
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? because Process.Start(ProcessStartInfo) can return null. A null process throws ArgumentNullException with ParamName "process" when awaited.
  • A process that has already exited returns its exit code at once.
  • There is no cancellation. Use WaitForExitCodeAsync when 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.

Give a build five minutes
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 OperationCanceledException the process keeps running.
  • A token that is already cancelled throws OperationCanceledException if the process is still running.
  • null throws ArgumentNullException with ParamName "process", synchronously, when the method is called.
info

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.

Poll a shipment until it is delivered
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
}
}
Tick numbers
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.

A timer with a token passed through 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.InfiniteTimeSpan or more than int.MaxValue - 1 milliseconds (about 24.8 days) throws ArgumentOutOfRangeException with ParamName "interval".
  • Cancelling through WithCancellation ends the loop quietly, the same as Every(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.

Sync inventory every 30 seconds until shutdown
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
}
}
Cancelled inside the loop
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().
info

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 other TimeSpan helpers used above.
  • foreach and deconstruction: the same compiler pattern for foreach, await foreach over a TextReader, and deconstruction.
  • Conventions: namespaces, null handling and exception rules shared by the whole library.