sans-io: meh

RSS podcast badge Spotify podcast badge Apple podcast badge YouTube podcast badge

Much ado about no i/o

James and Amos have a spirited discussion about sans-io, a technique for writing protocol libraries, and the tradeoffs of sans-io when compared to leaning into async/await.

View the presentation

Video

Audio

Download as M4A

Show Notes

Episode Sponsor: Depot

Transcript

Amanda Majorowicz: So I don't have anything to follow. Cool. All right. Bye.

James Munns: Yeah, no slides for this one. This one's going to be a time travel episodes, because I'm going to write the slides afterwards.

sans-io?

**James Munns:**But I had a thing that I used to recommend to people. And now after doing a bunch of work, I found my opinion changing. So I wanted to see Amos what your opinions were or if I'm missing something as my opinions shift.

Amos Wenger: So James, did you already retire the buddy system?

James Munns: No, no!

Amos Wenger: Phew, scared me for a second.

James Munns: No. So have you heard the phrase sans-io before?

Amos Wenger: I have.

James Munns: So this is one of those things that especially in Rust, people tend to recommend of like, hey, when I'm designing protocols or things like that, one of the recommended way is sans-io. So you have all of your code that does decoding and state machine logic and stuff like that in normal, usually blocking functions. And you handle your actual IO portions outside of that. And the purported benefits are it's generally portable for async and not async because you let the IO be IO wherever it's doing IO. You get benefits of testability because when you want to test it, you can just shove in Vecs or slices of bytes that are pre manufactured test artifacts and make sure it goes only to the next step and things like that. It is sort of compelling, but what I've realized is the whole point of async is to make the compiler do state machines for you. And to not have weird like loop match statements where you're like doing manual state machine changes. And someone even in the in the Rust subreddit today posted something where they're like, can I do this? And they posted some loop match. And I'm like, if you just use a regular async function, you don't need the match. Like it's just a for loop with a continue statement in the middle. If you need to skip back to the first one. And I've realized, particularly with postcard-rpc and poststation, that if you do have a good abstraction of the wire, you can avoid losing. So the reason the negative to doing this is it becomes difficult because you are really doing those those match of state machines again. You go, OK, decode the packet. What does this packet make me do? It makes me move this state. And a lot of people reach for enums for state machines where you go like match self.state. And that works fine, except for everyone wants to put data inside of those because you say when I'm in this mode, I have this data and it helps you make sure that you don't have data that gets stale when you're in the wrong state and things like that. And for a lot of these where you might want to borrow the contents of those states, then going to like swap the state out while you're in the middle of a match statement for doing this: one, you get really nested code because you're in a match statement. And even if you delegate to functions, you have to figure out, oh, well, I'm already mutably borrowing self.state, so I can't just pass state to this function that also mutates self because I'm already borrowing self. And if I take the thing out, then I need a placeholder value, which means what do I do in my state machine? If I'm in the placeholder value, I have to just panic. So you have a panicking branch in there. So it's one of those things where it's it sounds really good on paper. You can really enumerate the benefits. But then it's kind of every time I've tried to really do it like that. It works really well. And then the paper cuts just keep lining up.

Amos Wenger: That is true.

James Munns: Versus really leveraging something like async where a function is a hierarchical state machine. Like if you have one function, that is a state block. And if you call another function, that's another state block. When I say hierarchical state machines, do you know what I mean by that?

Amos Wenger: I do. I do. You didn't really leave me time to interject, but you know, it's very fun.

James Munns: I'm excited about this topic.

Amos Wenger: You know, it's very, very fun, James, is that I have my shit together in 2025. That's also an explicit episode. I'm sorry, kids.

James Munns: Hell yeah.

Amos Wenger: I have my shit together, which means I have a video backlog. Do you want to know the name of video of already researched, written, shot, edited and captioned?

James Munns: Why sans-io isn't great.

Amos Wenger: The case for sans-io.

Surprise: James has walked into a ~trap~ spirited debate

James Munns: Oh, point counterpoint.

