How it works now
ProtoPromise v1 only supports usage on the main thread, and even has an extra check in there to make sure it is only accessed from a single thread in DEBUG
mode (it sends a warning to the warning handler if it exists, otherwise it throws). This is undocumented.
The way Promises work are when a promise is resolved, rejected, or canceled, the callbacks that were subscribed to it get added to the event queue instead of executed synchronously. The next time Promise.Manager.HandleCompletes(AndProgress) is called (which happens automatically with the Unity package) is when the callbacks are dequeued and executed.
This behavior follows the Promises/A+ specification 2.2.4 onFulfilled or onRejected must not be called until the execution context stack contains only platform code.
It also means that callbacks are executed iteratively instead of recursively so that the library will never cause a stack overflow (this is why the benchmarks always work with high values of N while other libraries fail). This also helps with usability, allowing programmers to add as many callbacks as they want to a single Promise object within a single method. Uncaught rejections and object pooling kicks in later in the execution pipeline when the event queue is handled.
The problem
Unfortunately (or rather, fortunately), unlike javascript, C# is not relegated to a single thread (depending on the target application). Advanced C# programmers make great use of threads to speed up their applications. Simply adding "thread safety" to an individual Promise object introduces problems when you want to add multiple callbacks to it. While you're adding callbacks in a background thread, the main thread could be executing the event queue and trying to put that Promise object back in the object pool, thus invalidating any further actions to that Promise object, or throwing unhandled rejections before the background thread has a chance to add a rejection handler.
Of course, if we just get rid of object pooling entirely and remove the event queue to just execute callbacks synchronously, this would be a non-issue (like built-in Tasks), but one of the goals of this library was to reduce GC pressure, so I don't think that's the way to go (otherwise, why not just use built-in Tasks?), plus you can see from the benchmarks that object pooling greatly increases the library's speed (as minuscule of a difference as that makes in a real application, every bit matters). Also, uncaught rejections would need to be caught in the Promise's finalizer rather than during the event handling, further increasing GC pressure.
RSG Promises handle the uncaught rejections by forcing the programmer to call Done()
on the last promise in the chain. UniTask has a similar Forget()
that you can call to try to have the underlying object added back to the object pool. ProtoPromise currently does it opposite, where it automatically handles uncaught rejections and repooling in the event handler, but you can also prevent that from happening by calling Retain()
if you need to use the Promise object for a longer lifetime (not recommended because it can be difficult to keep track of retained objects to release them later).
If we have a Promise.Run(Action)
that executes a delegate on a background thread (to replace Task.Run
), we could set up a thread-local event queue, but that doesn't solve the issue of 2 separate threads using a single Promise object, and actually increases the contention. Also, a lot of C# applications are already using threads by other means (new Thread()
), and those threads should be able to interact with Promises without issues.
Possible solution
UniTask by default only lets you await a task once. You can call Preserve()
to allow you to await it as much as you want, along with Forget()
to allow it to repool again. What if we copy that behavior?
- Execute callbacks synchronously when a promise is settled instead of placing on event queue (this will take some extra work to execute iteratively). Only Progress callbacks will be executed on the main thread through the existing mechanism.
- Promises can only be awaited once by default (
Then
, Catch
, ContinueWith
, await
)
Now, to be able to await multiple times/add as many callbacks as we want, we need a similar Preserve()
method that will need to be called before we add any callbacks. We already have Retain()
so let's just keep that. We also need a Forget()
like UniTask to handle uncaught rejections and repooling. We already have Release()
, but if we always use Release()
as the promise chain terminator, that makes it impossible for multiple threads to retain a single Promise object. So let's use Release()
as the counter to Retain()
, and add a new Forget()
as the chain terminator (RSG uses Done
, but I think Forget
is more descriptive).
promise
.Then(() => {})
.Then(() => {})
.Forget();
What if we want to add a callback to a promise, and then return that promise?
promise.Retain();
promise.Then(() => {}).Forget();
promise.Release();
return promise;
Retain
allows as many awaits as you want until Release
is called. After that, it reverts to its initial state of only allowing a single await, or Forget
.
What if you call Forget()
on a retained Promise object? Should it still allow callbacks to be added until Release()
is called? Or should it throw an exception saying that Release
must be called before Forget
? I'm leaning towards the former to allow users to cache Promise objects (even though it's not recommended) and to allow a thread to keep using a promise after another thread forgot it.
If we allow callbacks to still be added after it's forgotten and until it's released, Forget
should also be allowed to be called any number of times. If a promise is already forgotten while retained, when it is fully released, the last Release()
call acts the same as a normal Forget()
call (it handles uncaught rejections and repools the object).
Further changes needed
With callbacks being executed synchronously upon Promise resolution, deferreds must be changed to invalidate themselves when they are resolved/rejected/canceled. This makes the State
property completely useless. The only thing that will matter then is IsPending
which is equivalent to IsValid
.
With these hefty changes, Promises might also benefit from being changed to struct
instead of class
, similar to how CancelationToken
s work (and even Deferred
s themselves). This would allow better validity checks and let us enable object pooling in DEBUG
mode (currently Promises are not pooled in DEBUG
mode to allow for validity checks, which are completely absent from RELEASE
builds). Pooling could also be simplified to on or off, completely removing the internal option (all pooled objects would be internal with this change). Furthermore, changing Promises to structs would increase the efficiency of already-completed Promises since it would only need to live on the stack and not have to go to the heap to find/create a Promise object.
[Edit] The downside of changing promises to structs is we lose the Promise<T> : Promise
inheritance, and all the benefits that brings with it.
ProtoPromise v2?
Such big changes to support threads surely require a major version change. It might even need a separate fork of its own, what do you think?