Async, stacks and colors revisited

This is likely to go essay style, so if you are looking for a fast read, this is not it.

First this: I love std.Io and it’s concurrency/async concept. It’s beautiful. It’s unfinished. I worked a bit with zio, which is great. One of my long term side projects is a reimplementation of Nats (a message broker/communication framework), same vision, but focused on performance and lean resource usage and without the spaghetti code. One of the requirements is that such a system must handle a lot of connections and a lot of messages, which translates to a lot of Go routines, fibers, callbacks or whatever is responding to IO.

This is where you cannot just use async or concurrent whenever you have to wait for IO to do something, because every waiter get’s a stack, something presized from a few kB to MB. Small sizes lead to stack overflows or memcpy or various other issues that each have their price to avoid. Big sizes lead to massive amounts of wasted memory or if you are fancy at least address space.

It’s easy enough to write a custom event loop for use cases where you cannot use std.Io naively, but the problem keeps surfacing in other areas. For example, you need to parse a message header, but the header is not guaranteed to come in as contiguous memory area. You want to read the header before the message is complete, because the message routing is complex and takes time and why let other cores sleep while they have work to do. You don’t know whether the header is completely read until you start parsing it and hit the end of buffer. So you have to handle the case when you need to wait for more data.

Parsing a header requires access to the data. If the data is in cache, it’s there, if not, loading it takes much longer than the parsing itself. So you want to do the parsing in the thread that does the IO, because the data is then still in cache (depending on stuff). But if you do compute in a thread that does a lot of IO, everybody else has to wait for the compute, so that better be fast, really fast.

My header scanner uses simd and compared with a byte-by-byte scanner, it’s about 1.5-3x faster. Mind you, these a nano-seconds, not a big deal. But there is a reason why simdjson has such a great reputation. The scanner implemented using std tokenizers is 2-4x slower than the b-b-b scanner, despite also using simd, it just uses a fresh register load for every token you scan.

This scanner I wrote is surprisingly cool. I plan to use it for other things, given how convenient and fast it is. Like CSV, passwd, procfs stuff scanning. All of these might be used in performance critical contexts and all these contexts have to somehow handle fragmented input. You really don’t want to load an entire file into memory if you don’t know how big it is. Fragmented input means you need to pause scanning until you have data to scan.

When using std.Io, this is no issue, you just use a reader-kind of abstraction for your input and code away. If your input is not yet there, your scanner gets suspended and work continues once data is available and the core doing the work is doing other stuff. And this is not expensive, not much. You’re still doing high performance scanning, it’s the fault of IO that you wait. But, if you can’t use std.Io because the overhead for fibers or threads is too much for your use case, this is not an option. Then the scanner has to return something like EGAIN and somehow has to know how to proceed once data is available.

Here is the real problem: If you’re writing some library, you don’t want to write two versions, one colorblind and one with suspension. That’s the whole point of std.Io’s async/conc, getting rid of colors. But if colorless can, as a matter of principle, never get top performance because of the framework overhead that is immanent to any std.Io implementation, then you need to provide an escape hatch.

Zig without async can of course provide this escape hatch. You basically do what you would do in C, you write a state machine that allows resuming the code waiting for IO. Getting this right however can be rather hard. And it’s hard for each and every library that has to do it. That’s why people love async keywords so much, even if it means you have to write two versions of the same logic. It’s much easier to write correct state machines.

Most people will just use std.Io abstractions and it will be perfectly fine. Most library authors will do the same and it helps a lot to write performant and maintainable code. So what am I complaining about?

Zig is the language I would always choose - as a matter of first principles and concepts - if I want the best performance and the most control. This is what Zig does. Together with comptime, I can build zero cost abstractions. The set of design decisions in Zig so far is unique in this regard. There is no GC, no runtime, no frameworks. Zig has enough sugar to make it surprisingly easy and safe.

That is, until std.Io. This is not Zig the language, it’s just a library and you don’t even have to use it. You can implement std.Io yourself and tweak it. But no normal developer would ever do that under any normal circumstances. It’s hard.

Zig, compared to other languages, has a couple of disadvantages. A lot of functionality that is included in other ecosystems is not in the std library and also not yet provided by the community. The fact that the language is still evolving puts a maintenance burden on devs. That’s fine with me, I can wait, I can contribute, and it’s fun.

