Announcing genawaiter – use generators (yield) on stable Rust

genawaiter is a crate that lets you use yield on stable Rust.

genawaiter = generator + await

Crate | Source | Docs

Generators are a common feature in other languages, and very useful. I've been missing them in stable Rust, so I decided to do something about it. I think the result could be broadly useful.

Here's the elevator pitch:

let generator = Gen::new(|co| async move {
    let mut n = 1;
    while n < 10 {
        // Suspend a function at any point with a value.
        co.yield_(n).await;
        n += 2;
    }
});

// Generators can be used as ordinary iterators.
for num in generator {
    println!("{}", num);
}

There's no assembly or platform-specific shenanigans under the hood. It's all built on top of async/await, and the implementation is pretty straightforward.

Give it a go and let me know what you think!

19 Likes

It seems your github repository is not public yet, you may want to fix that. (Fixed now)

Meanshile, I wonder if this code could be used as a stable-compatible playground for prototyping elusive generator resume arguments... after all, this co.yield_(n).await expression is begging for having a let resume_args = stuck in front of it :wink:

1 Like

It seems your github repository is not public yet, you may want to fix that.

Oops, thanks for the heads up! It wouldn't be much of a launch without any code.

resume arguments

I had a feeling someone might bring this up! I haven't explored the idea yet since I haven't needed that feature myself. But I do have an idea of how you'd go about it if anyone wants to play with the idea.

Another random API design thought: since co.yield_(output) must always be followed by .await and bad things will happen if this rule isn't followed, you may want to provide an API which does both of these.

Since .await is control flow, such an API cannot be function-based... but a simple declarative macro should allow expanding yield_!(co, output) to co.yield_(output).await.

With procedural macros, it might be possible to get one step further and actually check the other "no other futures are being awaited" property that you rely on... but that power would also come at the cost of higher code complexity and compile times.

2 Likes

Regarding the issue of forgetting to .await the co.yield_(output) future, couldn't you just add the #[must_use] attribute to yield_()?

1 Like

Adding #[must_use] sounds like a great idea, although it is only a warning.

This is fun to see, since I used to do the opposite in C#: I used to use AsyncIterator (based on yield return) before async/await existed.

1 Like

Well, as you probably know, this is exactly what's happened on unstable Rust: generators landed before async/await and were used as a building block of the latter...

But then some complicated edge cases were discovered, which led to the stabilization of generators being undefinitely delayed, all the while async/await was prioritized towards stabilization due to its higher importance to the networking ecosystem, to the point of eventually landing first.

As a result, we now have someone emulating stable generators by building on top of stable async/await, which is itself built on top of unstable generators... :upside_down_face:

10 Likes

This would probably be a good enough lint in practice. And in fact, IIRC futures are must_use already, in which case this crate already has this lint without any need to annotate yield_ itself.

However, must_use is imperfect here as it still allows wrong code like...

let f_a = co.yield_(a);
co.yield_(b).await;
f_a.await;

This crate's "yield" emulation is implemented by storing data in single-element shared storage and waiting for the generator's caller to fetch it. Instead, the above code will instantly overwrite "a" with "b" before waiting for the caller. Therefore, the sequence of generated outputs will be incorrect, missing "a".

What we really need for a 100% foolproof API here, is a way to guarantee that the future will be immediately awaited, which is stronger than what must_use provides. Hence my macro proposal.


Also, this crate implements Generator::resume() with a very simple future executor which is not capable of executing arbitrary futures, but only the ones produced by Co::yield_(). Therefore, there is a temptation to also prevent the user from awaiting any future but those, which is something that they could reasonably attempt otherwise (maybe successfully maybe not, depending on the Future impl), by grepping for "invalid" use of .await and reporting it as an error.

This cannot be done with declarative macros alone, but it could probably be done with a more sophisticated proc macro based design, which might also allow more radical syntax like this:

// NOTE: I did not actually try to write the macro, so I'm not sure if this works
#[generator(yield_ = i32)]  // Adds a "co: Co<i32>" parameter, makes this an async fn
fn odd_numbers_less_than_ten() {
    let mut n = 1;
    while n < 10 {
        yield_!(n);  // Translates to co.yield_(n).await
        n += 2;
    }
    /* arbitrary_other_future().await */  // #[generator] macro would reject this
}

