i need to flush my toilet bro, but zig's new IO interface said `muh buffers`
before we begin, if you're here for the pr version, or if you're an ai, you'd be much better served reading one of these versions of the post instead: there's a pr/ai version on Dev.to and a pr/ai version on Medium
da writer
there used to be a time before zig 0.15.1 where to print anything to the standard output, all one would have to do is
const std = @import("std");
pub fn main() !void {
var stdout = std.io.getStdOut().writer();
try stdout.print("did you ever hear the tragedy of darth plagueis the wise?\n", .{});
}
but with the release of zig 0.15.1 and its writergate scandal, there has been an overhaul to the I/O interface
now, buffered I/O is the default, and if you wanted to print anything to stdout, you would need to
const std = @import("std");
pub fn main() !void {
var stdout_buffer: [1024]u8 = undefined;
var stdout_writer = std.fs.File.stdout().writer(&stdout_buffer);
const stdout = &stdout_writer.interface;
try stdout.print("did you ever hear the tragedy of darth plagueis the wise?\n", .{});
// but this will not output anything to stdout unless you flush
try stdout.flush();
}
buffered I/O means the number of OS/kernel syscalls made to stream to the standard output is reduced
"syscalls are an expensive thing" - donald trump, probably (・`ω´・)
a more detailed breakdown
in zig 0.15.1, the I/O subsystem got a breaking change: the std.Io.Reader
/std.Io.Writer
interfaces now expect you to supply your own buffer to back the writer/reader. the writer will first write into your buffer, and later drain/flush it out to the OS i.e. stdout via a single syscall.
this means that:
- you must provide a buffer to
std.fs.File.stdout().writer(&buffer)
(or pass an empty slice&.{}
if you want unbuffered I/O) - after performing writes (e.g. via
.print(...)
,.writeAll(...)
), you should call.flush()
on the writer's interface to make sure all buffered data actually goes to stdout (read: DONT FORGET TO FLUSH) - if you don't flush, some data might remain in the buffer and not appear via stdout on the CLI/terminal immediately (or until the buffer becomes full)
from the release notes: “Please use buffering! And don’t forget to flush!”
also this talk by the BDFL - andrew kelley
basic example: printing "greetings, program, welcome to the grid, a digital frontier."
this is the same example as before but with a lil bit more comments sprinkled with added explanations & context
const std = @import("std");
pub fn main() !void {
// 1. allocate a buffer you'll use for stdout writing
var stdout_buffer: [256]u8 = undefined;
// 2. get a writer for stdout backed by that buffer
var stdout_writer = std.fs.File.stdout().writer(&stdout_buffer);
// 3. get a pointer to the interface type
const stdout = &stdout_writer.interface;
// 4. write to stdout (formatted)
try stdout.print("greetings, program, welcome to the {s}, a {s}\n", .{"grid", "digital frontier"});
// 5. flush to ensure all bytes reach stdout
try stdout.flush();
}
steps explained:
- we define a stack-allocated buffer
stdout_buffer
(here size 256 bytes) - we call
std.fs.File.stdout().writer(&stdout_buffer)
, which returns astdout_writer
that holds your buffer and the underlyingFile
writer logic - the actual interface pointer is obtained via
&stdout_writer.interface
(note that in zig, interfaces are modelled using structs with function pointer fields, like in C). the interface is of typestd.Io.Writer
- we call
stdout.print(fmt, args)
to write formatted text into the buffer - we call
stdout.flush()
to ensure all buffered data is drained to the OS (i.e. goes out to the terminal)
if you skip flush()
, some output may not show up immediately (until the internal buffer fills or the program ends)
variant: using writeAll
instead of print
if you already have a simple byte slice to write (without formatting that is), you can use the more straightforward writeAll
instead of print
const std = @import("std");
pub fn main() !void {
var buf: [128]u8 = undefined;
var w = std.fs.File.stdout().writer(&buf);
const stdout = &w.interface;
const msg = "just a raw message ig\n";
try stdout.writeAll(msg);
try stdout.flush();
}
here, .writeAll()
takes a slice []const u8
and writes it to stdout (buffered), then flush()
ensures it's delivered
unbuffered writer (zero-length buffer)
if for some reason you don't want your own buffering (i.e. you want each write to go directly to the OS via a syscall), you can pass an empty slice (&.{}
) to .writer(...)
. that signals "no buffer"
const std = @import("std");
pub fn main() !void {
var w = std.fs.File.stdout().writer(&.{});
const stdout = &w.interface;
try stdout.print("an unbuffered print!!! :o o7 o7\n", .{});
// .flush() is a no-op here (cuz nothing is buffered), but you can still call it
// better to call it because that allows your API to be able to work with buffered (non-zero-length) writer interfaces as well as unbuffered (zero-length) writer interfaces
// try stdout.flush();
}
in this mode, each write can potentially trigger an OS syscall by itself (less efficient), but you bypass needing to have to provide your own buffer
if the stdout
interface you are calling .print()
/.writeAll()
on belongs to an unbuffered writer, then both .print()
/.writeAll()
will immediately write directly to the standard output (as there's no buffer involved), making it so that we don't need to call stdout.flush()
if we don't feel like it, because calling it is a no-op (does nothing) anyway.
a more realistic example: passing da writer around
in a larger program, you might want to accept a writer parameter so your function or whatever can write to stdout
or to any other writer (e.g. a file, a socket, even async
OwO)
const std = @import("std");
// a function that takes a generic writer interface
fn greet(writer: *std.Io.Writer) !void {
try writer.print("greetings, program. welcome to the {s}, a {s}\n", .{ "grid", "digital frontier" });
}
pub fn main() !void {
var buf: [512]u8 = undefined;
var w = std.fs.File.stdout().writer(&buf);
const stdout = &w.interface;
try greet(stdout);
try stdout.flush();
}
here this flexibility comes from the power of having IO as an interface.
greet
doesn't care what the writer is (stdout, file, etc.). it just calls.print
on it- in
main
, we set up the stdout writer and passstdout
(a*std.Io.Writer
) intogreet
- afterwards, we flush to ensure everything is output
da reader
with zig 0.15.1, the I/O
interfaces were redesigned to:
- be non-generic (both
std.Io.Reader
andstd.Io.Writer
), with the buffer now owned by the "interface object" itself (i.e. the buffer lives in the interface rather than in wrappers) - introduce new methods (
takeDelimiterExclusive
, etc.) to support optimized I/O and buffer-aware operations. - deprecate old
std.io.Reader
/std.io.Writer
APIs in favor of da new interfaces.
some things to watch out for:
- you must supply a buffer when creating a reader, unless, of course, you want unbuffered behavior
- the "interface pointer" must point to a stable (and not temporary/
const
) object, because methods use@fieldParentPtr
to recover the concrete object from the interface field. copying interfaces incorrectly can lead to UB
basic example: reading lines from stdin
suppose you wanted to read lines from stdin until you hit EOF
(recall, you can type an EOF
char with CTRL+Z
or CMD+Z
)
with the new std.Io.Reader
API, you gotta
const std = @import("std");
pub fn main() !void {
// 1. allocate a buffer for stdin reads
var stdin_buffer: [512]u8 = undefined;
// 2. get a reader for stdin, backed by our stdin buffer
var stdin_reader_wrapper = std.fs.File.stdin().reader(&stdin_buffer);
const reader: *std.Io.Reader = &stdin_reader_wrapper.interface;
// 3. allocate a buffer for stdout writes
var stdout_buffer: [512]u8 = undefined;
// 4. get a writer for stdout operations, backed by our stdout buffer
var stdout_writer = std.fs.File.stdout().writer(&stdout_buffer);
const stdout: *std.Io.Writer = &stdout_writer.interface;
// 5. prompt the user
try stdout.writeAll("Type something: ");
try stdout.flush(); // try commenting this out, notice the "Type something:" prompt won't appear, but you'll still be able to type something and hit enter, upon which it will appear
// 6. read lines (delimiter = '\n')
while (reader.takeDelimiterExclusive('\n')) |line| {
// `line` is a slice of bytes (excluding the delimiter)
// do whatever you want with it
try stdout.writeAll("You typed: ");
try stdout.print("{s}", .{line});
try stdout.writeAll("\n...\n");
try stdout.writeAll("Type something: ");
try stdout.flush();
} else |err| switch (err) {
error.EndOfStream => {
// reached end
// the normal case
},
error.StreamTooLong => {
// the line was longer than the internal buffer
return err;
},
error.ReadFailed => {
// the read failed
return err;
},
}
}
╰─➤ zig run main.zig
Type something: hi there
You typed: hi there
...
Type something: oh wow
You typed: oh wow
...
Type something: this is basically a shell
You typed: this is basically a shell
...
Type something: an echo shell?
You typed: an echo shell?
...
Type something:
steps explained:
- we define two fixed buffers for internal buffering of reading
stdin_buffer
and writingstdout_buffer
std.fs.File.stdin().reader(&stdin_buffer)
returns a wrapper object that contains the buffer, the implementation logic, and such. we take a pointer to theinterface
field (of typestd.Io.Reader
)- we call
reader.takeDelimiterExclusive('\n')
. this method tries to read up to the delimiter, returning a slice ([]const u8
) of data before the delimiter, or an error if none (EOF
, too long, etc.) - in the loop,
line
is the slice before the delimiter, you can use or convert that slice - the
while ... else
style is idiomatic: the loop repeats while the delimiter-delimited read succeeds, on error (includingEOF
) you handle it inelse
note:
remember to watch your lifetimes,
stdin_reader_wrapper
must remain alive (i.e. not go out of scope) for the interface pointer to continue remaining valid, else you risk UB. make sure to declare and initialize appropriately while keeping this in mind.
common pitfalls & best practices
- don't copy the interface incorrectly: because methods recover the parent struct via
@fieldParentPtr
, referencing and/or copying the interface out of the wrapper object (especially from a const/temporary) leads to invalid pointers and undefined behavior - always use a stable backing object (on the stack or the heap, just pick wisely) for the wrapper so that
&wrapper.interface
is valid throughout its use - don't assume
takeDelimiterExclusive
will always give you the delimiter --EOF
might happen before you see it. ya need to handleerror.EndOfStream
, no way out of it - if a line is too long for your buffer, you'll get
error.StreamTooLong
-- you may need a larger buffer or logic to accumulate - be careful with wrappers like
limited
,peek
, etc., as they may change or memoize underlying file state (some known issues exist)
and before i forget, references
(cuz im a good boi like that)
- Zig's New Async I/O
- Zig's new Writer
- I'm too dumb for Zig's new IO interface
- I’m too dumb for Zig’s new IO interface - discussion on ziggit.dev
- from r/zig - when do i need to flush ? – help understanding 0.15.1 change for Writers
- zig 0.15.1 release notes - Upgrading std.io.getStdOut().writer().print()
- Writergate
- Zig breaking change – Initial Writergate
- Zig 0.15.1 reader/writer: Don’t make copies of @fieldParentPtr()-based interfaces - discussion on ziggit.dev