But what that means taken together is, that the ecosystem will evolve along the mainstream. Zig will eventually commit to std.Io and there will not be a sustainable path to write software with truly minimal resource usage and maximal performance. You can write optimal software, but then you have to write it all yourself. There will be no libraries that support stackless async, because to few users need it and everybody uses the one colorless interface. Even libraries that emphasize performance over everything else, will likely only support std.Io. And Zig will have a runtime. You can replace it, theoretically, but you likely won’t.

The thing I would like to discuss, after making this point above is this:

To implement my scanner in a reasonable way, I would provide a comptime parameter that has is something like either getMoreData(): u8 or getMoreData(): Future(u8). This parameter would then bleed into the function signatures of the methods providing scan results and also leak into some seams in the library. std.Io has a kind of future, zio does. But, to my knowlege they are either not public or somehow not really idiomatic Zig abstractions. There is no accepted Zig pattern for using futures, that’s the whole point of std.Io and removing async. So I would likely provide my own Future type and do my own state machine implementation, and so will everybody else.

This is not sustainable and it will in my estimation end up with people just paying the stack tax, Zig will have a runtime and it will be somewhere between excellent and good enough for most requirements and not the right tool if what you really want is both control over everything AND convenience.

With this particular experience, I get the impression that providing std.Io was THE RIGHT thing to do, but removing async was a MISTAKE. I think a language like Zig needs both, a colored async and a colorblind std.Io, unless colorblind and stackless are not mutually exclusive attributes, which they seem to be.

Your thoughts? Am I missing something?

5 Likes

I need to re-read more carefully, but one thing, you can configure zio’s initially stack size and still not risk overflow. The only reason the default is currently at 256KiB is because Zig’s stdlib routinely allocates large buffers on stack, so you actually need it in many cases, especially dealing with file names, and especially on Windows. Stackless would transform that into routine 64-256KiB frame allocations on heap and I’m not sure if that’s better. I personally started to appreciate having the stack space available, it turns into much faster programs in my experience, because many heap allocations can be avoided.

Additionally, if you accept some hybrid node, you could have implemented the network layer using zio APIs, where you are able to wait on multiple things without separate tasks, and handle the logic using std.Io APIs, where task per operation is acceptable.

Or you could construct two zio runtimes, one initial stack size of 2KiB purely for networking, one for processing, where the initial stack size is larger. Note these are just initial sizes, the stack can grow, risk of overflow is small.

1 Like

For context: My issue is not practical, it’s probably more dogmatic than I like to admit.

I wrote a Nats client with python, the result was 0% CPU usage and ~40MB memory. The app is idling around most of the time. But it was fast enough and I have 96G of ram, so there really was no problem. It just bugged me so see this for an app that does almost nothing.

I ported the thing to Zig using the “official” zig client for Nats, and the result was 64MB memory and 170% cpu while the thing idled. It also used 6-7 threads. That is not Zigs fault of course, and the guys developing the nats zig client are not incompetent either. They just didn’t use the lib in production yet.

I forked the lib and tuned some parameters, got to 20MB memory and 0%CPU when idle, still 6-7 threads. You could count that as success, but I still hated the result. It’s better than using the native Go client, because that has this roughly 20MB overhead for its runtime and stuff, but Zig has no runtime.

Then I took a close look at the lib code and saw it uses the threaded std.io, apparently because std has no functional network support for evented. I found zio, and hacked up the lib to use it. Result: 7MB, 0CPU and 3 threads (numbers prone to memory corruption). That’s a huge improvement over what I had before and I really could have stopped there. But I just didn’t believe that this is actually what a mostly idle app listening on a domain socket should need to send and recieve text messages. I already reduced stack sizes for coros and chose params I thought are appropriate for the client.

So I took a deeper look at zio and the lib and looking at what they do, I am pretty certain that getting this app to 2MB should be easy. Zio spends a lot of effort doing things such a client never needs. There is no way to comptime that out, because it’s in the contract of an std.Io implementation.

This is all very focussed on memory usage, but that’s only because it’s easy to measure it. At runtime there are similar barriers resulting from zio’s concept (and that is true for any particular choice made by a GP std.Io implementation). The floor that a correct std.Io implementation can reach is determined by the contract. It needs a stack, it needs cancellation support, etc. Looking at what Zio does and likely has to do, this is more than a nats client has to do and maybe even more than what a nats server does for message routing and delivery (and that quite a lot of messy logic happening there).

