I just stumbled over this in the std.Io.Group documentation (emphasis mine):
An unordered set of tasks which can only be awaited or canceled as a whole. Tasks are spawned in the group with Group.async and Group.concurrent.
[…]
However, asynchronous tasks are not guaranteed to run until Group.await or Group.cancel is called, so adding async tasks to a group without ever awaiting it may leak resources.
However the std.Io.VTable.groupAsync function has this to say:
When this function returns, implementation guarantees that start has either already been called, or a unit of concurrency has been assigned the task of calling the function.
This in isolation seems fine because both may be useful depending on the usecase. However std.Io.Group.async is implemented as follows:
pub fn async(g: *Group, io: Io, function: anytype, args: std.meta.ArgsTuple(@TypeOf(function))) void {
const Args = @TypeOf(args);
const TypeErased = struct {
fn start(context: *const anyopaque) void {
const args_casted: *const Args = @ptrCast(@alignCast(context));
_ = @as(Cancelable!void, @call(.auto, function, args_casted.*)) catch {};
}
};
io.vtable.groupAsync(io.userdata, g, @ptrCast(&args), .of(Args), TypeErased.start);
}
Meaning it just calls the other one. Is this just a case of documentation not being updated or is there something else there I’m potentially missing? Because for me this seems like a big footgun for some purposes, when I’m expecting that it already runs but it may not.
I’ve looked at the Threaded implementation and it seems to follow the groupAsync way of doing it.
They’re not contradictory. Assigning a unit of concurrency isn’t well-defined anywhere. It just means someone, at some point in time, will run the task. This point in time could be anywhere between now and Group.await returning.
Yes this and the unit of concurrency part makes sense and also isn’t really the problem here.
But what I’m reading from the emphasized part is that Group.await or Groups.cancel may be the “trigger” to those operations starting to execute. Meaning that all async calls will just be buffered somewhere and will never execute until either Group.await or Group.cancel are called.
The docs is just trying to be very flexible with the definition, to allow future implementations. However, I think it’s wrong in the context of a group. It’s normal usage pattern to never actually await a group, just keep spawning tasks and maybe cancel the whole group in defer. This is the only way to implement a standard server loop. So tasks in a group will definitely run even without await or cancel.
4 Likes
That’s also what I’m thinking and why I brought this up. await triggering the execution only makes sense in niche scenarios like a compute graph or something like that.
You are required to await or cancel it eventually to ensure its resources are cleaned up, ofc this often will be after quite a number of task spawns and completions.
@pzittlau It is important to clarify this topic is about async, but the usage pattern mentioned uses concurrent which has a stricter contract.
2 Likes
For my usecase an async is enough because I don’t need real concurrency via concurrent.
However I need the insurance, that the task runs without me triggering it via await or cancel. I don’t care if it’s done immediately in-line when calling async or if it’s done sometime afterwards. I just need it to eventually return without me needing to call await/cancel.
Therefore I will just mark @lalinsky’s reply as the solution and go on with using groupAsync directly and hoping that it will keep it’s current semantics.
be aware: you are writing code that leaks resources.
1 Like
Thank you, but nope. I will call await/cancel eventually but it shouldn’t be the thing to trigger other stuff running
1 Like
If this indeed is a server accept loop, you should be using concurrent, not async, as you don’t want to block the acceptor.
1 Like
Yeah I should’ve probably explained what I use it for a long time ago.
Basically I’m doing a transactional key-value store. Each transaction has an Io.Group and doing an operation(insert,lookup,delete,…) on a transaction is what will do the async call. The thing is that an operation should obviously return before calling commit or abort on a transaction but it also doesn’t need to be concurrent because it could just be done then and there.
So to reiterate I just need the guarantee that the operation/task/async is able to return before calling await/cancel and will not hand indefinitely.
1 Like
The IO interface does not guarantee what you want here. But specific implementations could offer that guarantee. I guess what you want could be viewed as the async version of a detached thread.
From the std.Io.Group documentation (not about either async not concurrent):
The resources associated with each task are guaranteed to be released when the individual task returns, as opposed to when the whole group completes or is awaited.
All implementations in stdlib, and all that I know of outside of stdlib, do guarantee that. With Zig changing so much each release, you can’t really assign much value to a comment in the docs.
You can want to under certain circumstances.
If you don’t you get a runaway memory condition which would eventually trigger an OOM condition.
Sure, if you block that’s bad, but in worst case it means higher latency for clients.
I personally think that std.Io.Select is the better choice for a server, e.g. something like this (I have not checked if it will compile; I wrote this mostly at the top of my head in this textbox):
const Tasks = union(enum) {
accepted: AcceptError!Stream,
done: void,
};
var select: std.Io.Select(Tasks) = .init(io, buffer);
defer while (select.cancel()) |t| switch (t) {
.accepted => |eu| if (handle(eu)) |s| s.close(io),
.done => {},
}
select.async(.accepted, Server.accept, .{server, io});
while (try select.await()) |t| switch (t) {
.accepted => |eu| if (handle(eu)) |s| {
// start to handle the connection
select.async(.done, handleConnection, .{io, s});
// also start to accept another connection
select.async(.accepted, Server.accept, .{server, io});
},
.done => {},
}
This would also work if the io implementation would e.g. be a single threaded blocking implementation (where you can use concurrent), but also deal decently well a more powerful implementation like e.g. std.Io.Evented.
5 Likes
This is a really nice solution, I like it a lot.
Note that it also depends on the fact that groupAsync runs the task without calling groupAwait, because Io.Select.await will wait on the queue, not the underlaying group. I think it’s a proof that the comment in docs is incorrect. 
2 Likes
I’ve now tried it both with a Group and a Select and both don’t quite lead to a nice API and are maybe even the wrong abstraction. Mostly because I will need to have a side channel for results and errors.
So I think I will just do it as a normal async and have a refcount and a status in the transaction. Then commit will change the status to committing and wait until the refcount is 0 and abort will set it to aborting. Then at various places the operations will just check whether they should abort and return error.Canceled.
This way I just forego all this and it does what I want it to do.
Well, with Select you can get an error via the normal return value way.
Of course if you additionally need a diagnostics value for errors, maybe sidestepping error unions with a handcrafted union is a good idea.
1 Like
Yes, that’s what they said. It’s normal usage to just .cancel it in a defer (which will always get called) rather than using .await at all.
4 Likes