[HN Gopher] Sans-IO: The secret to effective Rust for network se...
___________________________________________________________________
Sans-IO: The secret to effective Rust for network services
Author : wh33zle
Score : 198 points
Date : 2024-07-04 03:05 UTC (19 hours ago)
(HTM) web link (www.firezone.dev)
(TXT) w3m dump (www.firezone.dev)
| ethegwo wrote:
| Good job! Exposing state could make any async function 'pure'.
| All the user needs to do is push the state machine to the next
| state. I have tried to bind OpenSSL to async Rust before, its
| async API follows a similar design.
| wh33zle wrote:
| I did some quick research and found that there is an "async
| job" API in OpenSSL. That one appears to do IO though, it even
| says that creating a job is a very expensive operation and thus
| jobs should be reused.
|
| Is the similarity you are seeing that the work itself that gets
| scheduled via a job is agnostic over how it is executed?
|
| From this example [0] it looks more like that async API is very
| similar to Rust's futures:
|
| - Within a job you can access a "wait context"
|
| - You can suspend on some condition
|
| - You can trigger a wake-up to continue executing
|
| [0]:
| https://www.openssl.org/docs/man1.1.1/man3/ASYNC_is_capable....
| ethegwo wrote:
| Yes, you're right. It's not entirely similar, it's not IO-
| less. But in async Rust (or any other stackless coroutine
| runtimes), IO should be bound to the scheduler. This allows
| IO events callback scheduler and wake the task it binds to.
| Exposing and manually pushing state is a good way to decouple
| IO from the scheduler.
| wh33zle wrote:
| Yes! Decoupling is the goal of this! Using non-blocking IO
| is still useful in this case because it means we can wait
| on two conditions at once (i.e. socket IO and time), see
| [0].
|
| It is possible to do the same blocking IO but it feels a
| little less natural: You have to set the read-timeout on
| the socket to the time when you need to wake-up the state
| machine.
|
| [0]: https://github.com/firezone/sans-io-blog-
| example/blob/99df77...
| Animats wrote:
| "... be it from your Android phone, MacOS computer or Linux
| server. "
|
| Why would you want this in a client? It's not like a client needs
| to manage tens of thousands of connections. Unless it's doing a
| DDOS job.
| wh33zle wrote:
| In Firezone's case, things are built on top of UDP so
| technically there aren't any (kernel-managed) connections and
| only a single file descriptor is allocated for the UDP socket.
|
| The main benefit is being able to use `&mut` everywhere: At the
| time when we read an IP packet from the TUN device, we don't
| yet know, which gateway (exit node), it needs to go to. We
| first have to look at the user's policies and then encrypt and
| send it via a WireGuard tunnel.
|
| Similarly, we need to concurrently receive on all of these
| tunnels. The tunnels are just a user-space concept though. All
| we do is receive on the UDP socket and index into the
| corresponding data structure based on the sending socket.
|
| If all of these "connections" would use their own task and UDP
| socket, we'd would have to use channels (and thus copying) to
| dispatch them. Additionally, the policy state would have to be
| in an `Arc<Mutex>` because it is shared among all connections.
| ComputerGuru wrote:
| This is billed as something revolutionary and forward progress
| but that's exactly how we used to do async in $lang - including
| Rust - before language support for async/await landed.
|
| The biggest productivity boost to my rust embedded firmware
| development was when I could stop manually implementing state
| machines and marshalling all local variables into custom state
| after custom state between each I/O operation snd let rust do
| that for me by using async/await syntax!
|
| That's, after all, what async desugars to in rust: an automatic
| state machine that saves values across I/O (await) points for
| you.
| wh33zle wrote:
| I tried to address this at the end of the post: If what you are
| implementing is mostly _sequential_ IO operations, then this
| model becomes a bit painful.
|
| That isn't always the case though. In more packet-oriented
| usecases (QUIC, WebRTC & IP), doing the actual IO bit is easy:
| send & receive individual packets / datagrams.
|
| There isn't really much the compiler can generate for you
| because you don't end up with many `.await` points. At the same
| time, the state management across all these futures becomes
| spaghetti code because many of these aspects should run
| concurrently and thus need to be in their own future / task.
| k_bx wrote:
| Yep. The only things about async that bothers me is the need to
| write ".await" everywhere. I wish there'd be a way to inverse
| this, and actually just run ".await" by default, while having a
| special construct not to.
| mdtusz wrote:
| You mean `.await`, I assume?
| k_bx wrote:
| Thanks, didn't have my coffee yet :)
| sirdvd wrote:
| /s https://xkcd.com/2954/
| vlmutolo wrote:
| It's important to be able to see where the async function
| might pause execution.
|
| For example, if you're holding a mutex lock, you probably
| want to avoid holding it "across" an await point so that it's
| not locked for longer than necessary if the function is
| paused.
| Arnavion wrote:
| I agree that the explicit yield syntax is good.
|
| To play devil's advocate though, the case of Mutex
| specifically has it going for it that MutexGuard is !Send,
| so it's a compiler error if a MutexGuard is held across an
| await point in a Send Future. But yes if your Future is
| also !Send then the compiler will allow it. In that case,
| your only recourse is that clippy has lints for holding
| Mutex and RefCell guards across await points, as long as
| you're running it and paying attention to it of course.
| Aissen wrote:
| It is not billed as revolutionary. From the article:
|
| > This pattern isn't something that we invented! The Python
| world even has a dedicated website about it.
|
| And yet it is too common to find protocol libraries doing I/O
| in the wild :-(
| PaulHoule wrote:
| It was how we did I/O in assembly language in the 1980s. How
| else would you write an interrupt-driven YMODEM implementation?
| tel wrote:
| I don't think that's quite true. The lift here is that the
| state machine does not do any IO on its own. It always
| delegates that work to the event loop that's hosting it, which
| allows it to be interpreted in different contexts. That makes
| it more testable and more composable as it makes fewer
| assumptions about the runtime environment.
|
| Theoretically, you could do the same thing with async/await
| constructing the state machines for you, although in practice
| it's pretty painful and most async/await code is impure.
|
| There are lots of more experimental languages which exceptional
| support for this style of programming (Eff, Koka, Frank).
| Underlying all of Haskell's IO discourse is a very deep
| investment into several breeds of this kind of technology (free
| monads and their variants).
|
| Lately, Unison has been a really interesting language which
| explores lots of new concepts but also has at its core an
| extensible effects system that provides excellent language-
| level support for this kind of coding.
| sriram_malhar wrote:
| > I don't think that's quite true. The lift here is that the
| state machine does not do any IO on its own.
|
| Here is a simple counter example. Suppose you have to process
| a packet that contains many sequences (strings/binary blobs)
| prefixed by 4 bytes of length.
|
| You are not always guaranteed to get the length bytes or the
| string all in one go. In a sequential system you'd accumulate
| the string as follows handle_input(...)
| while not received 4 bytes accumulate in buf
| len = toInt(buf[0..4]) while not received len
| bytes accumulate in buf
|
| If implemented as a state machine ,these would require two
| await points to assemble the string. Flattening this out into
| a state machine manually is a pain.
| tel wrote:
| I agree totally, it wasn't my intention to say that there
| aren't protocols which require non-trivial state machines
| to implement their behavior.
|
| To be more clear, I'm contesting that the only thing being
| discussed in the article is this convenience around writing
| state machines. I think whether or not you have to write
| non-trivial state machines by hand or have them generated
| by some convenient syntax is orthogonal to the bigger
| insight of what Sans-IO is going after.
|
| I think the most important part here is that you write
| these state machines such that they perform no impure
| calculation on their own. In other words, you write state
| machines that must be driven by an event loop which is
| responsible for interpreting commands from those state
| machines and that all IO (and more generally, all impure
| calculation) is performed exclusively by that event loop.
|
| It's much more possible to compose machines like this
| because they don't make as many assumptions on the runtime.
| It's not that they're reading from a blocking or non-
| blocking socket. It's that they process some chunk of bytes
| and _possibly_ want to send some chunk of bytes back. The
| event loop, constructed by the user of the state machine,
| is responsible for deciding how to read/write those bytes.
| Arnavion wrote:
| I'm not sure what part of that is supposed to be a pain.
| The sans-io equivalent would be:
| handle_input(buf) -> Result { if len(buf) < 4 {
| return Error::IncompletePacket } len =
| toInt(buf[0..4]) if len(buf) < 4 + len {
| return Error::IncompletePacket } packet =
| buf[4..(4 + len)] return Ok { packet: packet,
| consumed: 4 + len } }
|
| where the semantics of `Error::IncompletePacket` are that
| the caller reads more into the buffer from its actual IO
| layer and then calls handle_input() again. So your "while
| not received required bytes: accumulate in buf" simply
| become "if len < required: return Error::IncompletePacket"
| tel wrote:
| I don't think that implementation is particularly good,
| although this is a big trick with Sans-IO: is the event
| loop responsible for buffering the input bytes? Or are
| the state machines?
|
| In effect, you have to be thoughtful (and explicit!)
| about the event loop semantics demanded by each state
| machine and, as the event loop implementer, you have to
| satisfy all of those semantics faithfully.
|
| A few alternatives include your version, one where
| `handle_input` returns something like
| `Result<Option<Packet>>` covering both error cases and
| successful partial consumption cases, one where
| `handle_input` tells the event loop how much additional
| input it knows it needs whenever it finishes parsing a
| length field and requires that the event loop not call it
| again until it can hand it exactly that many bytes.
|
| This can all be pretty non-trivial. And then you'd want
| to compose state machines with different anticipated
| semantics. It's not obvious how to do this well.
| r3trohack3r wrote:
| Oh hey thomaseizinger!
|
| I got half way through this article feeling like this pattern was
| extremely familiar after spending time down inside rust-libp2p.
| Seems like that wasn't a coincidence!
|
| Firezone looks amazing, connect all the things!
| wh33zle wrote:
| Haha thank you!
|
| Yes there are indeed similarities to rust-libp2p! Over there,
| things are more interleaved though because the actual streams
| and connections are still within `Future`-like constructs and
| not strictly split like in the sans-IO case here.
| tmd83 wrote:
| Does the actual traffic goes through the gateway or the gateway
| is only used for setting up the connection?
| wh33zle wrote:
| Yes, traffic is routed to the gateway through a WireGuard
| tunnel. Broadly speaking, what happens is:
|
| - Client and gateway perform ICE to agree on a socket pair
| (this is where hole-punching happens or if that fails, a relay
| is used)
|
| - The socket pair determined by ICE is used to set up a
| WireGuard tunnel (i.e. a noise handshake using ephemeral keys).
|
| - IP traffic is read from the TUN device and sent via the
| WireGuard tunnel to the gateway.
|
| - Gateway decrypts it and emits it as a packet from its TUN
| device, thereby forwarding it to the actual destination.
|
| It is worth noting that a WireGuard tunnel in this case is
| "just" the Noise Protocol [0] layered on top of UDP. This
| ensures the traffic is end-to-end encrypted.
|
| [0]: https://noiseprotocol.org
| amluto wrote:
| > Also, sequential workflows require more code to be written. In
| Rust, async functions compile down to state machines, with each
| .await point representing a transition to a different state. This
| makes it easy for developers to write sequential code together
| with non-blocking IO. Without async, we need to write our own
| state machines for expressing the various steps.
|
| Has anyone tried to combine async and sans-io? At least morally,
| I ought to be able to write an async function that awaits sans-
| io-aware helpers, and the whole thing should be able to be
| compiled down to a state machine inside a struct with a nice
| sans-io interface that is easily callable by non-async code.
|
| I've never tried this, but the main issues I would forsee would
| be getting decent ergonomics and dealing with Pin.
| wh33zle wrote:
| They actually play together fairly well higher up the stack.
| Non-blocking IO (i.e async) makes it easy to concurrently wait
| for socket IO and time. You can do it with blocking IO too by
| setting a read-timeout on the socket but using async primitives
| makes it a bit easier.
|
| But I've also been mulling over the idea how they could be
| combined! One thing I've arrived at is the issue that async
| functions compile into opaque types. That makes it hard /
| impossible to use the compiler's facility of code-generating
| the state machine because you can't interact with it once it
| has been created. This also breaks the borrow-checker in some
| way.
|
| For example, if I have an async operation with multiple steps
| (i.e. `await` points) but only one section of those needs a
| mutable reference to some shared data structure. As soon as I
| express this using an `async` function, the mutable reference
| is captured in the generated `Future` type which spans across
| all steps. As a result, Rust doesn't allow me to run more than
| one of those concurrently.
|
| Normally, the advice for these situations is "only capture the
| mutable reference for as short as possible" but in the case of
| async, I can't do that. And splitting the async function into
| multiple also gets messy and kind of defeats the point of
| wanting to express everything in a single function again.
| algesten wrote:
| One thing I toyed with, but didn't get very far, was to encode
| the HTTP/1.1 protocol as a Sans-IO state machine with .await
| points for the IO, but rather than the IO registering Wakers
| with an async runtime, it relinquished control back to the user
| to perform the IO manually. One can think of it as .await
| releasing "up" instead of "down".
|
| In the context of HTTP/1.1 the async code became a kind of
| "blueprint" for how the user wants the call to behave. At the
| time I was dead set on making it work for no_std (non
| allocator) environment, and I gave up because I couldn't find a
| way around how to need dynamic dispatch via Box<dyn X> (needing
| an allocator).
| 10000truths wrote:
| Rust has generators/coroutines that can somewhat address the
| use case you're describing, but they're an extra-unstable
| feature at the moment. Unfortunately, in its current
| incarnation, coroutines have the annoying limitation of only
| being exposed via the std::ops::Coroutine trait, so the
| underlying state machine generated by the compiler can't be
| manually allocated, even though the size of the state machine
| is ostensibly a compile-time constant.
|
| It's not an issue for a single coroutine whose lifetime is
| contained within the function that defines it, since the
| compiler can figure that out and stack-allocate the state
| machine. But arguably the most useful application of coroutines
| is as elements in a queue for event loop machinery. But
| implementing that is made impossible unless you box the
| coroutines. Vec<Box<dyn Coroutine>> is not a cache friendly
| data structure, and you'll feel the pain if you're doing
| extremely high concurrency I/O and need a million elements in
| your Vec.
| wh33zle wrote:
| If Rust ever gets a native generator syntax, this might be
| become achievable because one would be able to say: `yield
| transmit` to "write" data whilst staying within the context of
| your async operation. In other words, every `socket.write`
| would turn into a `yield transmit`.
|
| To read data, the generator would suspend (.await) and wait to
| be resumed with incoming data. I am not sure if there is
| nightly syntax for this but it would have to look something
| like: // Made up `gen` syntax: gen(yield_type,
| resume_type) gen(Transmit, &[u8]) fn stun_binding(server:
| SocketAddr) -> SocketAddr { let req =
| make_stun_request(); yield Transmit {
| server, payload: req }; let res =
| .await; // Made up "suspend and resume with argument"-syntax.
| let addr = parse_stun_response(res); addr }
| Arnavion wrote:
| Rust has had native generator syntax for a few years FYI.
| It's what async-await is built on. It's just gated behind a
| nightly feature.
|
| https://doc.rust-
| lang.org/stable/std/ops/trait.Coroutine.htm... and the syntax
| you're looking for for resuming with a value is `let res =
| yield ...`
|
| Alternatively there is a proc macro crate that transforms
| generator blocks into async blocks so that they work on
| stable, which is of course a round-about way of doing it, but
| it certainly works.
| kmac_ wrote:
| This is another take on defunctionalization. You create a model
| of execution but do not execute it. I.e., return or queue a
| value of type Send, and do not execute "send". The execution is
| separate and actually deals with "real-world" side effects. The
| execution can be done by sync/async/transformed to monads, it
| doesn't matter.
| ithkuil wrote:
| A long time ago I had "fun" implementing all sorts of network
| protocols with such an event based library on C:
| https://github.com/cesanta/mongoose
| bionhoward wrote:
| I wrote a library which I didn't release yet, where the natural
| async approach seems impossible to compile in Rust if async I/O
| is tied too tightly to main logic. (In b4 skill issue)
|
| Sans I/O mostly means, write pure functions and move I/O out of
| your main functionality as much as possible. Then you can deal
| with each part independently and this makes the compiler happy.
|
| 80-96% of your work on a sans io rust project is still going to
| be I/O, but it's not complected with your main logic, so you
| can unit test the main logic more easily
| ziziman wrote:
| How does this design compare to using channels to send data to a
| dedicated handlers. When using channels i've found multiple
| issues: (1) Web-shaped code that is often hard to follow along
| (2) Requires to manually implement message types that can then be
| converted to network-sendable messages (3) Requires to explicitly
| give a transmitter to interested/allowed entities (4) You get a
| result if your channel message failed to transmit but NOT if your
| message failed to transmit over network
|
| But besides that it's pretty convenient. Let's say you have a
| ws_handler channel, you just send your data through that and
| there is a dedicated handler somewhere that may or may not send
| that message if it's able to.
| wh33zle wrote:
| Channels work fine if you are happy for your software to have
| an actor-like design.
|
| But as you say, it comes with problems: Actors / channels can
| be disconnected for example. You also want to make sure they
| are bounded otherwise you don't have backpressure. Plus, they
| require copying so achieving high-throughput may be tricky.
| K0nserv wrote:
| For 4 you can implement that with a channel passed along with
| the message to send a result back. You can then block the
| sending side all the way to the callsite if you wish.
|
| My feeling is that sans-IO is particularly useful for
| libraries, although it can be used for applications too. In a
| library it means you don't force decisions about how I/O
| happens on your consumer, making it strictly more useful. This
| is important for Rust because there's already a bunch of
| ecosystem fragmentation between sync and async IO(not to
| mention different async runtimes)
| wh33zle wrote:
| The line between applications and libraries is fairly blurry,
| isn't it? In my experience, most applications grow to the
| point where you have internal libraries or could at least
| split out one or more crates.
|
| I would go as far as saying that whatever functionality your
| application provides, there is a core that can be modelled
| without depending on IO primitives.
| K0nserv wrote:
| Yes true, the one difference might be that you don't expect
| other consumers with a different approach to IO to use your
| internal libraries, although it does help you if you want
| to change that in the future and the testability is still
| useful
| binary132 wrote:
| In my eyes an ideal library should not contain state,
| internally allocate (unless very obviously), or manage
| processes. The application should do that, or provide
| primitives for doing it which the library can make use of.
| That makes applications and libraries very very different
| in my mind.
| K0nserv wrote:
| The thing about state is a good point. With the sans-IO
| pattern we have inversion of IO and Time, but adding
| memory to that would be a nice improvement too.
| binary132 wrote:
| Those C libraries that have initializers which take **
| and do the allocation for you drive me nuts! I'm sure
| there's some good reason, but can't you trust me to
| allocate for myself, you know?
| mgaunard wrote:
| This is just normal asynchronous I/O with callbacks instead of
| coroutines.
| Uptrenda wrote:
| I don't know what the take away is supposed to be here.
| Everything spoken about here is already basic network
| programming. It seems to focus on higher level plumbing and geeks
| out on state management even though this is just a matter of
| preference and has nothing to do with networking.
|
| The most interesting thing I learned from the article is that
| cloudflare runs a public stun server. But even that isn't helpful
| because the 'good' and 'useful' version of the STUN protocol is
| the first version of the protocol which supports 'change
| requests' -- a feature that allows for NAT enumeration. Later
| versions of the STUN protocol removed that feature thanks to the
| 'helpful suggestions' of Cisco engineers who contributed to the
| spec.
| K0nserv wrote:
| The big thing, in the context of Rust, I think is how this
| solves function colouring, but it also makes testing really
| simple as outlined in the post.
|
| The current situation in Rust is that if you implement a
| library, say one that does WebRTC, that uses the Tokio async
| runtime. Then it's very cumbersome for folks to use it if they
| are doing sync IO, using a different runtime(smol, async-std
| etc), are using iouring directly etc. With this approach you
| don't force the IO choice on consumers and make the library
| useful to more people.
| solidninja wrote:
| The parallels with abstracting over the effect type and
| Free(r) monads are really apparent if you've had exposure to
| that style of programming. As you said, the benefit is that
| you can separate the business logic (what you want to do)
| from the execution model (how you do it) and this is very
| much an ongoing theme in programming language development.
| zamalek wrote:
| I had been mulling over this problem space in my head, and this
| is a seriously great approach to the direction I have been
| thinking (though still needs work, footnote 3 in the article).
|
| What got me thinking about this was the whole fn coloring
| discussion, and a happy accident on my part. I had been writing a
| VT100 library and was doing my head in trying to unit test it.
| The problem was that I was essentially `parser::new(stdin())`.
| During the 3rd or 4th rewrite I changed the parser to
| `parser::push(data)` without really thinking about what I was
| doing. I then realized that Rust was punishing me for using an
| enterprise OOPism anti-pattern I have since been calling
| "encapsulation infatuation." I now see it _everywhere_ (not just
| in I /O) and the havoc it wreaks.
|
| The irony is that this solution is taught pre-tertiary education
| (and again early tertiary). The simplest description of a
| computer is a machine that takes input, processes/transforms
| data, and produces output. This is relevant to the fn coloring
| discussion because only input and output need to be concerned
| with it, and the meat-and-potatoes is usually data
| transformation.
|
| Again, this is patently obvious - but if you consider the size of
| the fn coloring "controversy;" we've clearly all been
| missing/forgetting it because many of us have become hard-wired
| to start solving problems by encapsulation first (the functional
| folks probably feel mighty smug at this point).
|
| Rust has seriously been a journey of more unlearning than
| learning for me. Great pattern, I am going to adopt it.
|
| Edit: code in question:
| https://codeberg.org/jcdickinson/termkit/src/branch/main/src...
| j1elo wrote:
| > _I changed the parser to `parser::push(data)` without really
| thinking about what I was doing. I then realized that Rust was
| punishing me for using an enterprise OOPism anti-pattern_
|
| Could you please elaborate more on this? I feel you're talking
| about an obvious problem with that pattern but I don't see how
| Rust punishes you for using it (as a very novice Rust learner)
| zamalek wrote:
| It's been a while, so I'm a bit hazy on the details. Every
| iteration of the code had the scanner+parser approach. The
| real problems started when testing the parser (because that
| was the double-encapsulated `Parser<Scanner<IO>>`). This
| means that in order to test the parser I had to mock complete
| VT100 data streams (`&mut &[u8]` - `&mut b"foo"` - is
| fortunately a Reader, so that was one saving grace). They
| were by all standards integration tests, which are annoying
| to write. Past experiences with fighting the borrow-checker
| taught me that severe friction (and lack of enjoyment) is a
| signal that you might be doing something wrong _even if you
| can still get it to work,_ which is why I kept iterating.
|
| My first few parser designs also took a handler trait (the
| Alacritty VT100 stuff does this if you want a living
| example). Because, you know, encapsulate and DI all the
| things! Async traits weren't a thing at the time (at least
| without async-trait/boxing/allocation in a hot loop), so fn
| coloring was a very real problem for me.
|
| The new approach (partial, I haven't started the parser) is:
| input.read(&mut data); tokens =
| scanner.push(&data); ops = parser.push(&tokens);
|
| Maybe you can see from that how much simpler it would be to
| unit test the parser, I can pass it mock token streams
| instead of bytes. I can also assert the incremental results
| of it without having to have some mock handler trait impl
| that remembers what fns were invoked.
|
| I'm not sure if that really answers your question, but as I
| mentioned: it's been a while. And good luck with the
| learning!
| binary132 wrote:
| Correct me if I'm wrong, but would another way of saying
| this be: write parsers in terms of a buffer, not in terms
| of IO?
| zamalek wrote:
| Yup, that makes sense.
| j1elo wrote:
| Thanks a lot! Yeah I see how the simpler design that has
| non-stacked implementations one on top of another is easier
| to understand and test. Yours is not only a lesson in
| design for Rust, but in general for any technology! This
| later idea is just to compose parts together, but those
| parts are able to work independently just fine (given
| properly formatted inputs). A simpler and more robust way
| of designing components that have to work together.
| zamalek wrote:
| Totally, Rust has substantially affected how I write code
| at my day job (C#). If I ever get round to learning a
| functional language, I'm sure that would have a much
| bigger effect.
| wh33zle wrote:
| I too came from the OOP world to Rust (6 years ago now) and in
| my first 2-3 years I produced horrible code!
|
| Type parameters and traits everywhere. Structs being (ab-)used
| as class-like structures that provide functionality.
|
| Rust works better if you avoid type parameters and defining
| your own traits for as much as possible.
|
| Encapsulation is good if we talk about ensuring invariants are
| maintained. This blog post about parse, don't validate comes to
| my mind: https://lexi-lambda.github.io/blog/2019/11/05/parse-
| don-t-va...
| hardwaresofton wrote:
| See also: monads and in particular the Free(r) monad, and effects
| systems[0].
|
| The idea of separating logic from execution is a whole thing,
| well trodden by the Haskell ecosystem.
|
| [EDIT] Also, they didn't mention how they encapsulated the
| `tokio::select!` call that shows up when they need to do time-
| related things -- are they just carrying around a
| `tokio::Runtime` that they use to make the loop code async
| without requiring the outside code to be async?
|
| [EDIT2] Maybe they weren't trying to show an encapsulated library
| doing that, but rather to show that the outside application can
| use the binding in an async context...
|
| I would have been more interested in seeing how they could
| implement an encapsulated function in the sans-IO style that had
| to do something like wait on an action or a timer -- or maybe the
| answer they're expecting there is just busy-waiting, or carrying
| your own async runtime instance (that can essentially do the busy
| waiting for you, with something like block_in_place.
|
| [0]: https://okmij.org/ftp/Computation/free-monad.html
| wh33zle wrote:
| > I would have been more interested in seeing how they could
| implement an encapsulated function in the sans-IO style that
| had to do something like wait on an action or a timer
|
| The "encapsulated function" is the `StunBinding` struct. It
| represents the functionality of a STUN binding. It isn't a
| single function you can just call, instead it requires an
| eventloop.
|
| The point though is, that `StunBinding` could live in a library
| and you would be able to use it in your application by
| composing it into your program's state machine (assuming you
| are also structuring it in a sans-IO style).
|
| The linked `snownet` library does exactly this. Its domain is
| to combine ICE + WireGuard (without doing IO) which is then
| used by the `connlib` library that composes ACLs on top of it.
|
| Does that make sense?
|
| EDIT: There is no busy-waiting. Instead, `StunBinding` has a
| function that exposes, what it is waiting for using
| `poll_timeout`. How the caller (i.e. eventloop) makes that
| happen is up to them. The appropriate action will happen once
| `handle_timeout` gets called with the corresponding `Instant`.
| hardwaresofton wrote:
| > The "encapsulated function" is the `StunBinding` struct. It
| represents the functionality of a STUN binding. It isn't a
| single function you can just call, instead it requires an
| eventloop. > > The point though is, that `StunBinding` could
| live in a library and you would be able to use it in your
| application by composing it into your program's state machine
| (assuming you are also structuring it in a sans-IO style).
|
| What I was thinking was that the functionality being executed
| in main _could just as easily be in a library function_ --
| that 's what I meant by encapsulated function, maybe I should
| have said "encapsulated functionality".
|
| If the thing I want to do is the incredibly common read or
| timeout pattern, how do I do that in a sans-IO way? This is
| why I was quite surprised to see the inclusion of
| tokio::select -- that's not very sans IO, but is absolutely
| the domain of a random library function that you might want
| to expose.
|
| It's a bit jarring to introduce the concept as not requiring
| choices like async vs not, then immediately require the use
| of async in the event loop (required to drive the state
| machine to completion).
|
| Or is the point that the event loop should be async? That's a
| reasonable expectation for event loops that are I/O bound --
| it's the whole point of a event loop/reactor pattern. Maybe
| I'm missing some example where you show an event loop that is
| _not_ async, to show that you can drive this no matter
| whether you want or don 't want async?
|
| So if I to try to condense & rephrase:
|
| If I want to write a function that listens or times out in
| sans-IO style, should I use tokio::select? If so, where is
| the async runtime coming from, and how will the caller of the
| function be able to avoid caring?
| wh33zle wrote:
| > If I want to write a function that listens or times out
| in sans-IO style, should I use tokio::select? If so, where
| is the async runtime coming from, and how will the caller
| of the function be able to avoid caring?
|
| To "time-out" in sans-IO style means that your state
| machine has an `Instant` internally and, once called at a
| specific point in the future, compares the provided `now`
| parameter with the internal timeout and changes its state
| accordingly. See [0] for an example.
|
| > but is absolutely the domain of a random library function
| that you might want to expose.
|
| That entire `main` function is _not_ what you would expose
| as a library. The event loop should always live as high up
| in the stack as possible, thereby deferring the use of
| blocking or non-blocking IO and allowing composition with
| other sans-IO components.
|
| You can absolutely write an event loop without async. You
| can set the read-timeout of the socket to the value of
| `poll_timeout() - Instant::now` and call `handle_timeout`
| in case your `UdpSocket::recv` call errors with a timeout.
| str0m has an example [1] like that in their repository.
|
| > It's a bit jarring to introduce the concept as not
| requiring choices like async vs not, then immediately
| require the use of async in the event loop (required to
| drive the state machine to completion).
|
| All the event loops you see in the post are solely there to
| ensure we have a working program but are otherwise
| irrelevant, esp. implementation details like using
| `tokio::select` and the like. Perhaps I should have made
| that clearer.
|
| [0]: https://github.com/firezone/firezone/blob/1e7d3a40d213
| c9524a... [1]: https://github.com/algesten/str0m/blob/5b100
| e8a675cd8838cdd8...
| hardwaresofton wrote:
| > To "time-out" in sans-IO style means that your state
| machine has an `Instant` internally and, once called at a
| specific point in the future, compares the provided `now`
| parameter with the internal timeout and changes its state
| accordingly. See [0] for an example.
|
| This part of the post was clear -- I didn't ask any
| clarifications about that, my point was about what I see
| as "read or timeout", a reasonable functionality to
| expose as a external facing function.
|
| The question is still "If I want to read or timeout, from
| inside a function I expose in a library that uses sans-IO
| style, how do I do that?".
|
| It _seems_ like the answer is "if you want to accomplish
| read or timeout at the library function level, you either
| busy wait or pull in an async runtime, but whatever calls
| your state machine has to take care of that at a higher
| level".
|
| You see how this doesn't really work for me? Now I have
| to decide if my read_or_timeout() function exposed is
| either the default sync (and I have to figure out how
| long to wait, etc), or async.
|
| It seems in sans-IO style read_or_timeout() would be
| sync, and do the necessary synchronous waiting
| internally, _without_ the benefit of being able to run
| other tasks from unrelated state machines in the
| meantime.
|
| > That entire `main` function is _not_ what you would
| expose as a library.
|
| Disagree -- it's entirely reasonable to expose "read your
| public IP via STUN" as a library function. I think we can
| agree to disagree here.
|
| > The event loop should always live as high up in the
| stack as possible, thereby deferring the use of blocking
| or non-blocking IO and allowing composition with other
| sans-IO components.
|
| Sure... but that means the code you showed me should
| never be made into a library (we can agree to disagree
| there), and I think it's reasonable functionality for a
| library...
|
| What am I missing here? From unrelated code, I want to
| call `get_ip_via_stun_or_timeout(hostnames: &[String],
| timeout: Duration) -> Option<String>`, is what I'm
| missing that I need to wrap this state machine in another
| to pass it up to the level above? That I need to
| essentially move the who-must-implement-the-event-loop
| one level up?
|
| > You can absolutely write an event loop without async.
| You can set the read-timeout of the socket to the value
| of `poll_timeout() - Instant::now` and call
| `handle_timeout` in case your `UdpSocket::recv` call
| errors with a timeout. str0m has an example [1] like that
| in their repository.
|
| Didn't say you couldn't!
|
| What you've described is looping with a operation-
| supported timeout, which requires timeout integration at
| the function call level below you to return control. I
| get that this is a potential solution (I mentioned it in
| my edits on the first comment), but not mentioning it in
| the article was surprising to me.
|
| The code I was expecting to find in that example is like
| the bit in strom:
|
| https://github.com/algesten/str0m/blob/5b100e8a675cd8838c
| dd8...
|
| Clearly (IMO evidenced by the article using this method),
| the most ergonomic way to do that is with a
| tokio::select, and that's what I would reach for as well
| -- but I thought a major point was to do it _sans_ IO
| (where "IO" here basically means "async runtime").
|
| Want to note again, this is not to do with the state
| machine (it's clear how you would use a passed in Instant
| to short circuit), but more about the implications of
| abstracting the use of the state machine.
|
| > All the event loops you see in the post are solely
| there to ensure we have a working program but are
| otherwise irrelevant, esp. implementation details like
| using `tokio::select` and the like. Perhaps I should have
| made that clearer.
|
| I personally think it exposes a downside of this method
| -- while I'm not a fan of simply opting in to either
| async (and whichever runtime smol/tokio/async-std/etc) or
| sync, what it seems like this pattern will force me to:
|
| - Write all code as sync - Write sync code that does
| waiting based on operations that yielding back control
| early - Hold my own tokio runtime so I can do concurrent
| things (this, you argue against)
|
| Async certainly can be hard to use and have many
| footguns, but this approach is certainly not free either.
|
| At this point if I think I want to write a library that
| supports both sync and async use cases it feels like
| feature flags & separate implementations might produce an
| easier to understand outcome for me -- the sync version
| can even start as mostly `tokio::Runtime::block_on`s, and
| graduate to a more performant version with better custom-
| tailored efficiency (i.e. busy waiting).
|
| Of course, I'm not disparaging the type state pattern
| here/using state machines -- just that I'd probably just
| use that from inside an async/sync-gated modules (and be
| able to share that code between two impls).
| wh33zle wrote:
| > What am I missing here? From unrelated code, I want to
| call `get_ip_via_stun_or_timeout(hostnames: &[String],
| timeout: Duration) -> Option<String>`, is what I'm
| missing that I need to wrap this state machine in another
| to pass it up to the level above? That I need to
| essentially move the who-must-implement-the-event-loop
| one level up?
|
| Essentially yes! For such a simple example as STUN, it
| may appear silly because the code that is abstracted away
| in a state machine is almost shorter than the event loop
| itself.
|
| That very quickly changes as the complexity of your
| protocol increases though. The event loop is always
| roughly the same size yet the protocol can be almost
| arbitrarily nested and still reduces down to an API of
| `handle/poll_timeout`, `handle_input` &
| `handle_transmit`.
|
| For example, we've been considering adding a QUIC stack
| next to the WireGuard tunnels as a control protocol in
| `snownet`. By using a sans-IO QUIC implementation like
| quinn, I can do that entirely as an implementation detail
| because it just slots into the existing state machine,
| next to ICE & WireGuard.
|
| > At this point if I think I want to write a library that
| supports both sync and async use cases it feels like
| feature flags & separate implementations might produce an
| easier to understand outcome for me -- the sync version
| can even start as mostly `tokio::Runtime::block_on`s, and
| graduate to a more performant version with better custom-
| tailored efficiency (i.e. busy waiting).
|
| > Of course, I'm not disparaging the type state pattern
| here/using state machines -- just that I'd probably just
| use that from inside an async/sync-gated modules (and be
| able to share that code between two impls).
|
| This is what quinn does: It uses tokio + async to expose
| an API that uses `AsyncRead` and `AsyncWrite` and thus
| fully buys into the async ecosystem. The actual protocol
| implementation however - quinn-proto - is sans-IO.
|
| The way I see this is that you can always build more
| convenience layers, whether or not they are in the same
| crate or not doesn't really matter for that. The key
| thing is that they should be optional. The problems of
| function colouring only exist if you don't focus on
| building the right thing: an IO-free implementation of
| your protocol. The protocol implementation is usually the
| hard bit, the one that needs to be correct and well-
| tested. Integration with blocking or non-blocking IO is
| just plumbing work that isn't difficult to write.
| hardwaresofton wrote:
| Ahh thanks for clarifying this! Makes a ton of sense now
| -- I need to try writing some of these style of programs
| (in the high perf Rust style) to see how they feel.
|
| > For example, we've been considering adding a QUIC stack
| next to the WireGuard tunnels as a control protocol in
| `snownet`. By using a sans-IO QUIC implementation like
| quinn, I can do that entirely as an implementation detail
| because it just slots into the existing state machine,
| next to ICE & WireGuard.
|
| Have you found that this introduces a learning curve for
| new contributors? Being able to easily stand up another
| transport is pretty important, and I feel like I can whip
| together an async-required interface for a new protocol
| very easily (given I did a decent job with the required
| Traits and used the typestate pattern) where as sans-IO
| might be harder to reason about.
|
| Thanks for pointing out quinn-proto (numerous times at
| this point) as well -- I'll take a look at the codebase
| and see what I can learn from it (as well as str0m).
|
| [EDIT]
|
| > The problems of function colouring only exist if you
| don't focus on building the right thing: an IO-free
| implementation of your protocol. The protocol
| implementation is usually the hard bit, the one that
| needs to be correct and well-tested.
|
| The post, in a couple lines!
|
| [EDIT2] Any good recommendations of a tiny protocol that
| might be a good walk through intro to this?
|
| Something even simpler than Gopher or SMTP? Would be nice
| to have a really small thing to do a tiny project in.
| wh33zle wrote:
| > [EDIT2] Any good recommendations of a tiny protocol
| that might be a good walk through intro to this? > >
| Something even simpler than Gopher or SMTP? Would be nice
| to have a really small thing to do a tiny project in.
|
| I only have experience in packet-oriented ones so I'd
| suggest sticking to that. Perhaps WireGuard could be
| simple enough? It has a handshake and timers so some
| complexity but nothing too crazy.
|
| DNS could be interesting too, because you may need to
| contact upstream resolvers if you don't have something
| cached.
| screcth wrote:
| It would be better if the compiler could take the async code and
| transform it automatically to its sans io equivalent. Doing it
| manually seems error prone and makes it much harder to understand
| what the code is doing.
| mpweiher wrote:
| Reading the article and some of the comments, it sounds like they
| reinvented the hexagonal or ports/adapters architectural style?
| Arnavion wrote:
| See also this discussion from a few months ago about sans-io in
| Rust: https://news.ycombinator.com/item?id=39957617
___________________________________________________________________
(page generated 2024-07-04 23:01 UTC)