My point is not that std.Io is not good, it’s excellent. So is zio, this is really good stuff. But so are Go routines, they are really well implemented and the concept provides a usable abstraction. I don’t like it, both emotionally and on grounds of principles. But I really don’t want Zig to become a compromise in terms of control and performance. Std.Io is a great choice for almost all use cases, but if it’s the only practical choice, it’s a limitation that I find painful. I really believe the stackless approach deserves an idiomatic representation in Zig world, because it solves a problem that std.io probably cannot solve.

Zio libraries (ev and more) are good, but not good enough to provide a substantial advantage over directly coding io-uring stuff, I don’t need portability for my use case.

1 Like

As far as I can tell, std.Io in fact wants to provide lower level API as well, it’s std.Io.Batch, but it’s unfinished as of right now. Unfortunately, it lacks per-Operation cancelation, which is a must for e.g. servers.

Other point is that while it’s fairly low level, it still has to do some things implicitly, for example: plumbing between std.Io structures and OS structures: iovec/msghdr/etc. It just has to allocate them somewhere.

1 Like

Zio’s ev module is a solution for missing implementations in std and I don’t mind waiting for std to evolve.

But the problem is not even so much the IO side of things than the effect it has on code that depends on IO.

Std.io provides the colorless abstraction in such a cool way that you simply write code as if it was sync and it works with async and concurrent. That’s amazing and you really don’t want to write two versions of the same code to support futures.

But once you commit to that, you also commit to the price of stackful routines. You can write your own eventloop, but you still have the to do something about how consumers of input handle fragmented inputs. The easiest way to do that is to use a reader and synchronous code, std.Io can run that async or concurrent without you having to worry about it.

If my scanner was a general purpose library, I would of course have used std.Io as input abstraction.

And that would kill a lot of the advantages my custom event loop would have. I had to serialize receiving a message before I can start parsing it, or I start parsing it, hit a premature-eof and then try again next time I receive data on that channel. But then I need an extra abstraction for “a message is incoming and incomplete, defer processing until more data arrives”.

At this point I had to choose between increasing the complexity of my network module (something that is already quite complex) or rewriting a scanner (assuming it would exist using std.Io). Not nice. And the same effect hits me with everything that consumes input or to a lesser extend produces output.

To me this seems to be a systemic problem that is at the same time very niche and fringe but really painful when it hits you.

I’m probably the no. 1 complainer std.Io here on Ziggit, but I want it to succeed, because I see compatibility across the ecosystem as the top priority. The interface still needs work to cover all the use cases, but I don’t think it makes sense to write networking libraries that use custom APIs at this point. For the first time, you can use one lib for HTTP server, one lib for Redis client and one lib for PostgreSQL client and and they will all cooperste under one event loop if you use an evented Io implementation. I think that’s awesome and it’s a huge value of Zig compared to languages like C/C++ where no such compatibility exists.

Regarding NATS, when I saw the official client, I was horrified. It makes me want to spend some time upgrading my own NATS client to std.Io.

6 Likes

Let’s do it together, I’m working on my own client (same conclusion).

Here is the fork I was working on, but I really don’t like hacking up stuff like this: GitHub - mutech/nats.zig: Zig Client for NATS · GitHub

Sadly the server side Go implementation of Nats is no better. Nats is a product, not a project. That bleeds through everything. Really sad, because their vision/concept is positively awesome.

I’ve been waiting for Zig 0.17 release. I submitted a bunch of PRs to improve things regarding timeouts and waiting for multiple operations, now they are merged, but as a rule, I don’t work with Zig master anymore, it sucked the energy out of me to wake up every day and having my project broken once again.

I’ve spent a lot of time thinking about the structure of my client, but that was Zig 0.13 and 0.14, so it was all based on threading. I then migrated it to zio, but it was weird, because the execution model is slightly different, so it felt wrong treating it as if tasks were threads. I think it makes sense for the general std.Io migration, but some things like callback delivery can probably cease to exist, or at least be less prominent.

I’ve put my use of NATS on hold, I lost trust in Synadia, especially after the Jespen report for JetStream. But the NATS client is what lead to me working on zio/dusty and a bunch of other networking stuff, so I should probably finish it :slight_smile:

2 Likes

