timcassell / ProtoPromise

Robust and efficient library for management of asynchronous operations in C#/.Net.
MIT License
145 stars 13 forks source link

Decouple Progress from Promises #310

Closed timcassell closed 6 months ago

timcassell commented 8 months ago

The Problem

Progress coupled to promises can be very convenient, thanks to its guaranteed normalization and optionality, but it comes with some downsides.

Performance Suffers

Default performance

Method Pending Mean Allocated Survived
AsyncAwait True 2.099 μs - 696 B
ContinueWith True 2.132 μs - 384 B

Performance with progress compiled out

Method Pending Mean Allocated Survived
AsyncAwait True 1.852 μs - 648 B
ContinueWith True 1.767 μs - 360 B

Without progress, async/await is ~13% faster, and ContinueWith is ~20% faster, while using less memory. This performance hit is global, meaning all uses of Promise are not as efficient as they could be if they don't use progress. This is despite the massive performance improvements I made in v2.4 (#138).

Most async methods do not make use of progress, so this is just wasted cycles and memory.

Indeterminate Progress

While progress is guaranteed to be normalized (0, 1), done automatically with .Then/.ContinueWith APIs, and manually with async/await, and automatically normalized from Promise.All and friends, sometimes the progress normalization can only be determined by the user.

Consider looping over an AsyncEnumerable. The number of elements that it can produce is unbounded. We as a library can't possibly know how to report on the progress with that. That's why progress does not flow out of AsyncEnumerable. But, in some cases, the user could predetermine how many elements will be produced, and thus be able to report progress accurately.

Weights

Currently, Promise.All and friends use a weighted progress strategy, using the depth of each Promise passed to it (async Promise always has a depth of 1). There is currently no way to configure those weights. The user may want to raise or lower the weight of some operations if they have some knowledge of how long each operation is expected to take.

Laziness

Because progress is subscribed lazily, we miss out on some further optimizations that could be done if progress was eager. It's impossible to make it eager with the current API surface.

Maintainability

In order to make progress (and by extension the promises they are coupled to) as efficient as they are now, I implemented an extremely complex algorithm, very tightly coupled to the inner workings of promises, even sharing fields to be as efficient as possible. This is a maintainability nightmare, making it difficult even for me to make changes, let alone a new person looking at the code.

Interoperability

The progress APIs coupled to promises cannot easily interoperate with other async types like Task and ValueTask.

The Solution

The solution is to decouple progress from Promises. This means removing the existing Deferred.ReportProgress, Promise.Progress, and Promise.AwaitWithProgress APIs, and replacing them with new APIs.

New Progress Type

APIs TBD, but basically it will work like this.

async Promise Func()
{
    await using var progress = Progress.New(p => { ... });
    await OtherFunc(progress.Token);
}

So the user creates a new progress object, passes its token to another async function (similar to CancelationToken), then DisposeAsync (await using in C# 8). Functions that accept the progress token will be able to report progress, and generate new tokens from it to split the progress into chunks (similar to the current Promise.AwaitWithProgress API), or generate merge or race tokens that can report progress (similar to progress from Promise.All or Promise.Race).

This is a more manual process than the existing APIs, but it solves all the problems.

Implementation Strategy

Because this is a big breaking change, it will require a major version bump to v3.0.0. But I don't want to just do it all at once for a jarring change to users, so it can be done in 3 steps.

timcassell commented 8 months ago

We can also take this opportunity to upgrade to double progress, rather than float.

I think these APIs cover everything.

namespace Proto.Promises
{
    public struct Progress : IAsyncDisposable
    {
        public static Progress New(Action<double> handler,
            SynchronizationOption invokeOption = SynchronizationOption.Foreground,
            bool forceAsync = false,
            CancelationToken cancelationToken = default(CancelationToken))

        public static Progress New<TCapture>(TCapture captureValue, Action<TCapture, double> handler,
            SynchronizationOption invokeOption = SynchronizationOption.Foreground,
            bool forceAsync = false,
            CancelationToken cancelationToken = default(CancelationToken))

        public static Progress New<TProgress>(TProgress handler,
            SynchronizationOption invokeOption = SynchronizationOption.Foreground,
            bool forceAsync = false,
            CancelationToken cancelationToken = default(CancelationToken))
            where TProgress : IProgress<double>

        public static Progress New(Action<double> handler,
            SynchronizationContext invokeContext,
            bool forceAsync = false,
            CancelationToken cancelationToken = default(CancelationToken))

        public static Progress New<TCapture>(TCapture captureValue, Action<TCapture, double> handler,
            SynchronizationContext invokeContext,
            bool forceAsync = false,
            CancelationToken cancelationToken = default(CancelationToken))

        public static Progress New<TProgress>(TProgress handler,
            SynchronizationContext invokeContext,
            bool forceAsync = false,
            CancelationToken cancelationToken = default(CancelationToken))
            where TProgress : IProgress<double>

        public ProgressToken Token { get; }

        public Promise DisposeAsync()

        ValueTask IAsyncDisposable.DisposeAsync()
        {
            return DisposeAsync();
        }

        public static RaceBuilder NewRaceBuilder(ProgressToken target)

        public static MergeBuilder NewMergeBuilder(ProgressToken target)

        public struct RaceBuilder : IDisposable
        {
            public ProgressToken NewToken()

            public void Dispose() { }
        }

        public struct MergeBuilder : IDisposable
        {
            public ProgressToken NewToken(double weight = 1d)

            public void Dispose() { }
        }
    }

    public struct ProgressToken : IProgress<double>
    {
        public void Report(double value)

        public ProgressToken Chunk(double minValue, double maxValue)
    }
}

Usage example:

async Promise Func()
{
    await using var progress = Progress.New(p => { ... });
    await OtherFunc(progress.Token.Chunk(0d, 0.5d));
    await OtherFunc(progress.Token.Chunk(0.5d, 1d));
}

async Promise OtherFunc(ProgressToken progress)
{
    using var progressMerger = Progress.NewMergeBuilder(progress);
    await Promise.All(
        QuickWork(progressMerger.NewToken()),
        MediumWork(progressMerger.NewToken(5)),
        LongWork(progressMerger.NewToken(10))
    );
}

async Promise QuickWork(ProgressToken progress)
{
    for (int i = 0; i < 10; ++i)
    {
        progress.Report((double) i / 10);
        await PromiseYielder.WaitOneFrame();
    }
    progress.Report(1d);
}