Amos Wenger: I maintain a crate named rc-zip, which is a sans-io implementation of the zip file format. And that's the topic of the video. And I explain why it's actually good. So I can make the same points in podcast form, because I think, realistically speaking, the overlap will be relatively small. And I'm going to actually get to bump it off of you instead of just talking to my shotgun mic. But it's very funny because I knew this day would come eventually.

James Munns: And actually, so you say zip file. So actually, I think I am much more inclined to agree with things like file format parsers or things that are not distributed systems, because I think a lot of protocol design when it comes to distributed systems is your action and reaction to things. And there's not surprisingly, there's not as much difference as you would think between like a file format. A file format is just kind of like a fixed distributed system. You know, it's something that you're talking to and decoding and things like that.

Amos Wenger: But I well, you will let me know if you still think that after you you of course watch my video about the zip file format, because it well, it's static. Yes. But going from left to right. No, very much not. So there's it's it's it's more complicated than TLS even. But OK, I'll throw one first argument in the ring. No pun intended. It's that the async abstractions we have now AsyncRead in the tokio crates and in the async-std crates are not actually good and also just not compatible with modern ways of doing I.O., which for me on Linux and BSDs and now on Windows and whatnot would be your io-uring where you would give ownership of the buffer to the kernel and then wait for the opportunity to be completed or canceled before you can actually free that buffer or do anything else with it. And you simply cannot have tokio's async interface and an io-uring system which is the most efficient way of doing I.O. nowadays, you simply cannot. So of course, immediately after that, the argument goes, you don't need io-uring, because your application is too slow. That's not the bottleneck, essentially. So that's a good point. Facebook needs io-uring, You probably don't.

James Munns: I don't think that's where I would go with that. I would say I probably agree with you that the abstraction isn't great because so what tokio, especially when you're talking about like TCP streams and things like that, you get a stream of date of data as bytes and you have to do explicit steps.

James Munns: So this is one of those things that I've boiled down in postcard—so postcard-rpc's wire traits, so it has wire traits for both the client and the server. They don't give you a stream of bytes. They give you a frame. And I do it. It depends on whether you're on no_std or not. But on desktop, it usually is you give a Vec as a frame and you receive back a Vec as a frame, not decoded. But you say this is one block where I expect framing to be an explicit step of this. Now, you could probably do something clever where you have more of like a buffer pool if you didn't really want to recycle Vecs. But for my case, I'm not Facebook. So for me, leaning on malloc or tcmalloc or jemalloc is probably fine and the Vecs are going to get reused anyway. But if you really wanted control over it, you could have more of like a recyclable buffer pool or something like that. But my actual interface is at the frame level because one, I need to be flexible because if you're going over a serial port, you might need to do some additional encoding or putting checksums on it because you may not trust that that's going. Whereas over if you decided to use UDP as your transport, you might just say the frame's a frame, or you might want to do it with encryption. So I have a version that goes over TLS 1.3, using rustls And so you can choose where you delineate the frame. And so frames come in. Then there's an explicit decoding step. So you turn it from a frame to a struct or it fails and you reject it and you handle it like that. And that's one of those things where I have implementations of my Wire trait that just use tokio channels. So if I'm doing a unit test or I'm doing simulation or something like that, I don't actually wire it up to a TCP socket. I just have an MPSC channel where frames go in frames come out. And it's very usable for testing. So I think probably my point is is that sans-io is sort of throwing the baby out with the bathwater in that. Yes, like a raw async read async write isn't a great interface for a protocol and two, throwing out all of async just so you can get your state machines to be explicit state machines is also sort of losing out on on some real benefit of being able to do like real control flow, like match statements or early returns or labeled loop breaks and things like that, where it would be egregious to try and keep that. You'd have to draw the state diagram and then all of your match statements go, OK, this is state transition 1.4A where I go from here to here. Versus in code, you just go, OK, you know, I just break from the loop and it's 'inner... break 'inner. You know what I mean?

Amos Wenger: This is I love this episode because I get to be the bad person, the devil's advocate. So my first argument was like the tokio AsyncRead interface is bad and it should feel bad and you're like, well, I did pass around ownership of the buffer, so I don't have that. And you I agree. So yeah, in that sense, I would agree. Just make everything async and pass around ownership of the buffer. Don't borrow anything mutably across async wait, await points, essentially. Next on the my agenda as the devil is to tell you that, unfortunately, I don't know if you've read the documents of the bright and shiny async rust future that they wrote a couple years ago. There's an async work group. I forget who's on it.

