Skip to content

Allocation-free awaitable async operations with ValueTask<T> and ValueTask #25182

@stephentoub

Description

@stephentoub

Background

ValueTask<T> is currently a discriminated union of a T and a Task<T>. This lets APIs that are likely to complete synchronously and return a value do so without allocating a Task<T> object to carry the result value. However, operations that complete asynchronously still need to allocate a Task<T>. There is no non-generic ValueTask counterpart today because if you have an operation that completes synchronously and successfully, you can just return Task.CompletedTask, no allocation.

That addresses the 80% case where synchronously completing operations no longer allocate. But for cases where you want to strive to address the 20% case of operations completing asynchronously and still not allocating, you’re forced to play tricks with custom awaitables, which are one-offs, don’t compose well, and generally aren’t appropriate for public surface area. Task and Task<T>, by design, never go from a completed to incomplete state, meaning you can’t reuse the same object; this has many usability benefits, but for APIs that really care about that last pound of performance, in particular around allocations, it can get in the way.

We have a bunch of new APIs in .NET Core 2.1 that return ValueTask<T>s, e.g. Stream.ReadAsync, ChannelReader.ReadAsync, PipeReader.ReadAsync, etc. In many of these cases, we’ve simply accepted that they might allocate; in others, custom APIs have been introduced specific to that method. Neither of these is a good place to be.

Proposal

I have implemented a new feature in ValueTask<T> and a counterpart non-generic ValueTask that lets these not only wrap a T result or a Task<T>, but also another arbitrary object that implements the IValueTaskSource<T> interface (or IValueTaskSource for the non-generic ValueTask). An implementation of that interface can be reused, pooled, etc., allowing for an implementation that returns a ValueTask<T> or ValueTask to have amortized non-allocating operations, both synchronously completing and asynchronously completing.

The enabling APIs

First, we need to add these interfaces:

namespace System.Threading.Tasks
{
    public interface IValueTaskSource
    {
        bool IsCompleted { get; }
        bool IsCompletedSuccessfully { get; }
        void OnCompleted(Action<object> continuation, object state, ValueTaskSourceOnCompletedFlags flags);
        void GetResult();
    }

    public interface IValueTaskSource<out TResult>
    {
        bool IsCompleted { get; }
        bool IsCompletedSuccessfully { get; }
        void OnCompleted(Action<object> continuation, object state, ValueTaskSourceOnCompletedFlags flags);
        TResult GetResult();
    }

    [Flags]
    public enum ValueTaskSourceOnCompletedFlags
    {
        None = 0x0,
        UseSchedulingContext = 0x1,
        FlowExecutionContext = 0x2,
    }
}

An object implements IValueTaskSource to be wrappable by ValueTask, and IValueTaskSource<TResult> to be wrappable by ValueTask<TResult>.

Then we add this ctor to ValueTask<TResult>:

namespace System.Threading.Tasks
{
    public struct ValueTask<TResult>
    {
        public ValueTask(IValueTaskSource<TResult> source);
        ...
    }
}

Then we add a non-generic ValueTask counterpart to ValueTask<TResult>. This mirrors the ValueTask<TResult> surface area, except that it doesn’t have a Result property, doesn’t have a ctor that takes a TResult, uses Task in places where Task<TResult> was used, etc.

namespace System.Threading.Tasks
{
    [AsyncMethodBuilder(typeof(AsyncValueTaskMethodBuilder))]
    public readonly partial struct ValueTask : IEquatable<ValueTask>
    {
        public ValueTask(Task task);
        public ValueTask(IValueTaskSource source);
        public bool IsCanceled { get; }
        public bool IsCompleted { get; }
        public bool IsCompletedSuccessfully { get; }
        public bool IsFaulted { get; }
        public Task AsTask();
        public ConfiguredValueTaskAwaitable ConfigureAwait(bool continueOnCapturedContext);
        public override bool Equals(object obj);
        public bool Equals(ValueTask other);
        public ValueTaskAwaiter GetAwaiter();
        public override int GetHashCode();
        public static bool operator ==(ValueTask left, ValueTask right);
        public static bool operator !=(ValueTask left, ValueTask right);
    }
}

And finally we add the System.Runtime.CompilerServices goo that allows ValueTask to be awaited and used as the return type of an async method:

namespace System.Runtime.CompilerServices
{
    public struct AsyncValueTaskMethodBuilder
    {
        public static AsyncValueTaskMethodBuilder Create();

        public void Start<TStateMachine>(ref TStateMachine stateMachine) where TStateMachine : IAsyncStateMachine;
        public void SetStateMachine(IAsyncStateMachine stateMachine);
        public void AwaitOnCompleted<TAwaiter, TStateMachine>(ref TAwaiter awaiter, ref TStateMachine stateMachine) where TAwaiter : INotifyCompletion where TStateMachine : IAsyncStateMachine;
        public void AwaitUnsafeOnCompleted<TAwaiter, TStateMachine>(ref TAwaiter awaiter, ref TStateMachine stateMachine) where TAwaiter : ICriticalNotifyCompletion where TStateMachine : IAsyncStateMachine;

        public void SetResult();
        public void SetException(Exception exception);
        public ValueTask Task;
    }

    public readonly struct ValueTaskAwaiter : ICriticalNotifyCompletion
    {
        public bool IsCompleted { get; }
        public void GetResult() { }
        public void OnCompleted(Action continuation);
        public void UnsafeOnCompleted(Action continuation);
   }

