> The design also avoids problems like function coloring and plays well with Rust's ownership rules.
Async is not I/O. It can be used for I/O, but it's just a way of writing pure, deterministic state machines that keeps a logical flow together instead of scattered over a bunch of transitions.
Even if you choose to manually compile your async flows into explicit state machines, you still have to deal with ‘function colouring’ (a.k.a. typing): a flow that crosses several state transitions (and may have other disjoint parts of the state updated in the meantime, via other transitions) now has to be rewritten as a (series of) state transitions, and can't be called from a blocking function. If anything, rewriting all your state machines without `async` makes the function colouring ‘problem’ worse, because multi-transition flows are even more different from normal functions.
In principle, you are right. Async is not I/O. And yes, `async` functions compile to a deterministic state machine.
The problem is, the moment you use `async`, your state machine reduces down to one API: `poll`. What if I want to change some state in-between two state transitions (i.e. between two `await` points)? Now I have to wire-in a channel to supply that data at the right point or access some shared data via a Mutex.
Rust's `async` implementation is great if you know everything upfront before you kick off your state machine. The moment you need to do many things concurrently, it can become a real mess.
Firezone's sans-IO core handles many things concurrently: inbound IP, inbound UDP, WebSocket control messages, TURN allocations, timers, user commands, etc. Having essentially one big struct that I can just call `&mut self` functions on for any one of these inputs makes this very easy to understand, compose and test.
A few things like TURN request-response handshakes might be easier as an `async` function but in reality, it is not a big deal and the composability you get from it outweighs it in my opinion.
Isn't this the idea behind generalizing `async` to `Generator`? You can set the `R` type parameter to whatever type you want to resume the computation with.
Correct! I played around with this in the past. It helps less than you'd think. As soon as you have a generic resume parameter, you can no longer drive the generator with a generic runtime.
If I am now writing my own runtime / eventloop to advance my state machine anyway, what exactly am I getting from doing that through an abstraction like the Generator trait?
On top of that, the resume and yield abstraction can get convoluted because a lot of times, you only want to do one or the other, meaning either case needs to support a no-op because a single `yield` always does both.
Lastly, with all input going through a single type, your state machine then needs to handle every possible input at every yield point. That is a lot of `match` statements and branching.
I am sure there are better ways to do this and having generators to express custom iterators would be amazing. But for generic state machines, I don't see much of a benefit.
> flow that crosses several state transitions (and may have other disjoint parts of the state updated in the meantime, via other transitions) now has to be rewritten as a (series of) state transitions, and can't be called from a blocking function
This is incorrect. You can easily call that flow from a blocking function, just call it in a loop.
What normally prevents a blocking function from "simply" calling an async one is that the async function needs runtime support for certain operations: the simpliest of which is suspending until it's notified, with more complex ones being async I/O primitives.
A Sans-IO library needs none of these. It can simply be called in a loop and as long as the caller provides more inputs it will make progress. An blocking caller can simply call the std's `Read::read` and pass the received bytes in, while an async caller can use async I/O primitives to do the same, and since the Sans-IO library yields once it finishes consuming the current input it won't block the async library either.
It's the best of both world but as always you have a tradeoff, and in this case it's the ability to express the state machine as if it was a straightforward function. Maybe once Rust gets support for generic coroutines this will get better.
> Async is not I/O. It can be used for I/O, but it's just a way of writing pure, deterministic state machines that keeps a logical flow together instead of scattered over a bunch of transitions.
I argue that it is exactly opposite. Blocking threads are a much more natural way of thinking about concurrency.
Quote from the erlang creator Joe Armstrong's thesis. He argues that the only way to understand multi-threaded programs is writing strictly sequential code for every thread and not muddling all the code for all the threads in one place:
"The structure of the program should exactly follow the structure of the problem. Each real world concurrent activity should be mapped onto exactly one concurrent process in our programming language. If there is a 1:1 mapping of the problem onto the program we say that the program is isomorphic to the problem.
It is extremely important that the mapping is exactly 1:1. The reason for this is that it minimizes the conceptual gap between the problem and the solution. If this mapping is not 1:1 the program will quickly degenerate, and become difficult to understand. This degeneration is often observed when non-CO languages ["non concurrency-oriented", looking at you JavaScript!] are used to solve concurrent problems. Often the only way to get the program to work is to force several independent activities to be controlled by the same language thread or process. This leads to a inevitable loss of clarity, and makes the programs subject to complex and irreproducible interference errors." [0]
It's interesting that we've gone from cooperative to preemptive to cooperative multitasking again.
I agree that blocking threads is generally more natural / obvious. Cooperative multitasking was always done for performance reasons and more recently for simplicity (in single thread implementations with no locking) and/or scaling reasons.
Message passing concurrency is going even further though, and never really seems to have taken off wholistically. Again, primarily for performance reasons. When we do pass messages (across networks, too/from RAM or disk, etc) we usually add caching and other optimizations which make concurrency even harder to reason about.
Without actual code solving an actual problem discussing this is all a bit wishy washy, but I think you're mixing 'levels'.
The sans-io design here is about building reusable parts that any runtime or thread can drive and incorporate into the colored interface they expose.
Function coloring is about (the annoyance when) composing interfaces designed around those different runtimes/thread that might use the same sans-io library internally.
Yes async compiles down to a statemachine, but we're talking about the elegance of the language syntax/composition. Most languages require _a lot_ of ritual (or overhead) if you want to use the async-statemachine as a pure statemachines and sidestep the build in runtime-side-effect channels.
Sweet, this is basically how I design my protocol implementations in rust as well.
Way more testable, the bulk can be shared between async and blocking IO, it's a lot easier to completely ban unsafe in your dependencies (excepting std and core), etc.
I've recently been developing a VPN Manager implementation using Rust, and the underlying architecture also uses WireGuard. Currently, I'm using WireGuard gotatun developed by Mullvad.
I wonder if you've tested any related data, such as performance curves, connection jitter, and so on, as well as things like security implementations.
One of the fundamentally great things about the Wireguard protocol is the simplicity. A small codebase can implement it, and that’s lead to some great libraries and innovation.
Another great (and subtle) thing is the monotonically increasing sequence number. Note that it isn’t incrementing, like DTLS. This small thing makes it so much easier to implement with efficient concurrency, and event external state (i.e. distributed concurrency).
> Your operating system and network card take over the repetitive jobs whenever they can. Your CPU gets room to breathe, and your battery stays full longer.
Where does this operating system run? Where does the power come from to run it if not battery?
Pure rubbish ai slop.
While the software might actually be ok upon inspection but this kind of ai slop nonsense copy doesn’t inspire confidence in using their software considering how security critical the vpn is.
The quoted section is meant to explain in layman's terms that we are doing hardware offloading where possible. That means vectorized instructions for encryption and checksum-offloading and packet segmentation in the kernel / hardware driver where supported.
Being more efficient per packet means that we using less power to move the same amount of data, hence less power draw on mobile platforms.
Having said that, I agree that it could be better while still not be filled with technical jargon.
I imagine they’re talking about handling network protocols in the kernel or the NIC instead of in userland code, and they’re generally right. It runs off the same battery, but the kernel implementation and especially the (hardware-accelerated) NIC implementations of protocols like TCP and TLS are generally more energy efficient than doing the same amount of IO in userland code.
I think the point is not if the text can be interpreted charitably, but that the usage of AI text in what is intended for humans to read causes mistrust about where else corners could have been cut, on what is security-critical software
And even the charitable reading is not a good indicator. There is reason things are getting pulled out of kernel and more and more userland services are used, it is because we have finally come to terms that secure software is hard and better hack as little of it trusted with the whole system as possible.
You can't use the Linux kernel implementation as soon as you want to be cross platform and the only official userspace implementation is in Go which is awkward to FFI into from Rust.
I agree with your point. Use what is out there. We did that by using Cloudflare's boringtun for a long time. But it has limitations that eventually we could no longer work around if we wanted good compile-time safety and performance.
> The design also avoids problems like function coloring and plays well with Rust's ownership rules.
Async is not I/O. It can be used for I/O, but it's just a way of writing pure, deterministic state machines that keeps a logical flow together instead of scattered over a bunch of transitions.
Even if you choose to manually compile your async flows into explicit state machines, you still have to deal with ‘function colouring’ (a.k.a. typing): a flow that crosses several state transitions (and may have other disjoint parts of the state updated in the meantime, via other transitions) now has to be rewritten as a (series of) state transitions, and can't be called from a blocking function. If anything, rewriting all your state machines without `async` makes the function colouring ‘problem’ worse, because multi-transition flows are even more different from normal functions.
The problem is, the moment you use `async`, your state machine reduces down to one API: `poll`. What if I want to change some state in-between two state transitions (i.e. between two `await` points)? Now I have to wire-in a channel to supply that data at the right point or access some shared data via a Mutex.
Rust's `async` implementation is great if you know everything upfront before you kick off your state machine. The moment you need to do many things concurrently, it can become a real mess.
Firezone's sans-IO core handles many things concurrently: inbound IP, inbound UDP, WebSocket control messages, TURN allocations, timers, user commands, etc. Having essentially one big struct that I can just call `&mut self` functions on for any one of these inputs makes this very easy to understand, compose and test.
A few things like TURN request-response handshakes might be easier as an `async` function but in reality, it is not a big deal and the composability you get from it outweighs it in my opinion.
If I am now writing my own runtime / eventloop to advance my state machine anyway, what exactly am I getting from doing that through an abstraction like the Generator trait?
On top of that, the resume and yield abstraction can get convoluted because a lot of times, you only want to do one or the other, meaning either case needs to support a no-op because a single `yield` always does both.
Lastly, with all input going through a single type, your state machine then needs to handle every possible input at every yield point. That is a lot of `match` statements and branching.
I am sure there are better ways to do this and having generators to express custom iterators would be amazing. But for generic state machines, I don't see much of a benefit.
This is incorrect. You can easily call that flow from a blocking function, just call it in a loop.
What normally prevents a blocking function from "simply" calling an async one is that the async function needs runtime support for certain operations: the simpliest of which is suspending until it's notified, with more complex ones being async I/O primitives.
A Sans-IO library needs none of these. It can simply be called in a loop and as long as the caller provides more inputs it will make progress. An blocking caller can simply call the std's `Read::read` and pass the received bytes in, while an async caller can use async I/O primitives to do the same, and since the Sans-IO library yields once it finishes consuming the current input it won't block the async library either.
It's the best of both world but as always you have a tradeoff, and in this case it's the ability to express the state machine as if it was a straightforward function. Maybe once Rust gets support for generic coroutines this will get better.
I argue that it is exactly opposite. Blocking threads are a much more natural way of thinking about concurrency.
Quote from the erlang creator Joe Armstrong's thesis. He argues that the only way to understand multi-threaded programs is writing strictly sequential code for every thread and not muddling all the code for all the threads in one place:
"The structure of the program should exactly follow the structure of the problem. Each real world concurrent activity should be mapped onto exactly one concurrent process in our programming language. If there is a 1:1 mapping of the problem onto the program we say that the program is isomorphic to the problem. It is extremely important that the mapping is exactly 1:1. The reason for this is that it minimizes the conceptual gap between the problem and the solution. If this mapping is not 1:1 the program will quickly degenerate, and become difficult to understand. This degeneration is often observed when non-CO languages ["non concurrency-oriented", looking at you JavaScript!] are used to solve concurrent problems. Often the only way to get the program to work is to force several independent activities to be controlled by the same language thread or process. This leads to a inevitable loss of clarity, and makes the programs subject to complex and irreproducible interference errors." [0]
[0] https://erlang.org/download/armstrong_thesis_2003.pdf
I agree that blocking threads is generally more natural / obvious. Cooperative multitasking was always done for performance reasons and more recently for simplicity (in single thread implementations with no locking) and/or scaling reasons.
Message passing concurrency is going even further though, and never really seems to have taken off wholistically. Again, primarily for performance reasons. When we do pass messages (across networks, too/from RAM or disk, etc) we usually add caching and other optimizations which make concurrency even harder to reason about.
The sans-io design here is about building reusable parts that any runtime or thread can drive and incorporate into the colored interface they expose.
Function coloring is about (the annoyance when) composing interfaces designed around those different runtimes/thread that might use the same sans-io library internally.
Yes async compiles down to a statemachine, but we're talking about the elegance of the language syntax/composition. Most languages require _a lot_ of ritual (or overhead) if you want to use the async-statemachine as a pure statemachines and sidestep the build in runtime-side-effect channels.
Way more testable, the bulk can be shared between async and blocking IO, it's a lot easier to completely ban unsafe in your dependencies (excepting std and core), etc.
[0] https://github.com/mullvad/gotatun
I wonder if you've tested any related data, such as performance curves, connection jitter, and so on, as well as things like security implementations.
Another great (and subtle) thing is the monotonically increasing sequence number. Note that it isn’t incrementing, like DTLS. This small thing makes it so much easier to implement with efficient concurrency, and event external state (i.e. distributed concurrency).
That latter bit we’ve done in our own library.
Edit: typo, don’t -> done
> Hardware does the heavy lifting.
> Your operating system and network card take over the repetitive jobs whenever they can. Your CPU gets room to breathe, and your battery stays full longer.
Where does this operating system run? Where does the power come from to run it if not battery?
Pure rubbish ai slop.
While the software might actually be ok upon inspection but this kind of ai slop nonsense copy doesn’t inspire confidence in using their software considering how security critical the vpn is.
The quoted section is meant to explain in layman's terms that we are doing hardware offloading where possible. That means vectorized instructions for encryption and checksum-offloading and packet segmentation in the kernel / hardware driver where supported.
Being more efficient per packet means that we using less power to move the same amount of data, hence less power draw on mobile platforms.
Having said that, I agree that it could be better while still not be filled with technical jargon.
You should not write things with AI that you expect people to read
I will die on this hill.
WG is like 2k LOC why the fuck do we need 30 different versions? Audit what you have.
I agree with your point. Use what is out there. We did that by using Cloudflare's boringtun for a long time. But it has limitations that eventually we could no longer work around if we wanted good compile-time safety and performance.
Can I do that with Jason's reference implementation?
Let people build. They might have reasons you haven't foreseen.