James Munns: I absolutely have, but not in two years or something.

Amos Wenger: I just remember Barbara. What does Barbara do in this scenario? There's always it's a user.

James Munns: It's one of the like voice of the customer story.

Amos Wenger: So there's a character they always have a name. Yeah, I forgot all of it. But it's good. That's my point. It's been a few years and I think not a lot of those have come true. There's been a lot of compiler level features shipped, which is amazing. But I'm waiting on async stack traces. Right. With synchronous code, you can sort of break point somewhere. You can just look at a ticket dump of all the stack traces. You know where it's blocked. That's hard to argue with. As long as you cannot do that with async rust code, you're going to feel the pain whenever you're inspecting a running system. And Go has that figured out again. I'm infamous Go hater almost. But I recognize that every rant about Go, I'm like they have great runtime introspection things because Goroutines are the core of everything. They have their own runtime and everything. That's the point. So debugging is definitely one of them. And just introspection in general, not even talking about backtraces, but one of my motivations when I was working, I'm not currently working on the HTTP stack because nobody's currently funding me for it. And I have currently other things to do. But when I was, one of the big motivations was that hyper is great and fine. And I love Sean, but it's always in code. So you don't know. You don't even know where resources are spent. You don't know where the memory goes. You don't know where it's pending. You don't know anything. It's all very opaque. You can't really observe the system. If you if you compare it with some C or C++, God forbid, HTTP servers, they have knobs for everything. They export all sorts of metrics. It matters because it runs on every edge node of your freaking network. So you need to know where the memory is spent, what knobs you can turn to optimize it. And with Async Rust, I agree with you. You have you can just write the code the way you would with synchronous code. You have to add a whole other layer on top of the existing Async Rust thing because it's designed to be zero cost. It's also kind of zero benefits. There's no extra stuff. There's no system that lets you know what's going on. It's just code that's called. It registers its interest in something and then it returns all the way to the runtime. And then if a task is blocked, you don't really know where it's blocked. And that's where you tell me about tokio-console. Is it not James?

James Munns: It's a good tool. And to be honest, in postcard and postation, I do use I'm not sure so much in postcard, but definitely in poststation, I use tokio pretty heavily. And one of the cool things that I do is I actually wrote a tokio sink that serializes the data and puts it in a database in a rolling ring buffer. So I can actually even get tokio instrument, not stack traces, but I can. I don't have good tooling for it. I've only had to dip into it once right now. I just collect it because if you collect all the data, you can go back and make a ring buffer. And it's it doesn't get sent to me. I don't do the like Grafana or Loki thing, but it's just stored in the local database like the same as incoming messages and stuff. And I have used that in a couple of times to figure out like, OK, where was this task and where was it spinning and things like that?

Admission: there is still forethought required for success

James Munns: And I think you're right. Like you do have to proactively set that up. I don't know. It's interesting. And maybe this just because I haven't done this in anger. I haven't had to debug as many cursed things on that. I think part of it is that I'm coming from sort of the embedded async world first. So I'm sort of used to everything needs to be as simple and as dumb as it can be just because you don't have much space. And I try and do a very similar thing. Like a lot of the existing frameworks and things like that. I have strong opinions of of just like I'm the kind of person that really needs to understand back to front. And so maybe this is one of those things where it doesn't. My opinions don't generalize. So maybe this is just a learning about myself. But I think one of the things that I've done with postcard RPC is there's only like there's you, the client. There's the I I do have IO workers because you can share one connection with multiple clients and things like that. But the client really just goes, take it out of this side, encode it, put it in the wire. And on the other side, it pulls it out of the wire, goes, does anyone want this? No. Throw it away. Or yes, you want this. Put it put it to you. So like maybe that's just one of those things that it works for postcard RPC because it is a stupidly simple protocol compared to HTTP or a TLS negotiation or things like that. But yeah, I don't know what to think about that because you're right. Like you do need those tools and you need to have the foresight of knowing how to avoid stepping in the cracks before you step in the cracks, because otherwise, if you design around cracks, then you'll find yourself falling in cracks all day.