I think I know what you mean about Synadia. I really, really want to use Nats, simply because there is nothing that could replace it conceptually. But technically, it’s a huge mess and the mess bleeds into everything, restrictions all over the place. My use case is not typical, that’s why all these restrictions hit me so hard, but it’s at the same time a logical conclusion of what Nats could and should be.

The other thing is politics and corporate nonsense. Synadia is not interested in the vision or concept that Nats was (if you believe early statements from the makers) and mostly driving things by their business demands. Valid attitude for a business, but no good for the product or the vision.

The idea to reimplement something nats-y is driven by other projects I’m working on that need it. I’m using nats for that (a fork fixing the worst problems) and it’s working out. I just keep hitting these issues and every time I have to decide whether to add another hack to nats or start working on a proper alternative. The former always wins because it delivers immediate results I’m waiting for. But it really gets old, hacking in this mess of a code base and making it worse.

This tension between what it is and what it should be is what’s driving this discussion here. I feel like std.Io vs. async is a thing that might be a real pain for Zig in the future and the only systemic issue I have with Zig. All the other problems are temporary in nature. Not working with main is sad but so what, Zig will eventually reach maturity whether it’s called 1.0 or 0.9997. It’s messy if fundamentals change but looking at the result so far, zig is just mind-blowing (and yes, sometimes also annoying).

But with std.Io evolving, there is little to no support for evolving an independent support for non-std.Io constructs. You can always dive into syscalls or asm if you feel like it, but that approach has consequences when modules have to integrate into code using std. You keep seeing “no longer supported, use std.Io” and then the corresponding method favors cross-platform support over specific platform completeness. No problem, syscalls are still there, but then you need to integrate into std.Io event loops and that can be easy or hard, depending on what you do. If there still was async, libraries, std or community, had an incentive to be compatible with idiomatic Zig, which then would include more than “must work with std.Io”.

I am a fan of std.Io, and I agree with your point that cohesion in interfaces is extremely important, especially looking at the many things not yet available in the zig ecosystem. I also agree with reducing the special integration with C removal, but I think the timing is bad. Convenience in that area should remain until Zig has its native ecosystem covering the important parts. But commiting to std.Io at least at this stage seems to undermine the cause of “Zig gives you full control, no hidden flow, best of breed performance”.

1 Like

I don’t know. Yes, std.Io explicitly hides control flow, which is against Zig’s mission statement. Especially if stackless coroutines get implemented, as currently planned, it will be a huge hidden control flow. If you are building specialized software like e.g. Nginx, owning the entire networking stack makes sense.

At the same time, a large part if the IT infrastructure runs on engines like Go, Tokio and Zig with zio via the std.Io interface beats all of them in performance, except for a few niche areas, but even in those, zio is just a few percent below the top. For example, monoio is faster at pure multi-threaded networking, but once you actually need to do some synchronization work across threads, it drops significantly. So you can have the universal cross-platform API and still faster than the current state of the art. Most applications are not doing just networking, once you have mixed workload, you get more benefits from an universal API like that.

2 Likes

I am not sure if my concept of an “optimal” implementation of a nats-like server is related to reality, I needed to finish it an measure to know, so that will take a while, but…

From what I understand from zio (much less than you do ;-), the coro dispatching is heavily based on work stealing. That is systematically not friendly to cache preservation. It is probably the best general purpose mechanism for scaling compute on a fixed number of threads though (which would explain excellent performance). Nats has, besides IO, a couple of specialized tasks that live and die from caches. One is subject matching. That’s a lot of pointer chasing that itself is not good for branch prediction and caching. If a task is likely to start with a cold cache, this is a heavy penalty. Another task is subscription management and the other side of subject matching. This also wants to keep its own cache. The way how these and other tasks affect the overall performance heavily depends on load. To respond to that you need to be flexible in how to assign threads to roles. Zio, again from what I understand, does not need or offer such flexibility, once you set up the instance, it keeps its initial config.

If you compare this with the real Nats implementation in Go, I would be surprised if a naive implementation on zio would not be better or similar out of the box, even though Go has a much more mature history and many optimization rounds. Their coros should be really good (and so is their reputation, overall). Linux is also pretty good at evicting unused memory, so the overhead of stacks is actually not as bad as I paint them. But the reason why it would be better would not be that zio’s architecture suits this problem domain well, it would be because you wouldn’t do all the crazy stuff you do in Go, with all the lock contention and tricks to reign in GC effects.

