diff --git a/docs/navigate/advanced-programming/toc.yml b/docs/navigate/advanced-programming/toc.yml index b69dbd5eaf957..efb7756c4eade 100644 --- a/docs/navigate/advanced-programming/toc.yml +++ b/docs/navigate/advanced-programming/toc.yml @@ -24,6 +24,10 @@ items: href: ../../standard/asynchronous-programming-patterns/executioncontext-synchronizationcontext.md - name: SynchronizationContext and console apps href: ../../standard/asynchronous-programming-patterns/synchronizationcontext-console-apps.md + - name: Cancel non-cancelable async operations + href: ../../standard/asynchronous-programming-patterns/cancel-non-cancelable-async-operations.md + - name: Coalesce cancellation tokens from timeouts + href: ../../standard/asynchronous-programming-patterns/coalesce-cancellation-tokens-from-timeouts.md - name: Best practices and troubleshooting items: - name: Common async/await bugs diff --git a/docs/standard/asynchronous-programming-patterns/cancel-non-cancelable-async-operations.md b/docs/standard/asynchronous-programming-patterns/cancel-non-cancelable-async-operations.md new file mode 100644 index 0000000000000..8b28373d17be2 --- /dev/null +++ b/docs/standard/asynchronous-programming-patterns/cancel-non-cancelable-async-operations.md @@ -0,0 +1,78 @@ +--- +title: "Cancel non-cancelable async operations" +description: Learn practical patterns for cancellation when an async operation doesn't accept a cancellation token, including canceling the wait, canceling the operation, or both. +ms.date: 04/13/2026 +ai-usage: ai-assisted +dev_langs: + - "csharp" + - "vb" +helpviewer_keywords: + - "cancellation, async" + - "Task.WhenAny" + - "WithCancellation" + - "CancellationToken" + - "NetworkStream, cancellation" +--- +# Cancel non-cancelable async operations + +Sometimes you need cancellation, but the operation you're waiting on doesn't accept a . In that case, choose the behavior you need: + +- Cancel the operation itself. +- Cancel only your wait. +- Cancel both the operation and your wait. + +The right choice depends on who owns the operation and what cleanup guarantees you need. + +## Understand the three cancellation meanings + +When people say, "cancel this async call," they usually mean one of three different things: + +- **Cancel the operation.** Signal the operation to stop work. +- **Cancel the wait.** Stop awaiting and continue your workflow, even if the operation still runs. +- **Cancel both.** Request operation cancellation and also stop waiting promptly. + +Treat these meanings as separate design decisions. If you mix them, cancellation behavior becomes hard to reason about. + +## Prefer token-aware APIs when available + +Before you add a wrapper, check whether the API already supports cancellation tokens. Modern .NET APIs have much broader token support than older .NET Framework code. For example, many stream APIs in .NET now support cancellation, and `NetworkStream` async operations honor cancellation tokens. + +Use token overloads whenever they exist: + +:::code language="csharp" source="./snippets/cancel-non-cancelable-async-operations/csharp/Program.cs" id="PreferTokenAwareApis"::: +:::code language="vb" source="./snippets/cancel-non-cancelable-async-operations/vb/Program.vb" id="PreferTokenAwareApis"::: + +## Cancel only the wait by using `Task.WhenAny` + +When an operation doesn't accept a token, cancel your wait by racing the operation against a token-backed task. This pattern often appears as a `WithCancellation` helper: + +:::code language="csharp" source="./snippets/cancel-non-cancelable-async-operations/csharp/Program.cs" id="WithCancellation"::: +:::code language="vb" source="./snippets/cancel-non-cancelable-async-operations/vb/Program.vb" id="WithCancellation"::: + +This pattern uses to return as soon as either task completes. + +Use this approach only when it's safe for the original operation to continue in the background. + +## Cancel both operation and wait when you own the operation + +If you own the operation and it accepts a token, pass the token and still use a cancelable wait when needed: + +:::code language="csharp" source="./snippets/cancel-non-cancelable-async-operations/csharp/Program.cs" id="CancelBoth"::: +:::code language="vb" source="./snippets/cancel-non-cancelable-async-operations/vb/Program.vb" id="CancelBoth"::: + +This combination gives responsive cancellation for callers and cooperative shutdown for the underlying work. + +## Handle abandoned operations safely + +If you cancel only the wait, the original task might later fault. Keep a reference so you can observe completion and log exceptions. Otherwise, you can miss failures and make troubleshooting harder. + +:::code language="csharp" source="./snippets/cancel-non-cancelable-async-operations/csharp/Program.cs" id="ObserveLateFault"::: +:::code language="vb" source="./snippets/cancel-non-cancelable-async-operations/vb/Program.vb" id="ObserveLateFault"::: + +## See also + +- [Consume the task-based asynchronous pattern](consuming-the-task-based-asynchronous-pattern.md) +- [Task cancellation](../parallel-programming/task-cancellation.md) +- [Cancellation in managed threads](../threading/cancellation-in-managed-threads.md) +- +- diff --git a/docs/standard/asynchronous-programming-patterns/coalesce-cancellation-tokens-from-timeouts.md b/docs/standard/asynchronous-programming-patterns/coalesce-cancellation-tokens-from-timeouts.md new file mode 100644 index 0000000000000..a4de0dacdb891 --- /dev/null +++ b/docs/standard/asynchronous-programming-patterns/coalesce-cancellation-tokens-from-timeouts.md @@ -0,0 +1,70 @@ +--- +title: "Coalesce cancellation tokens from timeouts" +description: Learn how to combine caller-driven cancellation with timeout-driven cancellation by using linked cancellation token sources in modern .NET. +ms.date: 04/13/2026 +ai-usage: ai-assisted +dev_langs: + - "csharp" + - "vb" +helpviewer_keywords: + - "CancellationTokenSource.CreateLinkedTokenSource" + - "timeout, cancellation" + - "linked cancellation token" + - "CancelAfter" + - "WaitAsync" +--- +# Coalesce cancellation tokens from timeouts + +Many APIs need two cancellation triggers: + +- A caller-provided . +- A timeout enforced by your component. + +Use to merge both into a single token that your async work can consume. + +## Use a linked token source for caller token plus timeout + +Create a timeout , link it with the caller token, and pass the linked token to the operation: + +:::code language="csharp" source="./snippets/coalescing-cancellation-tokens-from-timeouts/csharp/Program.cs" id="LinkedTokenBasic"::: +:::code language="vb" source="./snippets/coalescing-cancellation-tokens-from-timeouts/vb/Program.vb" id="LinkedTokenBasic"::: + +Dispose both `CancellationTokenSource` instances after the operation completes so registrations and timers are released promptly. + +## Encapsulate linking in a helper + +Wrap linking logic in a helper method when several call sites need the same policy: + +:::code language="csharp" source="./snippets/coalescing-cancellation-tokens-from-timeouts/csharp/Program.cs" id="LinkedTokenHelper"::: +:::code language="vb" source="./snippets/coalescing-cancellation-tokens-from-timeouts/vb/Program.vb" id="LinkedTokenHelper"::: + +A helper keeps timeout behavior consistent and avoids duplicate disposal bugs. + +## Choose the right timeout model + +Linked tokens are useful when the callee accepts a token and you need one unified cancellation path. In modern .NET, you also have these alternatives: + +- Use to enforce timeouts on a `CancellationTokenSource` you own. +- Use when you want to time out the wait without canceling underlying work. + +This example shows a `WaitAsync` timeout for "cancel wait only" semantics: + +:::code language="csharp" source="./snippets/coalescing-cancellation-tokens-from-timeouts/csharp/Program.cs" id="WaitAsyncAlternative"::: +:::code language="vb" source="./snippets/coalescing-cancellation-tokens-from-timeouts/vb/Program.vb" id="WaitAsyncAlternative"::: + +## Summary + +Use linked tokens when you need to combine caller intent and infrastructure timeouts into one cancellation contract. Keep ownership and disposal rules explicit: + +- If your component creates a `CancellationTokenSource`, your component disposes it. +- If callers pass a token, never dispose the caller's token source. +- If an operation doesn't accept tokens, use wait-cancellation patterns instead of linked tokens. + +## See also + +- [Cancel non-cancelable async operations](cancel-non-cancelable-async-operations.md) +- [Task cancellation](../parallel-programming/task-cancellation.md) +- [Cancellation in managed threads](../threading/cancellation-in-managed-threads.md) +- +- +- diff --git a/docs/standard/asynchronous-programming-patterns/snippets/cancel-non-cancelable-async-operations/csharp/CancelNonCancelableAsyncOperations.csproj b/docs/standard/asynchronous-programming-patterns/snippets/cancel-non-cancelable-async-operations/csharp/CancelNonCancelableAsyncOperations.csproj new file mode 100644 index 0000000000000..dfb40caafcf9a --- /dev/null +++ b/docs/standard/asynchronous-programming-patterns/snippets/cancel-non-cancelable-async-operations/csharp/CancelNonCancelableAsyncOperations.csproj @@ -0,0 +1,10 @@ + + + + Exe + net10.0 + enable + enable + + + diff --git a/docs/standard/asynchronous-programming-patterns/snippets/cancel-non-cancelable-async-operations/csharp/Program.cs b/docs/standard/asynchronous-programming-patterns/snippets/cancel-non-cancelable-async-operations/csharp/Program.cs new file mode 100644 index 0000000000000..59a7c4d57a984 --- /dev/null +++ b/docs/standard/asynchronous-programming-patterns/snippets/cancel-non-cancelable-async-operations/csharp/Program.cs @@ -0,0 +1,122 @@ +using System.Net.Sockets; + +// +public static class StreamExamples +{ + public static async Task ReadOnceAsync( + NetworkStream stream, + byte[] buffer, + CancellationToken cancellationToken) + { + return await stream.ReadAsync( + buffer.AsMemory(0, buffer.Length), + cancellationToken); + } +} +// + +// +public static class TaskCancellationExtensions +{ + public static async Task WithCancellation( + this Task task, + CancellationToken cancellationToken) + { + if (task.IsCompleted) + return await task.ConfigureAwait(false); + + var cancellationTaskSource = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + + using var registration = cancellationToken.Register( + static state => + ((TaskCompletionSource)state!).TrySetResult(true), + cancellationTaskSource); + + Task completed = await Task.WhenAny(task, cancellationTaskSource.Task) + .ConfigureAwait(false); + + if (completed != task) + throw new OperationCanceledException(cancellationToken); + + return await task.ConfigureAwait(false); + } +} +// + +// +public static class CancelBothDemo +{ + public static async Task FetchDataAsync(CancellationToken cancellationToken) + { + await Task.Delay(500, cancellationToken); + return "payload"; + } + + public static async Task RunAsync() + { + using var cts = new CancellationTokenSource(); + cts.CancelAfter(100); + + try + { + string payload = await FetchDataAsync(cts.Token) + .WithCancellation(cts.Token); + Console.WriteLine(payload); + } + catch (OperationCanceledException) + { + Console.WriteLine("Canceled operation and wait."); + } + } +} +// + +// +public static class ObserveLateFaultDemo +{ + private static async Task FaultLaterAsync() + { + await Task.Delay(250); + throw new InvalidOperationException("Background operation failed."); + } + + public static async Task RunAsync() + { + Task operation = FaultLaterAsync(); + + using var cts = new CancellationTokenSource(50); + + try + { + await operation.WithCancellation(cts.Token); + } + catch (OperationCanceledException) + { + Console.WriteLine("Stopped waiting; operation still running."); + } + + _ = operation.ContinueWith( + t => Console.WriteLine($"Observed late fault: {t.Exception!.InnerException!.Message}"), + TaskContinuationOptions.OnlyOnFaulted); + + await Task.Delay(300); + } +} +// + +public class Program +{ + public static async Task Main() + { + Console.WriteLine("=== Cancel both operation and wait ==="); + await CancelBothDemo.RunAsync(); + + Console.WriteLine(); + Console.WriteLine("=== Cancel wait only and observe late fault ==="); + await ObserveLateFaultDemo.RunAsync(); + + Console.WriteLine(); + Console.WriteLine("Done."); + } +} diff --git a/docs/standard/asynchronous-programming-patterns/snippets/cancel-non-cancelable-async-operations/vb/CancelNonCancelableAsyncOperations.vbproj b/docs/standard/asynchronous-programming-patterns/snippets/cancel-non-cancelable-async-operations/vb/CancelNonCancelableAsyncOperations.vbproj new file mode 100644 index 0000000000000..6fe3286ddd21e --- /dev/null +++ b/docs/standard/asynchronous-programming-patterns/snippets/cancel-non-cancelable-async-operations/vb/CancelNonCancelableAsyncOperations.vbproj @@ -0,0 +1,9 @@ + + + + Exe + CancelNonCancelableAsyncOperations + net10.0 + + + diff --git a/docs/standard/asynchronous-programming-patterns/snippets/cancel-non-cancelable-async-operations/vb/Program.vb b/docs/standard/asynchronous-programming-patterns/snippets/cancel-non-cancelable-async-operations/vb/Program.vb new file mode 100644 index 0000000000000..d2d2af8a0473f --- /dev/null +++ b/docs/standard/asynchronous-programming-patterns/snippets/cancel-non-cancelable-async-operations/vb/Program.vb @@ -0,0 +1,114 @@ +Imports System.Net.Sockets +Imports System.Runtime.CompilerServices +Imports System.Threading + +' +Public Module StreamExamples + Public Async Function ReadOnceAsync( + stream As NetworkStream, + buffer As Byte(), + cancellationToken As CancellationToken) As Task(Of Integer) + + Return Await stream.ReadAsync( + buffer.AsMemory(0, buffer.Length), + cancellationToken) + End Function +End Module +' + +' +Public Module TaskCancellationExtensions + + Public Async Function WithCancellation(Of T)( + operationTask As Task(Of T), + cancellationToken As CancellationToken) As Task(Of T) + + If operationTask.IsCompleted Then + Return Await operationTask + End If + + Dim cancellationTaskSource = + New TaskCompletionSource(Of Boolean)(TaskCreationOptions.RunContinuationsAsynchronously) + + Using registration = cancellationToken.Register( + Sub(state) + DirectCast(state, TaskCompletionSource(Of Boolean)).TrySetResult(True) + End Sub, + cancellationTaskSource) + + Dim completed = Await Task.WhenAny(operationTask, cancellationTaskSource.Task) + + If completed IsNot operationTask Then + Throw New OperationCanceledException(cancellationToken) + End If + End Using + + Return Await operationTask + End Function +End Module +' + +' +Public Module CancelBothDemo + Public Async Function FetchDataAsync(cancellationToken As CancellationToken) As Task(Of String) + Await Task.Delay(500, cancellationToken) + Return "payload" + End Function + + Public Async Function RunAsync() As Task + Using cts = New CancellationTokenSource() + cts.CancelAfter(100) + + Try + Dim payload = Await FetchDataAsync(cts.Token).WithCancellation(cts.Token) + Console.WriteLine(payload) + Catch ex As OperationCanceledException + Console.WriteLine("Canceled operation and wait.") + End Try + End Using + End Function +End Module +' + +' +Public Module ObserveLateFaultDemo + Private Async Function FaultLaterAsync() As Task(Of Integer) + Await Task.Delay(250) + Throw New InvalidOperationException("Background operation failed.") + End Function + + Public Async Function RunAsync() As Task + Dim operation As Task(Of Integer) = FaultLaterAsync() + + Using cts = New CancellationTokenSource(50) + Try + Await operation.WithCancellation(cts.Token) + Catch ex As OperationCanceledException + Console.WriteLine("Stopped waiting; operation still running.") + End Try + End Using + + Dim observed = operation.ContinueWith( + Sub(t) + Console.WriteLine($"Observed late fault: {t.Exception.InnerException.Message}") + End Sub, + TaskContinuationOptions.OnlyOnFaulted) + + Await observed + End Function +End Module +' + +Module Program + Sub Main() + Console.WriteLine("=== Cancel both operation and wait ===") + CancelBothDemo.RunAsync().Wait() + + Console.WriteLine() + Console.WriteLine("=== Cancel wait only and observe late fault ===") + ObserveLateFaultDemo.RunAsync().Wait() + + Console.WriteLine() + Console.WriteLine("Done.") + End Sub +End Module diff --git a/docs/standard/asynchronous-programming-patterns/snippets/coalescing-cancellation-tokens-from-timeouts/csharp/CoalescingCancellationTokensFromTimeouts.csproj b/docs/standard/asynchronous-programming-patterns/snippets/coalescing-cancellation-tokens-from-timeouts/csharp/CoalescingCancellationTokensFromTimeouts.csproj new file mode 100644 index 0000000000000..dfb40caafcf9a --- /dev/null +++ b/docs/standard/asynchronous-programming-patterns/snippets/coalescing-cancellation-tokens-from-timeouts/csharp/CoalescingCancellationTokensFromTimeouts.csproj @@ -0,0 +1,10 @@ + + + + Exe + net10.0 + enable + enable + + + diff --git a/docs/standard/asynchronous-programming-patterns/snippets/coalescing-cancellation-tokens-from-timeouts/csharp/Program.cs b/docs/standard/asynchronous-programming-patterns/snippets/coalescing-cancellation-tokens-from-timeouts/csharp/Program.cs new file mode 100644 index 0000000000000..565c9e85f1ea0 --- /dev/null +++ b/docs/standard/asynchronous-programming-patterns/snippets/coalescing-cancellation-tokens-from-timeouts/csharp/Program.cs @@ -0,0 +1,105 @@ +// +public static class LinkedTokenBasicDemo +{ + public static async Task RunAsync(CancellationToken userToken) + { + using var timeoutCts = new CancellationTokenSource(TimeSpan.FromMilliseconds(150)); + using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource( + userToken, + timeoutCts.Token); + + await Task.Delay(500, linkedCts.Token); + } +} +// + +// +public static class TimeoutPolicy +{ + public static async Task ExecuteWithLinkedTimeoutAsync( + Func operation, + TimeSpan timeout, + CancellationToken userToken) + { + using var timeoutCts = new CancellationTokenSource(timeout); + using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource( + userToken, + timeoutCts.Token); + + await operation(linkedCts.Token); + } +} +// + +// +public static class WaitOnlyTimeoutDemo +{ + public static async Task RunAsync(CancellationToken userToken) + { + Task operation = Task.Delay(500); + + try + { + await operation.WaitAsync(TimeSpan.FromMilliseconds(100), userToken); + Console.WriteLine("Operation completed before timeout."); + } + catch (TimeoutException) + { + Console.WriteLine("Timed out waiting without canceling operation."); + } + catch (OperationCanceledException) + { + Console.WriteLine("Canceled while waiting for the operation."); + } + } +} +// + +public class Program +{ + public static async Task Main() + { + Console.WriteLine("=== Linked token basic ==="); + using (var userCts = new CancellationTokenSource()) + { + try + { + await LinkedTokenBasicDemo.RunAsync(userCts.Token); + } + catch (OperationCanceledException) + { + Console.WriteLine("Canceled by timeout or caller token."); + } + } + + Console.WriteLine(); + Console.WriteLine("=== Linked token helper ==="); + using (var userCts = new CancellationTokenSource()) + { + try + { + await TimeoutPolicy.ExecuteWithLinkedTimeoutAsync( + async token => + { + await Task.Delay(500, token); + }, + TimeSpan.FromMilliseconds(120), + userCts.Token); + } + catch (OperationCanceledException) + { + Console.WriteLine("Helper canceled operation as expected."); + } + } + + Console.WriteLine(); + Console.WriteLine("=== WaitAsync alternative ==="); + using (var userCts = new CancellationTokenSource()) + { + await WaitOnlyTimeoutDemo.RunAsync(userCts.Token); + } + + Console.WriteLine(); + Console.WriteLine("Done."); + } +} diff --git a/docs/standard/asynchronous-programming-patterns/snippets/coalescing-cancellation-tokens-from-timeouts/vb/CoalescingCancellationTokensFromTimeouts.vbproj b/docs/standard/asynchronous-programming-patterns/snippets/coalescing-cancellation-tokens-from-timeouts/vb/CoalescingCancellationTokensFromTimeouts.vbproj new file mode 100644 index 0000000000000..1a8c1b998539f --- /dev/null +++ b/docs/standard/asynchronous-programming-patterns/snippets/coalescing-cancellation-tokens-from-timeouts/vb/CoalescingCancellationTokensFromTimeouts.vbproj @@ -0,0 +1,9 @@ + + + + Exe + CoalescingCancellationTokensFromTimeouts + net10.0 + + + diff --git a/docs/standard/asynchronous-programming-patterns/snippets/coalescing-cancellation-tokens-from-timeouts/vb/Program.vb b/docs/standard/asynchronous-programming-patterns/snippets/coalescing-cancellation-tokens-from-timeouts/vb/Program.vb new file mode 100644 index 0000000000000..da0fc73b0a70e --- /dev/null +++ b/docs/standard/asynchronous-programming-patterns/snippets/coalescing-cancellation-tokens-from-timeouts/vb/Program.vb @@ -0,0 +1,83 @@ +Imports System.Threading + +' +Public Module LinkedTokenBasicDemo + Public Async Function RunAsync(userToken As CancellationToken) As Task + Using timeoutCts = New CancellationTokenSource(TimeSpan.FromMilliseconds(150)), + linkedCts = CancellationTokenSource.CreateLinkedTokenSource(userToken, timeoutCts.Token) + + Await Task.Delay(500, linkedCts.Token) + End Using + End Function +End Module +' + +' +Public Module TimeoutPolicy + Public Async Function ExecuteWithLinkedTimeoutAsync( + operation As Func(Of CancellationToken, Task), + timeout As TimeSpan, + userToken As CancellationToken) As Task + + Using timeoutCts = New CancellationTokenSource(timeout), + linkedCts = CancellationTokenSource.CreateLinkedTokenSource(userToken, timeoutCts.Token) + + Await operation(linkedCts.Token) + End Using + End Function +End Module +' + +' +Public Module WaitOnlyTimeoutDemo + Public Async Function RunAsync(userToken As CancellationToken) As Task + Dim operation As Task = Task.Delay(500) + + Try + Await operation.WaitAsync(TimeSpan.FromMilliseconds(100), userToken) + Console.WriteLine("Operation completed before timeout.") + Catch ex As TimeoutException + Console.WriteLine("Timed out waiting without canceling operation.") + Catch ex As OperationCanceledException + Console.WriteLine("Canceled waiting because the caller token was canceled.") + End Try + End Function +End Module +' + +Module Program + Sub Main() + Console.WriteLine("=== Linked token basic ===") + Using userCts = New CancellationTokenSource() + Try + LinkedTokenBasicDemo.RunAsync(userCts.Token).Wait() + Catch ex As AggregateException When TypeOf ex.InnerException Is OperationCanceledException + Console.WriteLine("Canceled by timeout or caller token.") + End Try + End Using + + Console.WriteLine() + Console.WriteLine("=== Linked token helper ===") + Using userCts = New CancellationTokenSource() + Try + TimeoutPolicy.ExecuteWithLinkedTimeoutAsync( + Async Function(token) + Await Task.Delay(500, token) + End Function, + TimeSpan.FromMilliseconds(120), + userCts.Token).Wait() + Catch ex As AggregateException When TypeOf ex.InnerException Is OperationCanceledException + Console.WriteLine("Helper canceled operation as expected.") + End Try + End Using + + Console.WriteLine() + Console.WriteLine("=== WaitAsync alternative ===") + Using userCts = New CancellationTokenSource() + WaitOnlyTimeoutDemo.RunAsync(userCts.Token).Wait() + End Using + + Console.WriteLine() + Console.WriteLine("Done.") + End Sub +End Module