Amos Wenger: Let me ask you this. Have you ever had to box a future because you ran out of stack? No. So yes, you don't know the pain of async Rust, clearly, because that's a thing. That's the thing that happens in you write async Rust code. First of all, recursion doesn't work. That sucks. Again, explicit episodes. I can say it. It sucks. You try to call a function recursively and it goes, that would be an infinite state machine and it's not wrong. Yeah. But you can do that with synchronous code somehow. It just keeps recursing until there's no more space.

James Munns: So I think that's what I get. I think this is one of those embedded biases. Like I have you don't recurse.

Amos Wenger: You never do. You just iterate.

James Munns: No, I do recurse. I do love recursion.

Amos Wenger: Don't get me wrong.

James Munns: I'm actually a big fan of recursion. I don't tend to use it in async stuff because it's more IO based. I tend to use it for more parsing stuff. But that's usually synchronous code. I've never needed to asynchronously recur. Or I think like once. I think I found myself needing that about once.

Amos Wenger: But that's the whole point. That's what you just sold everyone on about is that you can just write it as if it's regular code and it magically makes state machines. But then that that abstraction is leaky and it breaks down when you try to recurse. It goes, uh-uh, that's an infinitely sized type. You can't do that. And it breaks down when you just write completely regular code. And it just so happens that it made a freaking future that is four megabyte large and you didn't have four megabyte left on the stack. And now everything blows up. And because it's a stack overflow, it doesn't. It's not really something you can. I don't even know if you can catch that. It just brings the whole application down. And then good luck looking at it, because when this started happening, when tokio, like the whole async ecosystem in tokio became viable enough that people started writing bigger and bigger web applications with it. The tooling to find out what's the size of my future was nil. You just had to go around and had a bunch of. I remember I worked at a company that had those problems. I had a bunch of print statements that printed the size of different values that were returned from functions, so futures. And then you just manually decided which ones you would box so that the stack wouldn't blow up. And those are two huge actual problems that people run into with async code. And I'm not even talking about the build times and everything.

James Munns: On embedded, we run into these problems a lot, because when you pass things by ownership up and down the stack, they'll be double, triple, quadruple the size. So, I mean, if you are setting up, if you're configuring your serial port and you want to give it a 20 kilobyte buffer to use, if you're creating that buffer and then passing it by ownership, it might show up two or three times on the stack because we don't have guaranteed RVO and things like that. And so...

Amos Wenger: Oh, but RVO is return value optimization. And that's the thing where instead of allocating space on the stack so that a function can return a value...

James Munns: You allocate space in the callee or in the caller, not the callee, because normally what happens, yeah, you allocate the destination and then the function you call also allocates that on the stack and then it returns it by copying it from the called function into the calling function. So you have two copies of it.

Amos Wenger: I have a simpler way to explain the problem, I think. Imagine you're making a very big vec from a literal. How does it work? I think if you call the vec macro, it first puts everything on the stack and then copies it to the heap, and that's an issue. Yeah. Yeah, that's basically the idea. You're trying to put things on the heap because you don't have enough stack and you know that you don't have enough stack, but then actually it's first put on the stack and then copied to the heap by the compiler because it's not smart enough to figure out that it should go directly on the heap. And that's one of the forms of return value optimization.

Counterpoint: async code is not the same as blocking code

James Munns: I guess really what I'm saying is I've already been traumatized into always being cognizant of where my data is and where it's moving.

Amos Wenger: Yeah, but it's just like me saying Rust is easy. It is now.

James Munns: That's fair. And I mean, that's a fair... Like, maybe this is also sort of me figuring out before I read a blog post by saying it's easy, trying to figure out what my biases are. Yeah. Like, I've already gone through the trauma and learning of like, when you're thinking about how data flows, part of that is thinking about where that data lives. And if you are doing something where the data is moving around a lot, you go, "I should probably just put that on the heap because it's going to be cheaper to pass around like a string or a vec than it is a stack allocated buffer." So I've never had a problem. And don't get me wrong, I haven't written nearly as many production desktop services as a lot of folks who are listeners have. I've written a lot more embedded systems where you do run into similar problems, but you have the similar situations. You will preallocate buffers and pass around a reference to that preallocated buffer rather than passing the buffer around for both speed and... Sure. If you need a buffer that's half of your total memory, you don't have room for two. You know what I mean?

Amos Wenger: Yeah, yeah, yeah. But what they think, it's the code ends up taking space and you have no idea. You write code, it compiles on it to a state machine. The size of the state machine is completely independent of your will. Like, it's so easy to change the control flow slightly. And then instead of having, let's say, two variants of an enum occupy the same memory space because there's a discriminant...

James Munns: There's very little optimization in future. Yeah, that is a big problem.

Amos Wenger: Small changes can result in big changes in the size of the future. And it's really hard to predict. And it's really easy to get in those situations where the stack is blowing up and you have to go on a hunting trip to figure out where it comes from. And that happens not just to beginners. It's like, "Oh, don't do that. Just be careful about this." Which, by the way, is kind of antithetic to Rust. Rust is all about... The compiler is not going to let you get in trouble, so you don't have to worry. It's not like you have to follow this set of rules or else your dog's going to get eaten. That's C, C++, Go, whatever. Rust is about guiding you and everything. Okay, this brings me to software is nice in theory, but in practice, it's being funded by capitalism. This is the capitalism moment of the podcast. Everybody take a shot. Here's what happened. Async functionality is built on generators. Generators let you write functions that can yield values. You are familiar with them if you write Ruby code, for example. You write a function and then you can return, but you can also just yield a value. So you can do iterators with that. That's one way of doing iterators. You write a function that can yield 1, 2, 3, 4, 5, and you're iterating over all the numbers. Rust doesn't do that. Rust has you have a type that implements a tray that you can call a function on and then it returns with a value. And you keep calling a function to return more values. It doesn't have this yield thing. But there are generators in Rust. They're just perma-unstable. They haven't been stabilized at all. Whereas async, which is built on top of generators, has been stabilized. Why is that? Because there are companies with a lot of money who really want to be able to write async code and not enough company with not enough money who want to be able to use generators. I want to be able to use generators, but I can't bankroll half of the Rust foundation by myself. I wish I could. So really, you're not asking for async, James. You're asking for generators. And that's what I want too, because you don't really, the async part is kind of not necessarily relevant because another argument I was going to throw at you and I'm going to let you respond. I'm going to let you finish. But sometimes you don't want to pull in async. Sometimes all you want is a nice small CLI tool. And I've defended Rust async forever. So I'm really enjoying playing the other part today. But sometimes you just want something that grabs some webpage and I don't know, parses the CSS or whatever. You don't want to bring in tokio and everything. You just want some bytes from the network. It's fine for you to do blocking syscalls. You're not doing a high performance server. People have a point that is true. And so if you write everything as async code, you can technically have actually synchronous code that implements async interface. You can pretend that it's async, but actually it blocks and it's going to mostly work. But as soon as you call something from Tokyo that is actually asynchronous, it's going to break down. And there's no, the compiler will not warn you about that. It will just fail at runtime. And it's still going to bring it a bunch of dependencies. There's still no good answer to writing code that will work both in an asynchronous context and a synchronous context. And the sense IO approach is kind of the only way to do it right now.

James Munns: So you make a lot of really good points. The, the, the, the, the.

Amos Wenger: We can take it one at a time. I know I said a lot. I'm sorry.

James Munns: No, no, no, it's okay. It's where, where do you start peeling the onion?

Amos Wenger: Am I the onion in the metaphor?

James Munns: The onion is ours. It's shared.

Amos Wenger: It's emotional right now. We have an onion together.

Admission: there is a step from rust to async rust

