Since work has started on getting generators into Rust in order to support async/await, I think it’s worth thinking about how this stuff will eventually look in the language. I have some issues with the direction the design seems to be going and a vague outline of how it might be possible to do it better. Having said that, I’m not at all confident that I have any idea what I’m talking about, but I thought I’d brain-dump this anyway
Problems with async/await style programming.
This example code from the generators RFC illustrates the first problem.
#[async]
fn print_lines() -> io::Result<()> {
let addr = "127.0.0.1:8080".parse().unwrap();
let tcp = await!(TcpStream::connect(&addr))?;
let io = BufReader::new(tcp);
#[async]
for line in io.lines() {
println!("{}", line);
}
Ok(())
}
Async/await infects everything with async
/await
notation. You can only call
functions marked async
from other functions marked async
. All "blocking"
calls needs to be marked await
etc.
Additionally, introducing async into Rust like this will split the Rust ecosystem into two halves: blocking Rust and async Rust. Everything that isn’t implemented twice will be unavailable (without painful workarounds) on one or other paradigm. Not only are these two halves incompatible, but they’ll cause runtime problems when accidently mixed. ideally they should either just work or raise a compilation error.
This is all a shame since, on some level, async code is no different to ordinary blocking code. Whether blocking/IO/multi-threading are being handled in-process or by the kernel is something we should be able to abstract away. Luckily, PL theorists know the tool to do this.
An effect system
For the sake of simplicity, suppose the only IO operations we care about are
open
and read
. Consider the following psuedo-rust code.
effect Io {
fn open(&self, path: &Path) -> io::Result<RawFd>;
unsafe fn read(&self, fd: RawFd, buf: &mut [u8]) -> io::Result<usize>;
}
struct NativeIo;
handler NativeIo for Io yields ! {
fn open(&self, path: &Path) -> io::Result<RawFd> {
libc::open(...)
}
unsafe fn read(fd: RawFd, buf: &mut [u8]) -> io::Result<usize> {
libc::read(...) // do a blocking read
}
}
struct TokioIo {
poll: mio::Poll,
}
enum Event {
Read,
}
handler TokioIo for Io yields (RawFd, Event) {
fn open(&self, path: &Path) -> io::Result<RawFd> {
let fd = libc::open(...);
set_non_blocking(fd);
...
}
unsafe fn read(fd: RawFd, buf: &mut [u8]) -> io::Result<usize> {
loop {
match libc::read(...) {
EWOULDBLOCK => yield (fd, Event::Read),
x => return ... ,
}
}
}
}
The above code introduces two new keywords: effect
and handler
.
Effects and handlers are a lot like traits and impls, but with two major
differences. The first is that handlers are allowed to yield
. Yielding saves
the state of execution (eg. by compiling the code as a state machine in the
first place), and returns control back up the stack. The second difference is in how
they’re used. An example of invoking this effect could look like this:
#[sticky(Io)] // can't be moved between Io effect contexts
struct File {
fd: RawFd,
}
impl File {
pub fn open(path: &OsStr) -> io::Result<File>
effect Io {
Io::open(path)
}
pub fn read(&mut self, buf: &mut [u8]) -> io::Result<usize>
effect Io {
Io::read(self.fd, buf)
}
}
In order to use an effect, a function needs to be marked with that effect.
Implementation-wise, this effect is like a trait bound on a generic type
parameter, except the type is unnamed and is provided by the calling function.
In order to call a function with an effect, the caller either needs to be
marked with that effect as well, or it needs to use a do
block.
fn example_fn() -> io::Result<u32>
effect Io
{
let file = File::open("blah.rs")?;
file.read(...)?;
Ok(123)
}
fn run_event_loop() {
let tokio_runtime = TokioIo::new(...);
let mut generator: impl Generator<Yield=(RawFd, Event), ...> = do &tokio_runtime {
example_fn()
};
loop {
match generator.resume(()) {
Yielded((fd, event)) => {
// The coroutine needs to block. Register the IO event we're waiting for.
// No tokens coz this is example psuedo-code with only one coroutine.
tokio_runtime.poll.register(fd, event);
}
Complete(x) => {
println!("returned with {:?}", x);
},
}
// Block, waiting for the event.
tokio_runtime.poll.poll();
}
}
A do
block is how we contain effectful code. It takes a handler (in the above
case a TokioIo
instance) and returns the code as a Generator
implementor. If
the handler yields anything other than !
then the contained code is compiled
to a state machine so that it can be resumed. Any effects that occur inside the
contained code are implemented through the effect handler, rather than through
the effect handler of the containing code (if there was one). As such, we can
use a single File
type, and have it behave differently depending on whether
it’s used inside our TokioIo
event loop or not. Backwards-compatibility could
be handled by making effect Io
special and implicit (like Sized
), with
main
being executed under the NativeIo
handler.
Conclusion
It’s been a while since I read any effect systems papers and I’m making all this up as I go along. But if something like this is at all possible then I think it’d be much nicer than keeping async and blocking code seperate. It also generalises to other kinds of effects, eg. iterators and panic could be implemented on top of an effect system like this.
Thoughts?