When should I `io` be passed in vs stored?

In his Don’t forget to flush talk, Andrew mentions one of the advantages of having an io instance passed as an argument is that function purity becomes apparent. I love that.
However, sometimes standard library types store their own io instance, making this function purity unapparent at first glance.

take this simple http server program:

const std = @import("std");
const print = std.debug.print;
const Io = std.Io;

pub fn main(init: std.process.Init) !void {
    const io = init.io;
    const address = try Io.net.IpAddress.parse("0.0.0.0", 8000);
    var server = try address.listen(io, .{});
    defer server.deinit(io);
    const stream = try server.accept(io);
    defer stream.close(io);

    var read_buffer: [1024] u8 = @splat(0);
    var write_buffer: [1024] u8 = @splat(0);
    var reader = stream.reader(io, &read_buffer);
    var writer = stream.writer(io, &write_buffer);

    var http_server = std.http.Server.init(&reader.interface, &writer.interface); 
    // server will wait for requests in the line below 
    var request =  try http_server.receiveHead();
    
    var body_buffer: [1024]u8 = @splat(0);
    const bodyReader = request.readerExpectNone(&body_buffer);
    var arena = std.heap.ArenaAllocator.init(std.heap.smp_allocator);
    const alloc = arena.allocator();
    
    const body = try bodyReader.readAlloc(alloc, @intCast(request.head.content_length orelse 0));
    std.debug.print("{any}", .{body});
    const html = try readHtml(alloc, io);
    try request.respond(html, .{.status = .ok});
}

fn readHtml(alloc: std.mem.Allocator, io: Io) ![] u8 {
    return try std.Io.Dir.cwd().readFileAlloc(io, "./index.html", alloc, Io.Limit.unlimited);
}

If you look into std.http.Server.receiveHead you’ll see that defines a std.http.Server.Reader, which contains a std.Io.Reader, which in turn contains an Io instance.
Why does/should std.Io.Reader contain an Io instance within it, instead of requiring it be passed in all its functions?
I guess it’s a little less verbose, but we do lose this ability to tell at a glance which functions may do Io (and possibly block).

More generally, what’s the rule of thumb to pass Io instances (or for that matter, std.mem.Allocator instances) to every function call, vs having a struct store it and pass it “implicitly”?

Let me know if any clarification is needed

2 Likes

When Zig library developers say you need to pass an Io or Allocator or whatever, that usually means that the library itself will not hiddenly create its own instance. It doesn’t mean that every single public fn will require an Io and Allocator.

Whenever you initiate the http.Server, you will need a stream reader and stream writer. So you have to initiate them firstly… When you initiate the stream reader and writer, you will need to pass the Io. So this is your Io, not the http.Server Io… So you put the defer close() on the next lines of the stream reader and writer, not the http.Server.init(). The http.Server doesn’t hold the Io, not even the reader, as the reader just an interface.

Another example is JSON and anything their name ended as a “Managed”, such as ArrayListManaged. Those will hold the Allocator and/or Io, but they will not create it themselves.

This is very important because they allow the program to group operations together. They also tell you that this instance will hold some data for a longer lifetime so the data is reusable in the future. This will also make the ArrayList.deinit() function possible, instead of abusing the defer allocator.free in half of your codes.

In my understanding, for Allocator, if you may only use a few instances, but they need to store a lot of complicated data in heap, and it has a lot of functions will do some loops to manipulate those complicated data, they would probably have a managed version. They may even create their own ArenaAllocator and store it, such as the std.json. When you pass an Allocator into their init(), they essentially just use your Allocator as the child_allocator. The reason is that, they need to use the allocator to fine tune their allocation for their complicated loops and calculations, because some Allocator can retain the capacity to avoid relloc too often… I don’t know much about Io yet, so I am not sure when storing the Io is better. My personal rule of thumb is that, if I think storing the Allocator is better, I will also store the Io, so the init() function can have Io and Allocator together.

Of course, on the other hand, if the instance only holds some simple data, and you may have a lot of instances, it’s good to have unmanaged instance as well, but I think it’s case by case now.

If an unmanaged instance is very complicated and has a lot of functions required Io and Allocator, it may create a lot of confusions to the users. The user may not know when to free them. That can cause UAF, or double free, or leaking. It needs very good transparency and documentation. The reason is when users use an instance and see a function required Io and Allocator, they have to think about when to free them. When I created a library for my team using, I generally follow a rule: “whenever you see an Allocator and/or Io pass in, you need to defer free them or deinit() next line”. So one pass, one free… A Managed instance can create the scene of “one pass one deinit”.

In sum, when Zig says reusable codes (lib) should accept Io and Allocator pass, it means it allows the user to create their Io and Allocator, and pass through… It doesn’t mean how many times and how many functions you can pass through. As long as it is your own Io and own Allocator, that’s the Zig way.

4 Likes

I think that storing these things is mostly an antipattern, and that part of what you’re seeing is that it is a seductive antipattern. Because std.Io is so new, we have yet to discover what an “unmanaged” version of some of these structs might properly look like.

1 Like