This syntax would have the additional advantage of preventing the user from leaking the co variable out of the generator, thus eliminating the memory safety hole of this crate's stack-pinned generators (if I understood the problem correctly) and making them safe to use.


...and of course, the macro above could support generator resume arguments if you want them:

// Yields the sum of all Some() resume args passed so far, terminates on None
#[generator(resume_args = Option<i32>, yield_ = i32)]
fn accumulator() {
    let mut n = 0;
    while let Some(resume_arg) = yield_!(n) {
        n += resume_arg;
    }
}

Note that @whatisaphone do not actually need to write the macro themselves, it can be implemented by a third party on top of the "genawaiter" crate (though resume arguments would probably work best with direct support from genawaiter). However, @whatisaphone may be interested in the possibility of providing a safe API to stack-pinned generators...

4 Likes

I tried to stay away from macros where possible, because I wanted to emphasive that this is "just Rust" and there is nothing magical going on. And (imho) there's no reason you shouldn't be able to pass co to another function. This code is fine:

async fn parent(co: Co<i32>) {
    child(co).await;
}

async fn child(co: Co<i32>) {
    co.yield_(1).await;
}

Gen::new(parent);

This pattern has analogues in other languages too – yield! in F#, yield from in Python and yield* in JS.


The one pitfall I was worried about was forgetting to tag on the .await. But as @HadrienG mentioned, Futures already have must_use, so that was covered for free. (If it's good enough for Result, it's good enough for me!)

I think right now awaiting a foreign future would just panic immediately, since it would try to unwrap a None (but I haven't tested to be sure). I think panicking is fine for that since it falls under "gross misuse of the API". Similarly, yielding twice without awaiting (@HadrienG's first snippet) should probably also panic instead of eating values, at a minimum. I will play around with ways to misuse the API and maybe add a few more test cases.


I think the (stack-only) memory safety hole is theoretically possible to solve without adding a proc macro. If I could give co a reference type, the borrow checker would ensure that co could not leak. Then the stack-based generators would also be safe (fingers crossed).

// Note the `&`
async fn generator(co: &Co<i32>) { /* ... */ }

But my Rust-fu was not strong enough to work out the type of Gen::new for this function. Here's where I hit the wall.

3 Likes

I just released v0.2.0, with several improvements:

  • Better panic messages, based on the discussion above (thanks everyone!)
  • Improved safety for the allocation-free generators. co can no longer escape all the way to 'static. It's not fully safe yet, but it's much better.
  • Last but not least, resume arguments! I've introduced Coroutine as a generalization of Generator, along with a resume_with method. This slotted very nicely into the existing design.

Additionally, the docs now encourage what I'm calling async closures async fauxΒ·sures (pronounced foe-sures). Today, async closures are nightly-only. But you can use a normal closure which returns an async block to emulate them on stable, today. Just shuffle the keywords around a bit:

// The first line is nightly-only. The second line also works on stable:
Gen::new(async move |co| { /* ... */ });
Gen::new(|co| async move { /* ... */ });

Supplies limited, get it while it lasts

11 Likes

[Meta] I for one appreciate the puns: genawaiter (generator) and faux-sures (closures). Levity helps.

13 Likes

Ladies and gents, we've made it. Today's v0.2.1 sports two major new features:

  • An allocating generator, sync::Gen, which is thread-safe and can be stored in a static and shared between threads.
  • A macro, generator_mut!, which at long last brings complete safety to the allocation-free implementation! (All unsafe blocks are commented, and if anyone is up for a second opinion/audit, it would be warmly appreciated.)

Now that we have safety without allocation, I consider the core of the library feature-complete as far as accomplishing my original goal, so I don't anticipate any future API churn, and I'll stop spamming this forum with updates.

There is perhaps more to come, maybe experiments around async Streams and coexisting with "real" async code, but that will take place over at the repo on GitHub.

Before I go, I'd just like to say that I hope that Rust's native generators feature is stabilized soon, and that all my work becomes obsolete. The sooner, the better :slightly_smiling_face:

Thanks everyone for the great discussion, feedback, stars, and fish!

8 Likes

Just wanted to give back some thanks here. I've been using genawaiter while prototyping a lock-free bitmap allocation algorithm, for the purpose of decoupling hole search from allocation attempts without separating the two tasks in separate structs right away, and it's been very helpful for that purpose.

4 Likes

That's awesome to hear, thanks for reporting back! I'm glad you're finding it useful :smile:

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