The problem I see is that using zio (or any std.Io implementation for that matter) sets a floor on how fast/resource conserving such an implementation can be, and this is in all likelihood not close to a handcrafted version. And here is the real issue, handcrafting the IO and threading model is not so hard as to be impossible. The real problem is that you need all kinds of support, TLS, websockets, JSON parsing, an http server and so on. Most of that is already there in Go. In Zig, it’s missing and when it’s there, it’s bound to std.Io. Whenever the handcrafted solution meets any third party code, you need to bridge it and since zio (or any std.Io implementation) would not be “the runtime” but one of them, it drags in all the limitations and costs going along with it. In Go you always pay that price once, but once payed you get the synergies. That’s why some Go programs perform better than some decent Rust equivalents, despite Rust being at least theoretically better.

Zig and Rust play in about the same league in terms of performance, Rust often has a slight edge. But I attribute that to maturity. Conceptually Zig should beat Rust easily.

At my level of Zig experience, I find that I spend too much time trying and failing to implement something, not because of the language. It’s easy and fast to get something to work correctly with a decent performance, which is high praise. But getting something to work in a specific way can be extremely tough. I can do that, because I code for fun. When I’m coding for money I would never try. If this was a professional project, I would likely use Go or C++ (I hate Rust) or even Kotlin, because I am able to reach the good enough level faster and more reliably there.

You said the zig guys are working on stackless tasks. Did this not already fail because of fn pointers and recursions leading to the async mechanism being discarded?

That’s one mode of operation, but not the only one. I specifically want zio to be flexible in how it’s used. You can use it fully single threaded (that’s how it started), you can have multiple threads and tasks pinned to the threads they started on (how I envisioned zio would work most of the time) and only recently I added work stealing, which I avoided for a log time, but for a general purpose engine, like when serving HTTP requests doing whatever, it’s the best option.

This is already possible by running multiple zio runtimes, so they form multiple groups of threads, and using std.Io as the unifying API for working with it. You can have network handling on one runtime (one io instance) and running background tasks on another runtime (another io instance), you can even have some other work on separate std.Io.Threaded instance.

I want to make this more flexible in the future, essentially tagging executor/thread groups and giving you specialized io instances for spawning tasks on the target group, while using just one runtime, because some of the resources can be shared.

And due to the design of zio, you can communicate between these using std.Io as if they were one unified layer. You can use queue, mutexes, etc.

It’s just in a vague proposal phase. The goal is to essentially eliminate recursion, or rather make it explicit and bounded. And then hide the async markers, so the compiler will automatically detect coro boundaries based in yield points. Once restricted function pointers are finished, the fn pointer boundary will be visible to the compiler, so it can determine where the yield points are. I personally don’t think it’s going to be ever fully done, but side product of the preparation work would be that stack size of each function can be determined statically, which means the end of stack overflow and oversized stacks. I’m looking forward to that in particular.

Is that because you have a big existing Nats install that you have to interface with, or you doing something greenfield and consider Nats to be uniquely suited ?

This is my position, so I’ll answer as well. I have a large project, where NATS with JetStream could be a great benefit. Not only can I use NATS Core to serve and load balance internal API requests, avoiding HTTP and load balancers / proxies, I can use NATS JetStream to store persistent data, so my workers will process stuff even if they are temporary down, while replicating the same copy to multiple data centers and doing the processing in each, and even using it as distributed KV store for storing coordination info. The idea is almost too good to be true, and yet it already exists in reality, it’s just hindered by code quality and unfortunately AI slop (I don’t use the word lightly) recently.

2 Likes

Where could I get the basic concepts of std.Io, like: async, concurrency, future, await, cancel, groups, locks, etc ? I can’t see the big picture and I just can’t find my way with zig documentation.
Not trying to blame zig docs for my lack of talent/intelligence, I understand the reasons, I am just stating a fact.

there are some threads on this forum with explainers for bits of std.Io. Loris has two good blog posts on the subject:

Andrew also has a text version of the stuff he talked about during the stream that Loris links to in the second link above.

All of these are pretty high level; for nitty gritty stuff once you get your feet well and truly wet, i think chatting here on the forum is probably the best source currently.

1 Like

This is a good place to start:

I’ll try to write a series of blog posts that go into details of everything, as they are some gotchas when using the interface.

4 Likes

Thanks @alanza & @lalinsky for the pointers.

That would be so very kind of you. Thanks.
This is a Future. I will be asyncWaiting for it.

1 Like