2

I want to wrap a Task in a Task<TResult> without using an async state machine, while preserving the original task's properties. Based on What's the best way to wrap a Task as a Task<TResult> and How to convert a Task into a Task<T>, the consensus seems to be that the best/least error-prone way to do the conversion is by awaiting the non-generic Task to complete, and then return a certain T. Something like this:

async Task<T> ConvertAsync<T>(Task task, T result)
{
    await task;
    return result;
}

Is there an alternative, non-async and error-free way of achieving the wrapping?

  1. The returned Task<T> should have the same Task.Status as the wrapped Task.
  2. The returned Task<T> should have its Exception property be the same as the original Task.
  3. The wrapping/converting should be done as asynchronously as possible, i.e. no blocking during the conversion of the task.
10
  • 1
    Why specifically do you not want to use the async pattern? I guess you could use a continuation instead, but that is not something I would recommend unless you really know what you are doing. Commented May 14 at 11:45
  • 2
    Does it have to be a Task<T> ? Because I could imagine something along "await anything" Commented May 14 at 12:01
  • 1
    @Fildor yes - to maintain the properties of the Task - 1. and 2. in the question Commented May 14 at 12:08
  • 1
    Something based on something like this => dotnetfiddle.net/czeS63 ? I guess one could make some effort to satisfy 1 and 2 ? Commented May 14 at 12:15
  • 1
    The question can still be reopened even if that happens :) Commented May 14 at 14:49

1 Answer 1

4

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:

  1. 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.
  2. 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.

Sign up to request clarification or add additional context in comments.

Comments

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.