-
-
Notifications
You must be signed in to change notification settings - Fork 2.5k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
add "select" syntax to the language to await the first function in a given set that completes #5263
Comments
I can see the conciseness of assuming Edit: Thanks for the quick reply, being a multi-
|
No, With |
PART 1 // AUTOCANCEL
fn httpRequestWithTimeout(url: []const, timeout: u64) !Response {
var request = async httpRequest(url);
var timeout = async sleep(timeout);
await {
request => |result| {
// cancel timeout;
// automatically cancels all frames from await block
return result;
} orcancel {
// when httpRequest finishes and finds out that
// it is cancelled the control flow jumps to here
// result is probably in scope
// free resources or ignore
_ = result;
// may not return a value here
},
timeout => {
// cancel request; // same as above
return error.TimedOut;
} orcancel {
// send signal to the timer or ignore
// may not return a value
},
}
}
// MANUAL CANCEL
// orcancel adds a 'finished, but cancelled, await myself' code path.
// this may be a general feature of the await:
fn example() Value {
const gvframe = async getValue();
return await gvframe orcancel {
// await suspends the 'example' frame
// if 'example' is cancelled (cancel bit is set) this code path will run when 'gvframe' completes
}
} (my personal opinion: timeout is a basic property of a request so PART 2 Some examples for 'cancelable async code = blocking code', assuming that fn httpRequest(url: []const) ![]const u8 <or cancel> { // opt-in compatible with error union return types
var conn = connect(url); // suspend point
var buf = allocBuf();
errdefer free(buf); // resource management
// every following resume point will check if the frame is cancelled
// and simulate `error.CancelZigFrame` so the errdefers run
readHttpToBuf(buf); // suspend point
return buf; // see [1]
} This opt-in mechanism may allow to implement Open questions: |
I updated the original post to take advantage of a hypothetical Example implementation of const CancelToken = struct {
/// Set to `true` to prevent `cancel` from running. This field is helpful by convention;
/// it's not required to be used.
awaited: bool = false,
/// This field is possibly modified from multiple threads and should not be accessed
/// directly.
state: enum {run, cancel, done} = .run,
fn cancel(self: *Operation, frame: var) void {
if (self.awaited) return;
const need_cleanup = @atomicRmw(bool, &self.state, .Xchg, .cancel, .SeqCst) == .done;
const return_value = await frame.*;
if (need_cleanup) {
// ... here, clean up resources from the operation completing successfully ...
if (return_value) |response| {
response.deinit();
} else |err| {
// nothing to clean up in this case
}
}
}
fn checkIsCanceled(self: *Operation) bool {
return @atomicLoad(bool, &op.state, .SeqCst) == .cancel;
}
fn finishAndCheckIsCanceled(self: *Operation) bool {
return @atomicRmw(bool, &op.state, .Xchg, .done, .SeqCst) == .cancel;
}
}; Example implementation of fn httpRequest(tok: *CancelToken) !*Response {
var resp = try allocator.create(Response);
errdefer allocator.destroy(resp);
var buf: [4096]u8 = undefined;
while (true) {
read_from_socket(&buf);
if (tok.checkIsCanceled()) {
return error.OperationCanceled;
}
try resp.data.append(buf);
}
if (tok.finishAndCheckIsCanceled()) {
return error.OperationCanceled;
}
return resp;
} |
The threading here gets tricky if unselected frames need to be guaranteed to be awaitable, but I think it's still possible as long as all async functions Xchg their frame's awaiter to "already returned", even if they are awaited. Did I miss any race conditions here? /// Before calling select, all frames must be unawaited or finished but unconsumed.
/// After select returns, all frames will be unawaited or finished but unconsumed,
/// except for the one frame that was selected, which will be consumed.
void select(self: @Frame(select), a: @Frame, b: @Frame, c: @Frame) var {
self.chosen_path = 0;
if (cas(&a.awaiter, null, A_READY)) |actual_awaiter| {
assert(actual_awaiter == ALREADY_RETURNED);
goto A_READY;
}
if (cas(&b.awaiter, null, B_READY)) |actual_awaiter| {
assert(actual_awaiter == ALREADY_RETURNED);
goto B_READY;
}
if (cas(&c.awaiter, null, C_READY)) |actual_awaiter| {
assert(actual_awaiter == ALREADY_RETURNED);
goto C_READY;
}
A_READY:
// check if another path already started. If it has, return to the 'resume' state.
// a future 'await' will consume the result without suspending, so no other 'resume'
// is needed.
if (cas(&self.chosen_path, 0, 1)) |other_path| return;
// consume the input, check for multiple awaiters UB. safe modes only.
assert(cas(&a.awaiter, ALREADY_RETURNED, RESULT_CONSUMED) == null);
// if the other machines haven't finished, restore them to the unfinished state
// if either of these CAS ops fails, there is a finishing race. `chosen_path`
// will handle that race condition, and the other awaiter will be left in a
// returned but not consumed state.
_ = cas(&b.awaiter, B_READY, 0);
_ = cas(&c.awaiter, C_READY, 0);
return caseA(a.return_value);
B_READY:
if (cas(&self.chosen_path, 0, 2)) |other_path| return;
assert(cas(&b.awaiter, ALREADY_RETURNED, RESULT_CONSUMED) == null);
_ = cas(&a.awaiter, A_READY, 0);
_ = cas(&c.awaiter, C_READY, 0);
return caseB(b.return_value);
C_READY:
if (cas(&self.chosen_path, 0, 3)) |other_path| return;
assert(cas(&c.awaiter, ALREADY_RETURNED, RESULT_CONSUMED) == null);
_ = cas(&a.awaiter, A_READY, 0);
_ = cas(&b.awaiter, C_READY, 0);
return caseC(c.return_value);
} |
Extension to the proposal, adding select {
one => {},
two => {},
else => {},
} This would mean that these two things are semantically equivalent: var y = nosuspend await x; var y = select {
x => |value| value,
else => unreachable,
}; If this makes sense to do when implementing the proposal then let's do it, otherwise |
@SpexGuy that's the same general idea I had, if I'm reading it correctly. It might be tricky to support arrays of frames though. I do think we should have a plan for how to support arrays of frames. |
We need to iterate all of the frames to reset their awaiters, so scanning to find one that is done shouldn't be too much extra work. pub fn select(frame: *@Frame(select), select_frames: []anyframe) var {
// either define this as checked UB or check and return here.
// this would hang indefinitely otherwise since no frame will resume.
if (select_frames.len == 0) return;
frame.taken = @as(i32, -1);
frame.select_frames = select_frames;
frame.resume_state = DO_SELECT;
for (select_frames) |*selected, i| {
if (cas(&selected.awaiter, null, frame)) |actual_awaiter| {
assert(actual_awaiter == ALREADY_RETURNED);
assert(xchg(&frame.taken, 0) == -1);
goto DO_SELECT;
}
}
assert(xchg(&frame.taken, 0) == -1);
return_to_resume;
DO_SELECT:
while (true) {
if (cas(&frame.taken, 0, 1)) |actual_taken| {
if (actual_taken == 1) {
return_to_resume;
} else {
// the frame was so fast, it returned before the
// setup code finished setting up awaiters.
// Need to wait for the setup code to finish
// before continuing, but can't suspend.
// It's on a different thread, we just have
// to wait it out
assert(actual_taken == -1);
// maybe yield to help the scheduler?
}
} else break;
}
// when we get here, at least one item is in the returned state
// we need to iterate all frames to reset their awaiters,
// so scanning to find one that's done is not extra cost.
var selected_index: ?usize = null;
for (frame.select_frames) |*select_frame, i| {
if (cas(&select_frame.awaiter, frame, 0)) |other_awaiter| {
if (other_awaiter == ALREADY_RETURNED) {
selected_index = i;
// don't break, we need to reset all awaiters.
} else {
assert(other_awaiter == 0);
break;
}
}
}
assert(selected_index != null);
const selected_frame = select_frames[selected_index.?];
assert(cas(&selected_frame.awaiter, ALREADY_RETURNED, RESULT_CONSUMED) == null);
return runSelectBlock(selected_frame, selected_index.?);
} We could also mix the two by using a fake intermediate frame type that is binary-compatible with anyframe to represent each case in the select. const SelectFrame = struct {
frame_function: usize = undefined, // points to a stub function to resume from for this select
index: usize = undefined, // index of this frame (normally this is the state)
awaiter: anyframe = undefined, // points to the frame of the function containing the select
};
fn select(frame: *@Frame(select), select_frames: *[N]anyframe) void {
if (N == 0) return;
frame.taken = @as(i32, -1);
frame.select_frames = select_frames;
frame.fake_frames: [N]SelectFrame = undefined;
for (frame.fake_frames) |*fake_frame, i| {
fake_frame.frame_function = resume;
fake_frame.index = i;
fake_frame.parent_frame = @frame();
}
for (select_frames) |*frame, i| {
if (cas(&frame.awaiter, null, &frame.fake_frames[i])) |actual_awaiter| {
assert(actual_awaiter == ALREADY_RETURNED);
frame.remaining_references = i;
assert(xchg(&frame.taken, 0) == -1);
resume(&frame.fake_frames[i]);
}
}
// need to make sure that no frames are referencing any of
// our frame stack allocated fake frames before we return.
frame.remaining_references = N;
assert(xchg(&frame.taken, 0) == -1);
}
// this function is called just like a frame resume
fn resume(frame: *FakeFrame) void {
const base_frame = @as(@Frame(select), frame.awaiter);
while (true) {
if (cas(&base_frame.taken, 0, 1)) |actual_taken| {
if (actual_taken == 1) {
// release one reference to a fake frame
// this one will no longer be used.
if (atomic_decrement(&base_frame.remaining_references) == 0) goto AFTER_BLOCK;
return_to_resume;
} else {
assert(actual_taken == -1);
}
} else break;
}
// only one thread will get here
for (base_frame.select_frames) |*select_frame, i| {
if (cas(&select_frame.awaiter, &base_frame.fake_frames[i], null) == null) {
// we beat the frame to its continuation, release a reference for it
// there is still one reference for the frame we finished, so this must be above zero.
assert(atomic_decrement(&base_frame.remaining_references) > 0);
}
}
const index = frame.index;
const selected_frame = base_frame.select_frames[index];
base_frame.ret_val = runSelectBlock(selected_frame, index);
// release one reference count. If the count is not zero at this point, it means that
// one thread is currently between marking its frame as returned and running the loop at
// the top of this function. That thread will run `AFTER_BLOCK` when it decrements and gets zero.
if (atomic_decrement(&base_frame.remaining_references) != 0) return_to_resume;
AFTER_BLOCK:
return base_frame.ret_val;
} |
talking of cancel tokens & timeouts; https://vorpus.org/blog/timeouts-and-cancellation-for-humans/ |
If I understand this correctly, then on wakeup the |
Not quite. It just needs to ensure that only one select branch will execute. So there is one atomic flag for the select. Each awaiter is set up to check and set the flag. If they are the first one to resume, they reset the awaiters on the other frames. If any of those other frames have already returned to their awaiter, they will see that the flag is set and will suspend to the event loop, as if they had no awaiter. This doesn't eliminate the race but it ensures that both paths behave correctly. |
Thought: if we accept |
At first glance, I don't see a good reason to support any of those. A good motivating example could change my mind though. In practice, I don't expect select to be used even with anyframe, given the constraints on frame lifetime. It should mostly be concrete frame types that are selected upon. These are my gut reactions:
|
Well, as a motivating use case for |
If a frame is cancelable as per #5913, then requiring a result location is an implicit suspend point -- when a frame is suspended on such a point, This requires further thought. I suspect this is one of those cases where the real solution comes from an angle no one has thought of yet. Some ideas, none of them good:
|
Result locations are assigned on frame creation, so there is no need to
wait. If the function is called with `async`, the result location is in the
frame. If called without `async`, the result location is assigned as it
would be for a normal call. If you need to run an async function in
nonblocking fashion with a result location, the @asynccall built-in allows
you to specify the result location. So there's no need to suspend on the
first use of the result location.
…On Fri, Nov 27, 2020, 3:30 AM Eleanor Bartle ***@***.***> wrote:
If a frame is cancelable as per #5913
<#5913>, then requiring a result
location is an implicit suspend point -- when a frame is suspended on such
a point, select would need to know whether the frame would immediately
run to completion once awaited, or have some code left. This means extra
frame state. Of course that's no big deal in safe modes, but select still
needs to operate correctly in unsafe modes.
This requires further thought. I suspect this is one of those cases where
the real solution comes from an angle no one has thought of yet.
—
You are receiving this because you were mentioned.
Reply to this email directly, view it on GitHub
<#5263 (comment)>, or
unsubscribe
<https://github.com/notifications/unsubscribe-auth/AARNCWRNNU7VTRYUYBIZDJLSR5WSJANCNFSM4MYJDFMQ>
.
|
Thanks, this is brilliant. Here's another use case to throw into the mix, if you like: How would one use I suppose this would be a |
@jorangreef that is a great question, thank you for sharing this use case. I think your idea may be the way to go, but I will make sure to keep this use case in mind when focusing on this proposal, and have a solid answer for it. |
Thanks @andrewrk — looking back at my comment, I can only guess what I was working on at the time! 😉 |
I thought I had already proposed this but I could not find it.
This depends on the, which is related to #3164 but I will open an explicit proposal for it.cancel
proposal being acceptedThere is one thing missing with zig's
async
/await
features which is the ability to respond to the first completed function. Here's a use case:Here's a more complicated example, which shows how select also supports arrays of frames:
Here's another use case:
zig/lib/std/event/batch.zig
Lines 68 to 86 in 9b788b7
As an alternative syntax, we could replace
select
withawait
. Since only labeled blocks can return values,await {
is unambiguously the "select" form ofawait
.The text was updated successfully, but these errors were encountered: