Inspired by discussion on #5913. Conceived first by @kprotty, although he never wrote it down 馃槈
Due to technical issues (pointed out by King) involving nested frames and the stubborn nature of some functions, cancelling a function from the awaiter side is sadly impractical. Something resembling cancellation is possible from the resumer side, however I believe that implementing this at the language level is a folly -- our job as language designers is not to provide every feature, but to provide only the features necessary to build whatever abstractions are suitable.
To that end, I propose one change to suspension semantics: rather than being valueless, suspend and suspend blocks will return a value to the function in which they appear, passed to them by the corresponding resume. An event loop may use this to inform a function how to proceed:
// In the suspending function
const action = suspend event_loop.registerContinuationAndCancellation(
@frame(),
continuation_condition,
cancellation_condition,
);
switch (action) {
.go => {},
.stop => return error.functionXCancelled;
}
// ------------------
// In the event loop (some details missing)
if (frame.continuation and @atomicRmw(bool, &frame.suspended, .Xchg, false, .Weak) {
const ptr = frame.ptr;
frame = null;
resume ptr, .go;
}
if (frame.cancellation and @atomicRmw(bool, &frame.suspended, .Xchg, false, .Weak) {
const ptr = frame.ptr;
frame = null;
resume ptr, .stop;
}
The invoker may specify a cancellation condition as argument (e.g. a timeout for a web request), and then the function will register the appropriate callbacks with the event loop. The event loop may even provide a method to generate an awaiter-triggerable cancel token, one end of which could be passed to a generically cancelable function. If a function is not cancelable (some file operations on some kernels), it can simply not take such a condition, and the user will not mistakenly try to cancel it.
Since @frame() may be called anywhere within the function, and the resumer needs to know the type before analysing the frame, the suspend type (T in anyframe<-T) must be part of the function's signature. I propose we reuse while loop continuation syntax:
const suspendingFunction = fn (arg: Arg) ReturnType : SuspendType {
// ...
};
Any function that uses the suspend keyword must have a suspend type. This is not function colouring, as any function with explicit suspend is necessarily asynchronous anyway (functions that only await cannot be keyword-resumed, so do not need a suspend type). The suspend type may be void or error!void (no error set inference, since such errors originate outside the function), in which case the handle type is anyframe<-void or anyframe<-error!void (not anyframe -- we require strongly typed handles for type checking, which is one drawback), and resume does not necessarily take a second argument, as in status quo.
This not only permits flexible evented userspace cancellation, but also more specialised continuation conditions: a function waiting for multiple files to become available could receive a handle to the first one that does, and combined with a mechanism to check whether a frame has completed, #5263 could be implemented in userspace in the same manner.
At first blush, this may appear to be hostile to inlining async functions -- however, allowing that would already require semantic changes (#5277) that actually complement this quite nicely: @frame() would return anyframe<-T of the syntactically enclosing function's suspend type, regardless of the suspend type of the underlying frame, and there is now a strict delineation between resumable and awaitable handles.
Small nitpick, but the check for &frame.suspended before resumeing it can be merged into the resume itself.
That's meant to be an atomic lock -- so if the continuation and cancellation resolve at the same time, they won't both try to resume at once. Double resume is UB in unsafe modes (or should be, if I understand correctly). I did notice some obvious holes in that impl though, so I've patched them (there might be more, but it's only for demonstration anyway, so who cares).
I think this can already (somewhat) be implemented in status-quo by communication via pointers. Untested:
const action = enum{.go, .stop};
fn asynchronous_task(action_ptr_loc: **action) result_type {
var resumer_requested_action: action = .go;
action_ptr_loc.* = &requested_action;
// ...
suspend;
switch(resumer_requested_action){
// decide what to do...
}
// ...
}
fn caller(){
var request_ptr: *action = undefined;
var aframe = async asynchronous_task(&request_ptr);
// ...
if() { //the same atomic-exchange you did
request_ptr.* = if(should_stop) .stop else .go;
resume aframe;
}
}
The downside is that you have to transport both aframe and request_ptr to a controlling resumer, which this proposal would solve.
I think the ability to upcast from anyframe<-T to anyframe<-void to allow unadorned resume aframe; (keeping the last suspend value unchanged) might also be useful.
Maybe not having that option (-> not requiring it in the async callee) is the saner default, but the only userspace workaround I can come up with requires an intermediate function as a seam and is really verbose.
there is now a strict delineation between resumable and awaitable handles
It can still be useful to have a type-erased handle for both, until you decide to split it up at a later point in the code - I assume that would be anyframe<-S->T .
Also, small nitpick, the syntax resume a, b; is a bit ambiguous if you consider that anyframe is itself a valid suspend-resume-parameter type.
Maybe resume(action) frame; would be clearer, f.e. resume(.stop) aframe;
De-lurking for a second.
Zig already has extended the meaning of else in other ways. What about something like this:
const result = suspend {
event_loop.registerContinuation(
@frame(),
...
);
} else {
// do cancellation actions.
}
This may be the wrong place to put that, but my understanding of the whole idea is that there are really only two things that can happen to a continuation from outside: resume and cancel. And if cancel can only be caught/happen at suspend points, then it seems like else might be sufficient.
Just a random thought...
Re-lurking.
I think the ability to upcast from
anyframe<-Ttoanyframe<-voidto allow unadornedresume aframe;(keeping the last suspend value unchanged) might also be useful.
One problem: where do you store that value?
It can still be useful to have a type-erased handle for both, until you decide to split it up at a later point in the code
As per discussion on #5277, no, this is not generally useful, and should not be encouraged. There is @ptrCast if such behaviour is desired.
the syntax
resume a, b;is a bit ambiguous
The first argument is the frame, the second is the suspend value. This is the case at every resume invocation. There is no ambiguity.
there are really only two things that can happen to a continuation from outside: resume and cancel
There are other use cases for suspend values as well -- read the proposal.
Zig already has extended the meaning of
elsein other ways
else means the same thing everywhere -- "if this condition is not met, and we're still evaluating, run this instead". This does not apply here -- the suspend body is always run, and whether the else is or not depends on outside influence.
allow unadorned
resume aframe;(keeping the last suspend value unchanged)
One problem: where do you store that value?
Inside the async function's stack frame, like it is in the status-quo code I posted.
The resume-parameter will already end up on the function's stack.
This change would require an initial default value (or potentially passed at every suspend point), and extend the lifetime of that memory slot to not be disjoint at every suspend point, but be allocated at the first and stay alive up until the last one.
The first argument is the frame, the second is the suspend value. [...] There is no ambiguity.
I meant ambiguity from a reader's perspective who's never encountered that syntax before, potentially mistaking it for a multi-resume (compare resume a; to resume a, b;), not ambiguity of the proposed ruleset.
Most helpful comment
De-lurking for a second.
Zig already has extended the meaning of
elsein other ways. What about something like this:This may be the wrong place to put that, but my understanding of the whole idea is that there are really only two things that can happen to a continuation from outside: resume and cancel. And if cancel can only be caught/happen at suspend points, then it seems like
elsemight be sufficient.Just a random thought...
Re-lurking.