Home > OS >  What is the point of ValueTask.Preserve()?
What is the point of ValueTask.Preserve()?

Time:03-04

ValueTask and ValueTask<TResult> have a Preserve() method which is summarized as "Gets a ValueTask that may be used at any point in the future."

What does that mean and when should we use it? Does it imply that a 'normal' ValueTask can't be used "at any point in the future"? If so, why?

CodePudding user response:

The documentation isn't very clear. From the source :

 public ValueTask Preserve() => _obj == null ? this : new ValueTask(AsTask());

So it's effectively just making a clone of the object in the case that the ValueTask represents a Task or an IValueTaskSource (_obj != null) or otherwise just copies by assignment if the ValueTask represents a successful synchronous completion. It's necessary because _obj is internal and can't be tested externally.

/// <summary>null if representing a successful synchronous completion, 
/// otherwise a <see cref="Task"/> or a <see cref="IValueTaskSource"/>.</summary>
internal readonly object? _obj;

The warning at the top gives a bit more information :

// TYPE SAFETY WARNING:
// This code uses Unsafe.As to cast _obj.  This is done in order to minimize the costs associated with
// casting _obj to a variety of different types that can be stored in a ValueTask, e.g. Task<TResult>
// vs IValueTaskSource<TResult>.  Previous attempts at this were faulty due to using a separate field
// to store information about the type of the object in _obj; this is faulty because if the ValueTask
// is stored into a field, concurrent read/writes can result in tearing the _obj from the type information
// stored in a separate field.  This means we can rely only on the _obj field to determine how to handle
// it.  As such, the pattern employed is to copy _obj into a local obj, and then check it for null and
// type test against Task/Task<TResult>.  Since the ValueTask can only be constructed with null, Task,
// or IValueTaskSource, we can then be confident in knowing that if it doesn't match one of those values,
// it must be an IValueTaskSource, and we can use Unsafe.As.  This could be defeated by other unsafe means,
// like private reflection or using Unsafe.As manually, but at that point you're already doing things
// that can violate type safety; we only care about getting correct behaviors when using "safe" code.
// There are still other race conditions in user's code that can result in errors, but such errors don't
// cause ValueTask to violate type safety.

CodePudding user response:

The ValueTask is a performance optimization over a Tasks, but this performance comes with a cost: You cannot use a ValueTask as freely as a Task. The documentation mentions these restrictions:

The following operations should never be performed on a ValueTask instance:

  • Awaiting the instance multiple times.
  • Calling AsTask multiple times.
  • Using more than one of these techniques to consume the instance.

These restrictions apply only for ValueTasks that are backed by a IValueTaskSource. ValueTasks can also be backed by a Task, or by a TResult value (for a ValueTask<TResult>). If you know with 100% certainty that a ValueTask is not backed by a IValueTaskSource, you can use it as freely as a Task. For example you can await is multiple times, or you can wait synchronously for its completion with .GetAwaiter().GetResult().

In general you don't know how a ValueTask is implemented internally, and even if you know (by studying the source code) you may not want to rely on an implementation detail. The ValueTask.Preserve method is a shortcut for creating a new ValueTask that represents the current ValueTask, and is backed by a Task. The two lines below are roughly equivalent:

ValueTask preserved = myValueTask.Preserve();
ValueTask preserved = new ValueTask(myValueTask.AsTask());

After calling Preserve the original ValueTask has been consumed, and can no longer be awaited, or Preserved again, or converted to Task with the AsTask method. Doing any of those actions is likely to result in an InvalidOperationException. But now you have the preserved representation of it, which can be used with the same freedom as a Task.

Here is a practical example where the ValueTask.Preserve can be useful. Let's assume that you want to create a LINQ operator for IAsyncEnumerable<T> sequences. The enumerator has a MoveNextAsync method that returns a ValueTask<bool>. You want to perform some logic after calling this method and before awaiting it, that might throw an exception.

Now you have the problem that you are not allowed to dispose the enumerator while a MoveNextAsync operation is in flight. You must await any pending MoveNextAsync operation before disposing the enumerator, otherwise an InvalidOperationException might be thrown by the DisposeAsync. But when you are in the finally block, you can't be sure if the last MoveNextAsync operation has already been awaited. Awaiting it a second time may again cause an InvalidOperationException.

One solution to this problem is to convert the ValueTask<bool> to a Task<bool> immediately after you get it from the MoveNextAsync. But then you are allocating memory invariably on each iteration, even if the ValueTask<bool> is already completed upon creation, and you expect the vast majority of the ValueTask<bool>s to be like this. In these very specific circumstances you could use the Preserve method to your advantage like this:

static async IAsyncEnumerable<T> MyOperator<T>(this IAsyncEnumerable<T> source)
{
    var enumerator = source.GetAsyncEnumerator();
    ValueTask<bool> moveNext = default;
    try
    {
        while (true)
        {
            moveNext = enumerator.MoveNextAsync();
            if (moveNext.IsCompletedSuccessfully)
                moveNext = new ValueTask<bool>(moveNext.Result);
            else
                moveNext = moveNext.Preserve();

            // Let's assume that here there is complex logic that might throw

            if (!await moveNext) break;

            // Here there is more logic that might throw

            yield return enumerator.Current;
        }
    }
    finally
    {
        if (!moveNext.IsCompleted) try { await moveNext; } catch { }
        await enumerator.DisposeAsync();
    }
}

This way you are paying the cost of allocating a Task<bool> only when the ValueTask<bool> completes asynchronously (or synchronously but unsuccessfully).

The above example also indicates that the Preserve method is not optimized enough to avoid the allocation of a Task, in case the ValueTask is backed by a IValueTaskSource and also has already completed successfully. That's the reason for checking the moveNext.IsCompletedSuccessfully condition. Admittedly this check is almost certainly superficial in this case. It's extremely unlikely that a ValueTask which is completed upon creation, is backed by a IValueTaskSource.

  • Related