James Munns: Definitely like I see, I understand the desire. It's nice to write blocking code. Yeah. Especially like in terms of learning curve, there are deficiencies in a lot of things and it does require some extra learning. And it's, it's sort of like the transition from like not Rust to Rust. You kind of get that transition again with Rust to Async Rust. And I think it's oversold a lot. Like I'm kind of stepping carefully here because there's a lot of, you know, people go on the internet and they copy and paste old arguments, even when those old arguments aren't necessarily true anymore. Async has made a lot of improvements. And I think the learning materials and the tooling and the compiler have come a long way and there's still, you know, improvement to be had where we're at the standard of where we'd probably like to be. But also there's a lot of people copying and pasting arguments from five years ago. And it's very tiring to people who are working on those improvements or libraries and things like that. Where people are just, you're like, you don't know. You're just memeing right now. And that's frustrating. So part of it, that's part of why I'm dancing around this. The other one is it is just a different beast. And I will agree with it that it is a different beast, but it allows for some very cool things. Like Boats has a thousand good ways of describing this, but just being able to throw things like, hey, I want to slap a timeout on this and call .timeout and seed control, or I want to be able to concurrently run these and race them against each other and those kind of things where it's the intratask concurrency. Doing it for high performance is a reason. But it's not the only reason why you would use async.

Amos Wenger: And there will be in the show notes a link to Boats' post about intratask concurrency and why futures matter. Because Amanda is the best.

James Munns: And there are things where Tokyo doesn't have to be this huge dependency. You can turn off a lot of features, and you can do those. And yes, it is a little setup to do. But I have my favored setup of Tokyo in my command buffer where I hit Control-R, cargo add tokio, and it adds the features that I want to the project that I want, and it's pretty quick to set up.

Amos Wenger: But I mean, OK, so first off, I just want to say I don't even have to argue with you, even though that's kind of what we're doing, for fun. Just remember that we're friendly towards each other. We're not actually fighting. OK, don't do this at home.

James Munns: But we're not actually fighting. We both probably mostly agree on all of these points. We probably do. But it's interesting to dance around.

Amos Wenger: But I think I don't have to argue with you, because I can just wait two years, and one of two things will happen. One, you will feel the pain of your growing application with async code and figure out that it's not just beginners. It's not just four years ago. It is like current Rust that has very real problems. And then you will see that I'm actually right. Or the compiler will get so much better that it will make me wrong. And in either case, I will be happy, because I'll be either right or the world will be better. So I win either way.

James Munns: I think the thing that is frustrating me the most right now is that most of my responses to this are that it requires attention to how you design your systems. One of the things that I said at the last talk I gave was, sometimes the problem with a good tool is that you use it too far beyond what it should be used, because it's so pleasant to use. And I think async can do that, where you do still have to think--

Opinion: you shouldn't treat async the same as blocking code

James Munns: I don't totally buy the thing of, async is just like blocking code. It's straight line. You don't have to think about-- I do think, from a systems perspective, you have to think about what your code is doing, how you architect it, where the data is stored, where the data is going, where you draw the lines of things like that. And I think that's a systems design question. And I don't think you are well-served by pretending that blocking code and async code are the same, because I don't think they are. They're very similar, and it's nice that you can do that. I think that's sort of a missed feature of that. But it is one of those things. I've been doing async on desktop for maybe two or three years for relatively different sized applications, and async on embedded for maybe four years or something like that. And I really did have a learning curve of that. But I think that's the same kind of thing in desktop rust, is like you're saying with recursion, you can blow your stack in recursion very easily, and you'll have to learn that. Or you have to learn that you need to lock stdio before you print, or otherwise all of your benchmarks are very silly. There are definitely systemic things that you need to learn. And I do think that they're higher for async. But I think it's-- I don't know. I don't like that my answers get good, because that's not a good answer, and it's not really a scalable answer.

