Skip to content
Permalink
Branch: effects
Find file Copy path
Fetching contributors…
Cannot retrieve contributors at this time
280 lines (214 sloc) 9.31 KB
  • Feature Name: effects
  • Start Date: (fill me in with today's date, YYYY-MM-DD)
  • RFC PR: (leave this empty)
  • Rust Issue: (leave this empty)

Summary

This RFC describes a system for user-defined effects and handlers.

An effects system would allow Rust code to be generic over the implementation of global runtime subsystems such as IO, memory allocation and panic handling.

A fair warning: this RFC has a lot of details to be worked out. It also introduces several new keywords, new kinds of items and new flow-control constructs. Considering the scale of changes it introduces I'm hesitant to even propose it. However, given the wide scope of problems that it aims to solve I think it's at least worth considering.

Motivation

The effect system described in this RFC allows the programmer to specify the interface of a global runtime, then swap out different implementations of that runtime in different parts of their code. A runtime implementation, or "effect handler" as they'll be called from here on, can also control whether code gets compiled as an ordinary function or into a generator-style state machine.

A non-exhaustive list of usages for this system follows:

Runtime-agnostic IO code

The introduction of tokio and futures threatens to split the Rust ecosystem into two sub-languages: blocking Rust and async Rust. Libraries written for one are not directly portable to the other, requiring them to either be wrapped or reimplemented. This is a shame since where the event loop is running, either in-process or in the kernel, is the kind of implementation detail we should be able to abstract-out. The effects system proposed in this RFC allows exactly this.

With this RFC, IO code can be written once and compiled to run on either the tokio event loop, the kernel event loop (ie. using blocking system calls), or any other event loop that someone may want to implement. This deprecates the #[async] macro by providing the same functionality directly in the language.

Allocator-agnostic code

RFC #1398 describes an Allocator trait to give the programmer control over what memory allocator they're using. One drawback of this RFC, is that it requires extra type parameters to be added all over the place in order for code to be allocator-generic. There's also no way to ensure that a region of code uses the allocator it's intended to (eg. there that isn't code buried somewhere which is defaulting to the wrong allocator), nor is there any way to express that a function doesn't perform allocation, and have this enforced by the compiler.

With this RFC, the Allocator trait described in #1398 could instead be implemented as an effect. It would have (almost) the same API, but would not have these problems.

Panic-agnostic code

It may be useful to give the programmer more control over how panics are handled in different regions of code, perhaps allowing unwinding in some parts but aborting in others. It would also sometimes be useful to be able to disallow panicking in parts of our code and have this enforced by the compiler (such as in extern "C" functions).

This RFC enables these changes by allowing panicking to be implemented via a Panic effect which can be controlled.

More powerful coroutines

Rust already has experimental support for coroutines on nightly via the Generator trait. These coroutines have certain limitations though, for example they don't allow yielding from inside functions or allow nesting generators.

This RFC replaces the current coroutines with a more powerful construct which alleviates these limitations.

Guide-level explanation

effect and handler

First let's look at the way the effect system can be used to provide different implementations of a global subsystem. We'll take random number generation as our example. A simple random-number-generation API might look like this:

effect Random {
    fn random_byte() -> u8;
}

And an implementation might look like this:

struct SystemRandom;

handler Random for SystemRandom {
    fn random_byte() -> u8 {
        let mut buffer = [0u8];
        let mut f = File::open("/dev/urandom").unwrap();
        f.read(&buffer[..]).unwrap();
        buffer[0]
    }
}

However this isn't the only possible implementation. In tests, for example, we might want random numbers to be generated by a predictable algorithm so that our tests are reproducible. Here's another implementation which can provide this:

struct TestingRandom;

handler Random for TestingRandom {
    fn random_byte() -> u8 {
        // Chosen by fair dice roll. Guaranteed to be random.
        4
    }
}

In our code, we can use whichever Random handler is present to generate random bytes, but we can also swap out the current random handler for a different one within some scope by creating a generator.

let my_byte = Random::random_byte();

let my_handler = TestingRandom;
let my_generator = generator<Random = my_handler> {
    let my_other_byte = Random::random_byte();
}

In the above code the generator block will use my_handler (a TestingRandom instance) to generate random bytes, instead of whatever Random instance was present outside of the block.

sleep example

Another thing effects do is control coroutining between the generator and the code executing it.

For a simple example, consider an Io effect which defines a single operation

  • pausing execution for some period of time.
effect Io {
    fn sleep(duration: Duration);
}

There are at least two ways to implement sleeping: We can block the current thread, or we can suspend execution, return to an event loop and tell the event loop how long to wait before resuming the task. These two implementations can be provided by two different handlers.

struct Blocking;
struct Tokio;

handler Io for Blocking {
    fn sleep(duration: Duration) {
        libc::pthread_sleep(duration.as_millis());
    }
}

handler Io for Tokio yields Duration {
    fn sleep(duration: Duration) {
        yield duration;
    }
}

This says that the Tokio event loop expects tasks to yield with a Duration indicating the length of time they should sleep for (and that there are no other IO operations the event loop provides).

We can then write sleepy code which is generic over the particular implementation used.

fn my_sleeping_fn() {
    Io::sleep(Duration::from_secs(1));
}

In this code snippet, Io::sleep will resolve to whatever Io handler is ambiently present, whether it be a Blocking or a Tokio, and call its sleep method. In the case of Tokio it will yield back to whereever in the code the Tokio Io runtime was instantiated and allow the code to be resumed later.

let g = generator<Io = Tokio> {
    my_sleeping_fn();
}

match g.start() {
    GeneratorResult::Yield(duration) => {
        println!("let's resume g in {}", duration);
    },
    _ => unreachable!(),
}

// some time later

g.resume(());

In the above example we have handler Io for Tokio yields Duration { .. }. The yields Duration here indicates that any method of the handler can yield a Duration back to the caller of Generator::{start,resume}.

We can also specify a resume type, as we do in this expanded example. For this example, lets extend the Io effect with file IO.

effect Io {
    fn read(fd: RawFd, buffer: &mut [u8]) -> io::Result<usize>;
    fn write(fd: RawFd, buffer: &[u8]) -> io::Result<usize>;
}

handler Io for Tokio yields Vec<(RawFd, Ready)> -> (RawFd, Ready) {
    fn read(fd: RawFd, buffer: &mut [u8]) -> io::Result<usize> {
        yield vec![(fd, Ready::readable())]
    }

    fn write(fd: RawFd, buffer: &[u8]) -> io::Result<usize> {
        yield vec![(fd, Ready::writable())]
    }
}

If a generator is created using this Tokio handler for Io, it will yield Vec<(RawFd, Ready)> and will be passed a (RawFd, Ready) when being resumed. After adding select, this sort of thing could be used to implement an event loop (though for efficiency you probably wouldn't design it quite like this).

Reference-level explanation

An effect definition is identical to a trait definition except that it uses the keyword effect. A handler definition is identical to a trait impl except that it specifies a yield and resume type. These types describe an interface for pausing and resuming effectful code.

For every defined effect there is a global stack of handlers. A handler is an object of some type which implements the effect. You can use the name of the effect to refer to the handler at the top of the stack. eg. Io::sleep calls the sleep method on whatever object is living on the Io stack.

For any effect, the type of the handler acts like an invisible generic paramenter on all functions that make use of the effect. The argument for this paramenter is passed down based on context, eg. if we have

fn f() {
    g()
}

fn g() {
    Io::sleep(...);
}

Then the function instance f<Io = Tokio> will call g<Io = Tokio> and not something like g<Io = Blocking>.

A function instance can be compiled to either a regular LLVM function or a state machine depending on whether any of its effect handlers require the function instance to be resumable. eg. f<Io = Tokio> may be (at the LLVM-level) a function which returns a state-machine object, whereas f<Io = Blocking> would just be a regular function.

You can’t perform that action at this time.