Async and effect handlers

I think we could get around the virtual methods issue, firstly by attaching effects to types instead of methods. So File might look like (if Io wasn’t implicit):

struct File: effect Io {
    fd: RawFd,
}

impl File {
    fn read(&mut self, &mut [u8]) -> io::Result<usize>
    // no effect annotation
    {
        // can use Io because it's attached to the type
        Io::read(...)
    }
}

So, essentially, the effect becomes an extra generic parameter on the type instead of the method. Standalone functions could still be given effects though. It would also have to be illegal to call virtual methods while in an effect context that needs to yield and resume, because it would be impossible to build the required state machine without knowing what method impl you’re going to be calling (and thus what state you need and how much memory you need for it). There’d also have to be the same restriction on recursive functions generally. Having a way to explicitly allow a generator to allocate memory could allow the user to lift these restrictions though.

fn eff_iter<T, F>(f: F) -> EffIterator<impl Generator<Yield = T, Resume = (), Return = ()>>
where F: FnOnce() effect Iter<T> { ... }

I'm trying to understand the interaction with closures here. Am I correct that the effect Iter<T> is part of the bound on F? (I initially thought the effect Iter<T> was still an annotation on eff_iter, which I later realized didn't make sense.) This seems to imply that F is a higher-kinded type parameter, which would seem to require your later suggestion that effects would have to be able to be applied to types.

It also seems like this would mean that using asynchronous io would have a somewhat broader footprint. With async/await, only the new async functions are treated as asynchronous when called in asynchronous contexts. If effect Io is a default effect, then existing functions will be treated as asynchronous as well. For functions that don't do any io, I supposed these could be inferred not to have effect Io, though if they do virtual dispatch, that would have to be treated as potentially effectful. Is there any potential that interpreting a presently correct synchronous function as asynchronous could break it?

Yeah... as it's written it would require higher-kinded types. Since we know that function uses the For handler though, it could be re-written as something like this maybe:

fn eff_iter<T, F>(f: F) -> EffIterator<impl Generator<Yield = T, Resume = (), Return = ()>>
where F: FnOnce(),
      F effect Iter<T>=For

I'm not quite sure what you're saying here. All functions that could potentially do Io would need to be (potentially) compiled twice. Or rather, they're secretly a generic function which can be compiled with different Io type parameters. If we know they don't actually do any Io then they could be compiled just once and monomorphisation would turn my_func<Io=Native> and my_func<Io=Async> into the same function. If they call any function that may need to do Io though then they'll need to be compiled into a state machine when built with Io=Async.

Virtual dispatch also requires dynamic memory allocation. In the RFC I've half-written, using virtual dispatch is a compilation error unless you explicitly opt-in to giving your generator a heap-allocated stack.

Is there any potential that interpreting a presently correct synchronous function as asynchronous could break it?

Well we can't possibly make the Io effect capture all potential kinds of blocking, so there'll be some libraries that will lock up your event loop if used in an async context. That's no worse than the status-quo though. Or did you mean something else?

How does this work if you can already call virtual methods which could potentially do Io? It seems like trait objects would have to default to effect Io=NativeIo.

I’m getting confused as well, so let’s try and put this in more concrete terms: An effect is like a trait, handlers are types which implement these traits. Something is effectful if it has a type parameter bound by that effect/trait. A function which invokes an effect needs a run-time reference to the effect handler. For now let’s assume there are no implicit effects.

We have:

effect Io {
    fn blah(&self);
}
==>
trait Io {
    fn blah(&self);
}


handler Async for Io yields A -> B {
    fn blah(&self) { .. }
}
==>
impl Effect for Async {
    type Yield = A;
    type Resume = B;
}
impl Io for Async {
    fn blah(&self) { .. }
}


struct MyType effect Io { .. }
==>
struct MyType<H: Io> { .. }


trait MyTrait effect Io {
    fn wub();
    fn zoom() effect Alloc;
}
==>
trait MyTrait<H: Io> {
    fn wub(io: &H);
    fn zoom<A: Alloc>(io: &H, alloc: &A);
}


fn my_func() effect Io {
    Io::blah();
}
==>
fn my_func<H: Io>(io: &H) {
    io.blah()
}


impl MyTrait for MyType {
    fn wub() { .. }
    fn zoom() effect Alloc { .. }
}
==>
impl<H: Io> MyTrait<H> for MyType<H> {
    fn wub(io: &Io) { .. }
    fn zoom<A: Alloc>(io: &H, alloc: &A) { .. }
}


trait MyEffectlessTrait {
    fn smash();
}
impl MyEffectlessTrait for MyType {
    fn smash() { .. }
}
==>
impl<H: Io> MyEffectlessTrait for MyType<H> {
    fn smash() { .. }
}


impl MyTrait for MyEffectlessType { .. }
==>
impl<H: Io> MyTrait<H> for MyEffectlessType {
    fn wub(io: &H) { .. }
    fn zoom<A: Alloc>(io: &H, alloc: &A) { .. }
}


impl MyType {
    fn bar() effect Alloc { .. }
}
==>
impl<H: Io> MyType<H> {
    fn bar<A: Alloc>(io: &H, alloc: &A) { .. }
}

So, in order to have a trait method impl use an effect, the effect needs to be on the trait, not (necessarily) the type. Otherwise we don’t have the required argument for the handler. This means we could have a trait object of type Read<Io=Async> and, so long as we’re in an Async context (have io: &Async in scope) we can call methods on it. Does this model make sense?

2 Likes

Okay, so my understanding of this is that Read<Io=Async> would require explicit allocation to call its methods while Read<Io=NativeIo> (or any effect yielding !) could have its methods called in any effect Io=NativeIo context. Is this right? That seems like it could work, though it would mean that the default effect for code using objects would need to be effect Io=NativeIo rather than being generic over Io. This seems reasonable enough. It would mean that less code would work in async contexts by default, though.

@steven099 Any effect yielding ! or resuming !, but otherwise yes. I don’t think we need to have any notion of default effect handlers though, just that any code which requires a stack has to be called with a handler which allows it to have a stack, or with an explicitly heap-allocated stack.

This topic was automatically closed 90 days after the last reply. New replies are no longer allowed.