Amos Wenger: And I disagree. I disagree with you, James. And that's beautiful, because that makes a good podcast. Because I don't think it's very honest. Because I think if we're adding async functions and we're telling everyone they're exactly like synchronous functions, except sometimes you have to know how they run. But the goal, with each addition of features, was to be OK. So that thing that used to not be possible with async code is now possible with async code. There's a clear trend to making you be able to do everything you can do with synchronous code, also do with asynchronous code. So clearly the aim is for you to not have to worry about it. The fact that you can't do recursion is a problem. There's a bunch of new things that were added recently that lets you do lending iterators. What did we have recently? We had GATs, we had async closures. Those things made sense. It wasn't a big invention. Everybody wanted them. They were just hard to implement in the compiler, because it's not a trivial transform at all. And there's this borrow checking involved. It's really complicated when you get into the details of how it's implemented. But everything made sense. From the moment you were like, OK, we can make this async. We can make an await point actually return and then come back to the exact same state in the function. The whole universe of possibilities of what we could do with async Rust was already drawn. And the fact that some of it is currently problematic, you can't recurse, you can't-- sometimes the features get too big and the stack blows up, you have to debug that. It's a compiler bug. It's not-- people are being unreasonable and asking too much of async Rust. It's like, this should be fixed. But because we live in the real world with limited resources and limited funding, it's good enough that the biggest users of async Rust are kind of OK with where it's at. And things are making slow progress. But there's no big rush because you can already do so much. Like you say, if you just like-- sometimes you run into corner cases and you just kind of work around them. But as far as I'm concerned, it's a compiler bug. It's just like the last x% are the hardest because some of them involve fundamental changes to the compiler.

James Munns: Yeah. I don't entirely disagree. And I do think a lot of those should be better. I think-- I don't know. Some of them are given more-- or they seem like more of a problem than they are-- well, I don't know.

Amos Wenger: I think you just got lucky and you just didn't run into too many of those yet. I think we should check in with you in six months.

James Munns: I mean, I'm happy to. But I mean, Postation has been exceedingly pleasant to develop. And even though my database is a blocking database-- so I'm using fjall as my database and fjall is blocking-- but I mean, there are ways to develop that where it becomes not particularly challenging. Or it is blocking, but it's a transactional database. And my queries aren't large. And I'm not recursing over those. Most of my blocking queries are insertions only and things like that. And for the areas--

Amos Wenger: I think you're in the honeymoon period. And you're trying to-- I just have to disagree with people who run into problems just don't know enough or they had older Rust versions. I just disagree with that.

James Munns: I guess the point that I'm really trying to make is that sans-io forces you to disregard all of the benefits of async for the cost of compatibility. And it's one of those challenging things. Because when it comes to making sure that your state machines are clear and correct and things like that, it's way easier to lose things in the diff of a very large set of match statements rather than linear code. But generators. It's not the

Amos Wenger: answer you expected. But I want you to take a minute.

James Munns: No, no, I'm very familiar with generators. Think about it. And I do think there are some cases where they absolutely would be the thing that you want, like being able to have possible--

Amos Wenger: Because your main argument is it sucks to write state machines in Rust, which is absolutely true. I've written a bunch. I know that. You just do macros.

James Munns: It is, but I prefer protocols. So I'm very heavy in protocol stuff right now. You do really want to say, I want to send something and await the result. And I want to timeout, or I want to receive the result. And if-- or I want to basically mux on a timeout occurs, a wrong message comes in, a correct message comes in, or a giant cancellation flag, or some external signal has been received and things like that. And you really do want to be able to say, OK, I'm going to receive.await, and then I'm going to do a select statement on three items. And I think I'd agree with you in that you could cover a fairly large subset, or sans-io could be made much better with generators. I would definitely agree with you on that, in that you could boil your state down to that. But I guess what I'm saying is you can get a lot of the benefit by having good abstractions for things like that, and that sometimes existing abstractions are not great, and they force you into corners that end up making things more challenging. Whether Async can improve or not, I think, I'll agree with you. I absolutely can. But also that I think there is some level of people are more scared than the actual level there. But again, maybe I just haven't seen the right horrors yet.

Opinion: I don't think I would enjoy writing protocols without async

James Munns: The thing that I'm really getting at is that I've seen a glimpse of something that can be really pleasant in Async, and writing protocol design, particularly designing my own protocol from scratch with Async Rust in mind to begin with, and designing data structures around that, and designing interactions with the understanding of, hey, I know I'm going to need a timeout or error or success and being able to really natively encode that as Async logic has been tremendously pleasant. And just thinking about how I would do all that, if I had to turn everything inside out to do sans-io, is one of those really challenging things to me, because I don't think it would be nearly as pleasant. And maybe this is the benefit of designing something simple from scratch with Async in mind to begin with, rather than something that was encoded 20 years ago from an RFC that has so many edge cases and performance impacts and things like that. I think there's some hesitation to be made instead of always going with Sans I.O., because I think there is some really interesting and clever and pleasant, and really things that make your code easier to read and more robust if you address them as Async challenges to begin with, instead of always sort of landing on sans-io, even though it is a very good tool in very specific cases.

