Here is the non-async alternative that I prefer, when I am authoring library-level code and I want to avoid the few behavioral disadvantages of the async state machine:
public static Task<T> ContinueWithResult<T>(this Task source, T result)
{
return source.ContinueWith(static (t, s) =>
{
TaskStatus status = t.Status;
if (status == TaskStatus.Canceled)
{
TaskCompletionSource<T> tcs = new();
tcs.SetCanceled(new TaskCanceledException(t).CancellationToken);
return tcs.Task;
}
if (status == TaskStatus.Faulted)
{
TaskCompletionSource<T> tcs = new();
tcs.SetException(t.Exception.InnerExceptions);
return tcs.Task;
}
return (Task<T>)s;
}, Task.FromResult(result), CancellationToken.None,
TaskContinuationOptions.ExecuteSynchronously,
TaskScheduler.Default).Unwrap();
}
The ContinueWithResult above behaves better than the async state machine in two regards:
- In case the
source task completes as faulted with more than one exceptions, all exceptions are propagated to the converted task. In comparison the await propagates by design only the first exception.
- In case the
source task completes as faulted with an OperationCanceledException, the converted task will also be faulted with the same exception. In comparison the async/await propagates a canceled task (not faulted), because it has a special handling for OperationCanceledExceptions.
Additionally the exception is propagated without throwing it with the await, and then catching it by the async state machine and storing it in the resulting task. So the propagation is a tad more efficient (throwing+catching exceptions is expensive), and the stack trace is one line shorter.
Disadvantage: In case the source task completes as canceled, the ContinueWithResult propagates only the associated CancellationToken. In comparison the async/await propagates also the Message and the stack trace of the OperationCanceledException that caused the cancellation of the source task. As of .NET 9, there is no public API to obtain this internal information other than awaiting the task and observing the exception.
The ContinueWithResult above is not optimized for the case that the original Task is already completed. In my use cases the tasks were not expected to be completed. If your intention is to convert tasks in tight loops where most of them will be completed, I would not recommend using the ContinueWithResult as is.
An improvised benchmark that compares the ContinueWithResult with the standard async/await approach can be found here. Basically it attaches 1,000 continuations on each of 10,000 incomplete tasks (10,000,000 continuations in total), and measures the duration and the memory allocations for creating/attaching the continuations. On the dotnetfiddle server (.NET 9) it produces this output:
Tasks: 10,000
Continuations per task: 1,000
ContinueWithResult, Duration: 1,414 msec, Allocations: 2,086,720,984 bytes
ContinueWithResultAsync, Duration: 1,346 msec, Allocations: 1,526,720,000 bytes
On my PC (also .NET 9, Release mode) it produces this output:
Tasks: 10,000
Continuations per task: 1,000
ContinueWithResult, Duration: 4,458 msec, Allocations: 2,886,720,760 bytes
ContinueWithResultAsync, Duration: 2,727 msec, Allocations: 1,286,720,000 bytes
I don't think that the difference is particularly important. In practice the cost of creating the original task will dwarf the cost of the continuation, in the majority of cases.
Task<T>? Because I could imagine something along "await anything"Task- 1. and 2. in the question