    public readonly struct ConfiguredValueTaskAwaitable
    {
        public ConfiguredValueTaskAwaiter GetAwaiter();
        public readonly struct ConfiguredValueTaskAwaiter : ICriticalNotifyCompletion
        {
            public bool IsCompleted { get; }
            public void GetResult();
            public void OnCompleted(Action continuation);
            public void UnsafeOnCompleted(Action continuation);
        }
    }
}

Changes to Previously Accepted APIs

At the very least, we would use the ValueTask and ValueTask<T> types in the following previously accepted/implemented APIs that are shipping in 2.1:

  • Pipelines. Instead of pipelines having a custom PipeAwaiter<T> type, it will return ValueTask<T> from the ReadAsync and FlushAsync methods that currently return PipeAwaiter. PipeAwaiter<T> will be deleted. Pipe uses this to reuse the same pipe object over and over so that reads and flushes are allocation-free.
  • Channels. The WaitToReadAsync and WaitToWriteAsync methods will return ValueTask<bool> instead of Task<bool>. The WriteAsync method will return ValueTask instead of Task. At least some of the channel implementations, if not all, will pool and reuse objects backing these value tasks.
  • Streams. The new WriteAsync(ReadOnlyMemory<byte>, CancellationToken) overload will return ValueTask instead of Task. Socket’s new ReceiveAsync/SendAsync methods that are already defined to return ValueTask<int> will take advantage of this support, making sending and receiving on a socket allocation free. NetworkStream will then expose that functionality via ReadAsync/WriteAsync. FileStream will potentially also pool so as to make synchronous and asynchronous reads/writes allocation-free.
  • WebSockets. The new SendAsync(ReadOnlyMemory<byte>, …) overload will return ValueTask instead of Task. Many SendAsync calls just pass back the result from the underlying NetworkStream, so this will incur the benefits mentioned above.

There are likely to be other opportunities in the future as well. And we could re-review some of the other newly added APIs in .NET Core 2.1, e.g. TextWriter.WriteLineAsync(ReadOnlyMemory<char>, ...), to determine if we want to change those from returning Task to ValueTask. The tradeoff is one of Task's usability vs the future potential for additional optimization.

Limitations

Task is powerful, in large part due to its “once completed, never go back” design. As a result, a ValueTask<T> that wraps either a T or a Task<T> has similar power. A ValueTask<T> that wraps an IValueTaskSource<T> can be used only in much more limited ways:

  • The 99.9% use case: either directly await the operation (e.g. await SomethingAsync();), await it with configuration (e.g. await SomethingAsync().ConfigureAwait(false);), or get a Task out (e.g. Task t = SomethingAsync().AsTask();). Using AsTask() incurs allocation if the ValueTask/ValueTask<T> wraps something other than a Task/Task<T>.
  • Once you’ve either awaited the ValueTask/ValueTask<T> or called AsTask, you must never touch it again.
  • With a ValueTask<T> that wraps a Task<T>, today you can call GetAwaiter().GetResult(), and if it hasn’t completed yet, it will block. That is unsupported for a ValueTask<T> wrapping an IValueTaskSource<T>, and thus should be generally discouraged unless you're sure of what it's wrapping. GetResult must only be used once the operation has completed, as is guaranteed by the await pattern.
  • With a ValueTask<T> that wraps a Task<T>, you can await it an unlimited number of times, both serially and in parallel. That is unsupported for a ValueTask<T> wrapping an IValueTaskSource<T>; it can be awaited/AsTask'd once and only once.
  • With a ValueTask<T> that wraps a Task<T>, you can call any other operations in the interim and then await the ValueTask<T>. That is unsupported for a ValueTask<T> wrapping an IValueTaskSource<T>; it should be awaited/AsTask’d immediately, as the underlying implementation may be used for other operation, subject to whatever the library author chose to do.
  • You can choose to explicitly call IsCompletedSuccessfully and then use Result or GetAwaiter().GetResult(), but that is the only coding pattern outside of await/AsTask that’s supported.
    We will need to document that ValueTask/ValueTask<T> should only be used in these limited patterns unless you know for sure what it wraps and that the wrapped object supports what's being done. And APIs that return a ValueTask/ValueTask<T> will need to be clear on the limitations, in hopes of preserving our ability to change the backing store behind ValueTask<T> in the future, e.g. an API that we ship in 2.1 that returns ValueTask<T> around a Task<T> then in the future instead wrapping an IValueTaskSource<T>.

Finally, note that as with any solution that involves object reuse and pooling, usability/diagnostics/debuggability are impacted. If an object is used after it's already been effectively freed, strange/bad behaviors can result.

Why now?

If we don’t ship this in 2.1, we will be unable to do so as effectively in the future:

  • Some methods (e.g. the new Stream.WriteAsync overload) are currently defined to return Task but should be changed to return ValueTask.
  • Some methods return ValueTask<T>, but if we’re not explicit about the limitations of how it should be used, it’ll be a breaking change to modify what it backs in the future.
  • Various types (e.g. PipeAwaiter<T>) will be instant legacy.
  • Prior to .NET Core 2.1, ValueTask<T> was just OOB. It’s now also in System.Private.CoreLib, with core types like Stream depending on it.

Implementation Status

With the exception of pipelines, I have these changes implemented across coreclr and corefx. I can respond to any changes from API review, clean things up, and get it submitted as PRs across coreclr and corefx. Due to the breaking changes in existing APIs, it will require some coordination across the repos.

(EDIT stephentoub 2/25: Renamed IValueTaskObject to IValueTaskSource.)
(EDIT stephentoub 2/25: Changed OnCompleted to accept object state.)

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

Milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions