Closed timcassell closed 6 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);
}
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
Performance with progress compiled out
Without progress,
async/await
is ~13% faster, andContinueWith
is ~20% faster, while using less memory. This performance hit is global, meaning all uses ofPromise
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 fromPromise.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 ofAsyncEnumerable
. 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 eachPromise
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
andValueTask
.The Solution
The solution is to decouple progress from Promises. This means removing the existing
Deferred.ReportProgress
,Promise.Progress
, andPromise.AwaitWithProgress
APIs, and replacing them with new APIs.New Progress Type
APIs TBD, but basically it will work like this.
So the user creates a new progress object, passes its token to another async function (similar to
CancelationToken
), thenDisposeAsync
(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 currentPromise.AwaitWithProgress
API), or generate merge or race tokens that can report progress (similar to progress fromPromise.All
orPromise.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.