Amos Wenger: Now that I'm done playing Devil's Advocate, I will agree with you that, one, it's not that bad to write Async Rust code. I've been writing that for three years now. So another very interesting read you can have that is going to be in the show notes is an article from the maintainer of Cargo Next Test, Rain, who explains why Cargo Next Test, how Cargo Next Test uses Async and how it would be so hard to do that using synchronous syscalls, it would not be possible. So nextest uses tokio very, very efficiently, not efficiently, it's a great effect. Effectively. Finally, I wanna say that my, if you give up the idea of having nice, light, synchronous binaries, and you're all in Async land, you've just kind of accepted you're gonna have some sort of runtime, then I think I would be fine if we all standardized on an io-uring-compatible abstraction, because if you have that, you can implement, you can implement that interface efficiently on top of something like tokio, but you cannot do the reverse. So one of them is strictly more flexible than the other. So I'm just really sad that tokio, the current AsyncRead trait in the Rust ecosystem are the de facto standard because you cannot do uring with them. So if we could standardize on that, then we'd notice, or if we have a frame based interface, like you're doing with postcard-rpc, then that's fine too, because it's also flexible enough.

James Munns: I think people should be less afraid to define their own interfaces. I think there's something very powerful of going, look, portability is fine, and if people want to polyfill portability onto this, but there's something to be said about defining exactly the interface you actually want and to use that, because I think that's one of the things that I've done in my projects, and I found there's a time for portability and there's a time for being specific. And I think getting that right, I think goes a really long way. Because like I said, if I can say frames are frames, it doesn't matter whether it's IOU ring or blocking IO, well, you probably couldn't use blocking IO, but it doesn't really matter how it gets there. I say, that's your problem. I'm saying what I need, and you're telling me how you want to do it, which gives a lot of customization back to the integrator, which I think it helps a lot of these challenges.

Amos Wenger: My mind immediately goes to tokio-rustls which is a very leaky abstraction, because TLS is not just the TCP sockets. There's a side, what is it called? There's events, there's alerts that can happen. You can know when the keys are being rotated. You can know where there's some sort of exception or whatever. And that's not part of the AsyncRead interface. So on Linux, you have a file descriptor, and then you have some way to get to that. I think it's called ancillary data. I don't know exactly, but essentially it's not just bytes go in, bytes come out. There's control data, there's things on the side, and that's completely lost in the tokio-rustls abstraction and other rustls abstractions. But it's not in the rustls crate, which does implement the protocol in some form of sans-io way. So yeah, I would agree with you there.

Follow up in Season 3?

James Munns: Well, then here's to the honeymoon period.

(Laughing)

Amos Wenger: And we'll check back on you in six to 12 months.

James Munns: Yeah, we'll do the follow-up. I think that'll be good. Season three.

Amos Wenger: We will.

Amanda Majorowicz: Beautiful, that was a cool ending.

(Laughing)

Amanda Majorowicz: Also long fucking honeymoon, man, cheers.

Episode Sponsor

This episode is sponsored by Depot: the build acceleration platform that's on a mission to make all builds near instant. If you're tired of watching your builds in GitHub Actions crawl like the modern-day equivalent of paint drying, give Depot's GitHub Actions runners a try. They’re up to 10x faster, with unlimited concurrency, faster caching, support for Linux, macOS, and Windows, and they plug right into other Depot optimizations like accelerated container image builds and remote caching for Bazel, Turborepo, Gradle, and more.

Depot was built by developers who were tired of wasting time waiting on builds instead of shipping. It's made for teams that want to move faster and stay focused on what actually matters.

That’s why companies like PostHog use Depot to cut build times from over 3 hours to just 3 minutes, saving tens of thousands of build hours every week.

Start your free 7-day trial at depot.dev and let them know we sent you.