std.Io.Reader does not contain an Io instance or require one actually (and std.http.Server.receiveHead does not interact with an Io instance directly, the reason receiveHead blocks is because you are passing in std.Io.net.Stream.Reader which does use io into std.http.Server.init). Readers/Writers are an abstraction over streams of bytes which doesn’t inherently require io (e.g. Reader.fixed) but some implementations of the Reader interface do require io like std.Io.File.Reader. Implementations of the Reader interface are able to use @fieldParentPtr to get an io instance from their parent struct if they need one. You can’t do something like options: struct {io: ?std.Io = null} on every Reader function that calls into the vtable because users of the Reader interface don’t know whether to pass in io or not or whether the reader requires some bespoke io. Also that’s a lot of copying the io interface which I would assume to be slower than @fieldParentPtr (?).

My rule of thumb for whether to have io/gpa in a struct is how many instances of the struct there are and how much the struct is using io/gpa. If theres 1 instance of the struct and it uses io/gpa in more than a few functions then I store it in the struct. If theres more than one then I feel like I would be wasting space storing io (@sizeOf(usize) * 2) or gpa (also @sizeOf(usize) * 2) (there’s 2 ptrs in each struct).

For an interface, if the interface requires io/gpa then you should pass it in because there should be multiple instances of an interface and if it doesn’t require io/gpa then let the implementations do @fieldParentPtr or whatever other retrieval method to get the stuff they need.

1 Like

I don’t have a good idea of how to measure this (although I’d love to see a shootout!) but my thinking is slightly different: copying std.Io is very easy, because it is just a write of 2 * usize; probably it can be passed in registers. @fieldParentPtr itself is also not difficult (it’s just subtraction). The expensive thing in both cases is the dereference, which requires loading an address. Needing to do that load in order to grab a stored std.Io instance is very likely to be slower, but given that we’re usually in a situation where the load is happening anyway, it probably comes out in the wash.

2 Likes

Always pass Io and Allocator instances explicitly except when you implement an interface that does not allow it. For example, std.Io.Reader.VTable.stream does not have Io parameter. That is why std.Io.File.Reader have to store Io in a field. Another example, std.Io.Writer.VTable.drain does not have Allocator parameter. That is why std.Io.Writer.Allocating have to store Allocator in a field.

4 Likes

When I store io (and allocator)

I am not going to start holy war

These are my preferences

Like OFFTHETOPIC endian imports

Feelings

When I feel that it’s uncomfortable to add io/allocator every call

Multi-tasking

When struct/object travels between tasks

Io isn’t I/O

const _Mailbox = struct {
    const no_create_destroy = void{};

    poly: polynode.PolyNode,
    mutex: Io.Mutex,
    cond: Io.Condition,
    list: std.DoublyLinkedList,
    len: usize,
    closed: std.atomic.Value(bool),
    oob_count: usize,
    oob_last: ?*std.DoublyLinkedList.Node,
    wake_epoch: u64,
    io: Io,
    alloc: std.mem.Allocator,
};

Example of call

mailbox.wakeUpAll(handle);
2 Likes

If you want your storage to be movable, you shouldn’t store various interfaces, including io.

this confuses me. std.Io is trivially movable (indeed, that’s why all functions take it as a parameter like io: std.Io). maybe you mean something else?

1 Like

I agree that in practice Io is almost movable, because there’s almost no need to move the instance behind the Io interface. Std also designs the instances behind the Io interface with the assumption that they are not movable.

I generally assume that storage carrying interfaces (pointers) is non-movable, because if there’s a need to move the instances behind these interfaces, the interfaces would break. For Allocators, such a need might actually exist, so for me, data structures carrying allocator interfaces are non-movable. But I really haven’t thought of a practical scenario where there would be a need to move instances behind Io interfaces, so Io interfaces can generally be moved.

the arguments for/against movability of std.Io apply verbatim to std.mem.Allocator, since they are designed on precisely the same userdata+vtable pattern.

of course you are right that moving the implementation is inadvisable, since it breaks all existing interfaces taken from it.

1 Like

Yeah, that’s why even when the Io interface is implemented exactly like the Allocator interface (even if it’s just a simple pointer being stored, it’s pretty much the same), their moveability value is different. The issue is whether the underlying instance actually needs to be moved. If it does, then putting the interface into storage and making it non-movable becomes a real problem, that’s something Allocator instances might face. But Io instances don’t actually need to be moved, so putting the interface into storage doesn’t seem like a big deal.

For me it’s mostly about writing re-usable code, as storing the interface means the caller can’t move it’s implementation. You’re kind of doing something akin to function colouring but for the whole struct.

For Io this is unlikely to happen, but still possible, but for an allocator it’s entirely possible - fixed buffer allocators, arenas, etc. might easily end up moving especially at the end of an init function.

Generally speaking I only store either one when I’m forced to by other limitations (e.g. opaque callbacks, or implementing an interface that doesn’t support passthrough).

1 Like

What do you mean when you say “the caller can’t move it’s implementation”
Zig’s fields are always public, so you can just change the io interface that is stored in a struct, right?

I want to avoid introducing headache-inducing terminology, but the “move” we’re talking about here roughly refers to a “trivial move,” meaning ownership transfer that can be achieved through a simple copy. Modifying the struct during the move, however, complicates the concept of a move even further.

1 Like

A common example is creating an arena deeper in the call stack, but needing to give it to the caller so it can free it when it wants.

If other data has a handle to that allocators interface, you cannot naively return a copy of the allocator to the caller, as the handles to the allocator will be pointing to the stack of the function that just returned!

There are various ways to solve this ofc that range from: the function not creating the arena, instead getting it as a parameter; or allocating the arena with itself so that it has a stable address.

2 Likes