A task represents completion
A Task lets you track an operation that may succeed, fault or be canceled. Task<int> adds an integer result on success; it is not the integer itself. In var count = await pending, count has type int if pending is Task<int>.
Calling an async method starts executing its body and may reach an await whose operand is still incomplete. In that situation, the method suspends and returns control to its caller. If the task is already complete, await need not suspend.
This model promises neither a new thread for each operation nor automatic parallelism for CPU code. It also does not protect data shared between calls. When reading a snippet, identify what work started, which result the next statement depends on and which entity observes completion.
Return completion the caller can observe
A library that saves data should let its caller know when the save finished and whether it failed. An async Task method supplies that observation point; the caller can await it in an appropriate try. An async void method supplies no task and is normally reserved for event handlers whose contract requires void.
Wrapping only its invocation in try/catch does not let the caller handle that method’s asynchronous failures. Returning true immediately after starting work does not prove completion either. Work can be started and retained for later observation, but the application must define who owns that task and its errors.
The Async suffix is a useful convention; alone it neither defines the contract nor guarantees that the entire body avoids blocking calls.
Combine tasks without losing results and failures
Task.WhenAll tracks every supplied task. If first produces A and second produces B, await Task.WhenAll(first, second) supplies results in that order even if second finishes first. It is not a list sorted by response speed.
The combined task finishes only after all of them; if any faults, it is Faulted. Without faults, any cancellation determines Canceled. To build a failure inventory, retain the combined task and inspect its Exception after confirming Faulted.
That AggregateException contains the supplied tasks’ retained failures; one message recorded by a handler does not demonstrate that only one failure occurred. Also do not assume WhenAll limits concurrency: supplying many already-started operations does not automatically create a capacity limit.
Cancellation is a cooperative request
The owner of a CancellationTokenSource can request cancellation; operations receive its token and decide how to respond under their contract. Passing the token does not prove that every layer observes it. In a loop, an explicit ThrowIfCancellationRequested checkpoint can prevent the next item from starting when a request is already signaled.
It throws OperationCanceledException associated with that token. A called API should receive the token if its contract supports that mechanism; creating a new unrelated source does not transmit the earlier request. Do not confuse a request with terminal state: work that ignores the request can continue.
Even an operation that ends by cancellation may have produced effects before stopping. The owner must manage the source and dispose it when its use ends, without disposing it while other participants still depend on it.
Wait timeout and operation outcome
WaitAsync can bound a wait for a Task. If the limit expires, the task representing that wait can fault with TimeoutException while the original task remains pending. This distinction matters for a remote call: stopping the wait does not prove that the server stopped working.
For recovery, retain operation identity and use the application protocol to query status or retry idempotently. The no-duplication guarantee must come from that protocol, not from await. If the service supplies neither status lookup nor stable identity, state the uncertainty instead of declaring success or rollback without evidence.
Also do not discard references to active work without defining who tracks it and who retains the resources it still needs.
The resource must outlive dependent work
An ordinary method that opens a reader with using and returns an incomplete Task immediately leaves the reader’s scope. If that task still depends on the reader, it encounters a resource disposed too early. When the function owns the resource, making the method async and using return await inside using retains the scope until completion is observed.
This decision also determines where exceptions are handled. A local variable that merely holds the same Task does not extend the scope. The reasoning also applies to a concurrency permit: return it only after the work it protects finishes.
If you time out the wait and immediately release the reader or permit, the original operation may still use the resource or consume capacity. Waiting and operating need coherent lifetime contracts.
Guided reading: acquire, await, release
The example shares one SemaphoreSlim with a limit of two across calls to the class. Its owner retains it for the service lifetime; it is not disposed while calls are pending. WaitAsync(token) occurs before try.
If that wait is canceled before acquisition, it does not enter finally and does not return a nonexistent permit. After acquisition, finally returns exactly one permit whether work succeeds or throws. The delegate is valid, follows the Task contract and represents all protected work; if it launches independent tasks and returns before them, the limit no longer covers those tasks.
Cancellation is cooperative: if work ignores the token, it retains the permit until it finishes. The limit applies to this shared instance, not to every process in the system. In assessments, interpret these paths and select answers; code execution is not required.
using System;
using System.Threading;
using System.Threading.Tasks;
public static class LimitedWork
{
private static readonly SemaphoreSlim Gate = new(2, 2);
public static async Task<int> RunAsync(
Func<CancellationToken, Task<int>> work,
CancellationToken token)
{
ArgumentNullException.ThrowIfNull(work);
await Gate.WaitAsync(token);
try
{
return await work(token);
}
finally
{
Gate.Release();
}
}
}With two calls still executing work, a third waits for a permit. If the third is canceled before acquiring, it does not call Release.
Common pitfalls
Confusing Task with thread; exposing async void in libraries; treating timeout as rollback; releasing permits without acquisition; disposing resources while tasks still use them.
Related topics: Exceptions, resources and files · Methods, parameters and patterns · Builds, dependencies and runtime diagnosis
Track actual completion and retain resources and permits until the work they represent finishes. A cancellation request or expired wait does not demonstrate reversal.
Reference: Task asynchronous programming model · C# 14 / .NET 10; no preview features