-
Notifications
You must be signed in to change notification settings - Fork 0
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
FnPinMut: fully generalized stackless semicoroutines #1
Conversation
I like it! It make a lot of sense outside of coroutines. My two questions so far:
|
Assuming it is |
The rule on specialization is that the standard library can't expose any specializations that aren't totally covered (i.e. the exposed implementations don't require specialization to exist). So either we put On that line, would it make sense to insert |
Right! I forgot about as_ref. I don't think FnPinMut fits into a linear hierarchy with the other Fns. Like, anything which implements Fn can implement FnOnce and FnPinMut. Anything which implements FnMut can implement FnOnce but not FnPinMut. Or does that mean it fits between Fn and FnMut? |
Also, I've been thinking a bit more about "coroutines as an extension to closures" idea but I don't want to drop in anywhere major yet. Like, any closure |
If we didn't have to worry about self captures at all, then I'd agree with I need to think more about the interaction of |
|
Yes, So I'm suggesting |
Note that we don't require a super-trait bound to use a trait. A coroutine desugars to // Semantically contains `PhantomPinned` when self-referencial
enum Coro1 { /* state_machine */ }
impl FnPinMut for Coro1 { ... }
// `Self: Unpin` is an implementation details but probably it is easier to implement in this way because otherwise we have to do a type checking at the desugaring stage. I don't know current generator implementation.
impl FnMut for Coro1 where Self: Unpin { ... } |
I've added a bit of intro to the explanation that covers yield closures that don't need |
(These restrictions may be lifted in the future.) | ||
|
||
An implementation of `FnPinMut` is provided for all closures where said implementation is sound. | ||
Practically, this means all currently stable `FnMut` closures, as they are `Unpin`, get the following implementation: |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
A closure can be !Unpin
by capturing a !Unpin
value:
use std::marker::{Unpin, PhantomPinned};
fn main() {
struct Pinned(PhantomPinned);
impl Pinned { fn method(&mut self) {} }
let mut pinned = Pinned(PhantomPinned);
let closure = move || pinned.method();
fn is_unpin(_: impl Unpin) {}
is_unpin(closure); // ERROR: the trait bound `std::marker::PhantomPinned: std::marker::Unpin` is not satisfied
}
}) | ||
``` | ||
|
||
This closure will implement all four `Fn` traits. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is it correct that this closure can implement Fn
?
A call modifies the state by iterating 1..6
. It needs a mutable reference.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The only type of yield closure that can implement Fn
is a yield closure with only 1 state. For example, the yield closure |x| loop { yield x + 1; }
could be Fn
because it is exactly identical to |x| x + 1
.
``` | ||
|
||
Closures are now allowed to contain **yield expression**s of the form `yield $expr`. | ||
If a closure contains a yield expression, it _may not_ also contain a return expression. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
My main concern with disallowing return
is that try blocks would then be required to have any kind of reasonable ?
story. I would mention in the alternatives section that return
could be supported through a desugar like return $x:expr => yield $x; unreachable!()
without fundamentally changing the behavior of coroutines.
}) | ||
``` | ||
|
||
This closure will implement all four `Fn` traits. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The only type of yield closure that can implement Fn
is a yield closure with only 1 state. For example, the yield closure |x| loop { yield x + 1; }
could be Fn
because it is exactly identical to |x| x + 1
.
Discuss prior art, both the good and the bad, in relation to this proposal. | ||
A few examples of what this can include are: | ||
|
||
- For language, library, cargo, tools, and compiler proposals: Does this feature exist in other programming languages and what experience have their community had? |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
On my Reddit post, someone brought up yield from
in python which allows one coroutine to delegate to another. Python's coroutines can signal that they have reached an end state by throwing StopIteration
. When that happens, any delegation ends and yield from
evaluates to the last yield
ed value. That actually means that return x
inside a python coroutine is the same as yield x; throw StopIteration
which looks a lot like our potential desugar for return
! The difference is that Python can then end the coroutine delegation while we would have no obvious way to do so (having gotten rid of GeneratorState
). Just an interesting validation + trade-off.
# Unresolved questions | ||
[unresolved-questions]: #unresolved-questions | ||
|
||
- Exact naming of `trait FnPinMut`. `FnPinMut` was chosen for this RFC as an |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
FnPin
could also be an option since (as y'all pointed out) Pin<&mut Self
is the only obviously useful way to take self as pinned.
More on that dumb "yield as a strict extension to closures" idea that I won't let die. We could specify that when any kind of closure (yield or otherwise) reaches the end of the block, it yields and will resume at the top. If there is only one state, the closure doesn't need a mutable state variable and can implement
coroutine { loop {
#[bind_input]
let x: usize; // not mutable
// state machine is always here
// x is bound here
yield x + 1;
} }
coroutine { loop {
#[bind_input]
let x: String;
// x is bound here
yield x + "Hello"; // move x
// x is shadowed here
yield x + "World"; // move x
} }
coroutine { loop {
#[bind_input]
let x: String;
// x is bound here
yield 0;
// x is shadowed here
yield 1; // both xs are dropped before execution is handed back to the caller
} } || {
if test() {
return 0;
}
yield 1;
2
} becomes coroutine { loop {
#[bind_input]
let _: ();
if test() {
yield 0;
continue;
}
yield 1;
yield 2;
} }
coroutine { loop {
#[bind_input]
let ctx: &mut Context;
yield Poll::Ready(3);
// logic error to poll again, could panic instead
} }
coroutine { loop {
#[bind_input]
let ctx: &mut Context;
match await!(func(), ctx) {
Ok(value) => value,
Err(error) => {
yield Poll::Ready(Err(error));
continue // logic error to poll again, could panic instead
},
}
yield Poll::Ready(Ok(3));
// logic error to poll again, could panic instead
} } |
I just realized some really cool things about the loop version:
|
Woah, there is something even better about end-with-a-bang. It is compatible with and strictly more conservative than other proposals! Because return is not allowed, we can stabilize this proposal and later decide if adding a
|
I like the idea loop {
let bomb = DropBomb::new();
return 1;
} The closure explodes the bomb before it yields the value let value = $expr;
$(drop($local_var);)*
yield value;
continue; Alternatively, something like Another point is about the // probably one state
loop {
process();
yield 1;
}
// one state?
loop {
if cond() {
yield 1;
} else {
yield 2;
}
}
// cannot be one state
loop {
let bomb = DropBomb::new();
yield 1;
// bomb explodes after resumed
} I find it is confusing that a coroutine loses |
I've also been thinking a lot about the drop and unit state cases. My current conclusion is that // cannot be one state
loop {
let bomb = DropBomb::new();
yield 1;
// bomb explodes after resumed
} This could actually be one state but that isn't clear because of the ambiguous syntax. If the user writes: || {
let bomb = DropBomb::new();
1 // or `return 1;`
} Then the compiler generates: coroutine loop {
let bomb = DropBomb::new();
yield_continue 1; // drop(bomb); yield 1; continue
} Which is clearly a unit state. If the user writes: || {
let bomb = DropBomb::new();
yield 1;
} Then the compiler will throw a type error because the yield type (u32) does not match the return type (unit). If the user writes: || {
let bomb = DropBomb::new();
yield 1;
return 2;
} Then the compiler generates: coroutine loop {
let bomb = DropBomb::new();
yield 1;
yield_continue 2;
} Which clearly has two states. So I don't think the rule is subtle at all. Every coroutine has the "empty state" (the state before the first resume). The only way to re-enter that state is with an implicit or explicit |
There's another option though, returning || loop {
yield 5;
} We could just say that this doesn't implement |
True. But if doesn't work in every case. For example, || {
some_action();
loop {
yield 5;
}
} This diverges but does require two states. I bet there are some interesting optimization opportunities in some diverging cases but I would feel odd having the type system aware of those. |
Summarizing trait implementationsA coroutine implements
All values yielded or returned in a coroutine must have the same type. The Yet another
|
More thought on
The current behavior is:
As it is possible to assert negative trait bound (implementation), adding I propose to choose (1). Firstly, it is always safe to implement Secondly, it also means all Thirdly, if (3) is chosen, there is a question of whether a coroutine with For (1.c), there is a concern about an implementation change affecting public API. For the choice of (a), (b) or (c), I have no strong opinion currently but my preference is (c). |
FWIW, I think |
Braindump incoming... Bind-at-yield, as I originally imagined it, is a footgun. Consider this code: " x".chars().for_each(|c| {
println!("begin");
while c.is_whitespace() {
yield;
}
println!("end");
}) It seems like it should print "begin" then "end" but it doesn't. Because " x".chars().for_each(|c| {
println!("begin");
if c.is_whitespace() {
loop {
yield;
if !c.is_whitespace() { break }
}
}
println!("end");
}) That sucks. Thankfully, mutate-at-yield fixes this problem! I originally believed that mutate-at-yield was not consistent with closures since it does not allow corouties to move their resume arguments but this is not actually the case. Rust allows move-and-reassign like this: let mut x = String::new();
loop {
let y = x;
do_stuff(y);
x = String::new();
} So the following coroutine would be valid under magic mutation: |x: String| {
let y = x;
do_stuff(y);
// x = ...
} With magic mutation, references to inputs can not live across |x| {
let y = &x;
yield;
dbg!(y);
} let mut x = ...;
let y = &x;
x = ...;
dbg!(y); However, this is allowed: |x| {
let x0 = x;
let y = &x0;
yield;
dbg!(y);
} I'm not sure if I have talked about this before but Note that Proposals for a dedicated generator syntax often say something like "you should eventually be able to write a single function that is both async and an iterator, or in other words a stream." The idea is that, by making async/await orthogonal to generators, the two will be composable at some point in the future. TBH, I have yet to see a concrete demonstration of that composability. In contrast, our proposal actually supports stream creation today, with no additional syntactic tricks: let mut bytes = open("some_path.bin"); // impl TryStream<Vec<u8>, Error>
|ctx: &mut Context| {
let mut buffer = Vec::new();
while let Some(chunk) = bytes.next().await {
// ? works because Poll<Option<Result<_>>>: Try
for byte in chunk? {
match byte {
0 => yield Poll::Ready(Some(Ok(buffer.split_off(0)))),
x => buffer.push(x),
}
}
}
yield Poll::Ready(Some(Ok(buffer)));
Poll::Ready(None)
} Note that the above doesn't even require a definition of |
fn parse_csv<E>(strs: impl TryStream<&str, E>): impl TryStream<Vec<String>, E> {
let csv_parser = |c: char| {
let mut line = Vec::new();
loop {
while c.is_whitespace() { yield None; }
let mut part = String::new();
while c != ',' && c != '\n' {
part += c;
yield None;
}
part.truncate(part.trim_end().len());
line.push(part);
if c == '\n' {
return Some(line);
} else {
yield None;
}
}
};
FnStream(move |&mut Context| {
while let Some(item) = strs.next().await {
for c in item?.chars() {
match csv_parser(c) {
Some(o) => yield Poll::Ready(Some(Ok(o))),
None => (),
}
}
}
Poll::Ready(None)
})
} |
I don't follow. Could you elaborate more?
I thought the same. This should be described in RFC for why "magic mutation" is "right". |
I now have some opinions on how we market/teach this feature. I'll probably even write a follow up to my blog post soon. In the mean time, I'm dropping some thoughts on the RFC text here so they aren't lost.
|
I don't really think the verbosity difference between
I do think this is a valid point! It is one reason I originally intended delegate/yield_from/await(...) to work only on But
I'm fine with having a
Again, I can't get 10 seconds into prototyping a pushdown parser or some kind of
I mean yes, but I don't think I understand what you mean. There are plenty of It could be useful for the implicit context to be available as an additional argument when delegating within an async block. I think that is the problem your code example is trying to solve? But that seems to me like an oddly specific case for coroutine delegation in general. Like, once we figure out how coroutine delegation ever works, we can look into ways of using it more ergonomically in async blocks (beyond the trivial case of old-fashioned I think we agree that delegation is a very important thing that coroutines need to be able to do. And I think we agree that Edit: Just for the sake of clarity going forward, the expansion of
This would make delegate a little less powerful than Python's |
Sorry it's taken me so long to respond. A plus is that it's given me more time to think on this. I think the main issue here is that when I think "generalized yield from" I think of something that works with a generalized coroutine, allowing delegation of the yield sequence to one called within. This version of delegation has nothing to do with await, and would cover iterators and streams. To the contrary, you seem to be focused on a more specific idea of generalized await, which would essentially just cover
Ah I didn't see that in
It doesn't have to be, but I think that Essentially, I agree with what @CAD97 said
However I don't think we should rely on assumptions about any particular function signature. Then your previous example becomes fn map_stream<X, Y>(input: impl Stream<Item=X>, func: FnMut(X) -> Y) -> impl Stream<Item=Y> {
(async move || {
while let Some(x) = stream.next().await {
yield Some(func(x));
}
None
}).into_stream()
} I think we're getting a little ahead of ourselves here, though. Streams aren't even in std yet, and are far from final IMO.
I don't think we should even worry about it in this proposal besides a small note about future opportunities.
Are you sure That would certainly be more meaningful in this example of yours: fn digits(radix: u32) -> impl FnPin(char) -> Fulfill<i32> {
let mut value = 0;
|c| {
match c.to_digit(radix) {
Some(d) => {
value *= radix;
value += d;
Partial
},
None => Complete(value),
}
}
}
fn integer(radix: u32) -> impl FnPin(char) -> Fulfill<i32> {
|c| {
let multiplier = match c {
'-' => {
yield Partial;
-1
},
_ => 1,
};
let value = delegate!(digits(radix), c); // or something like `digits(radix).yield(c)`
Complete(value as i32 * multiplier)
}
} This keeps things (IMO) orthogonal, more easily composable, more meaningful, and more understandable.
I meant if you want to cover iterators.
Here's how I'd write your example with an async yield closure and fn byte_stream<R: AsyncRead>(input: R) -> impl TryStream<Item=u8>
(async mut move || {
pin_mut!(input);
let mut buffer = [0u8; 1024];
loop {
let num = AsyncReadExt::read(input, &mut buffer).await?;
if num == 0 {
return None;
}
for &byte in buffer.iter().take(num) {
yield Some(Ok(byte));
}
}
}).into_stream()
}
I think we're on the same page, but I think that capability should be orthogonal to async/await. And that cases which aren't orthogonal (like AsyncRead and stuff) would be better handled by just using async/await instead. I think making await and Poll dual-purpose for both async stuff and synchronous stuff like your parser example would be super confusing for beginners just because it allows for await outside async contexts, not to mention the composability and other conceptual issues.
I think all of this is fine besides tl;dr
|
@pitaj Hmmm, your examples of However, we must understand that Anyway, if we trivially (move || async move {
while let Some(x) = stream.next().await {
yield Some(func(x));
}
None
}).into_stream() then it becomes clear that the closure adds a totally useless level of indirection and we should additionally simplify it to: (async move {
while let Some(x) = stream.next().await {
yield Some(func(x));
}
None
}).into_stream() So yield closures aren't even directly relevant here! Although they'd still exist under the async block sugar, it is a totally different ability to the user. Heck, they might be a totally independent RFC. I actually touched on a version of On top of that it is now clear that So, to get useful streams we must allow one of Edit: Note that the awesome You have convinced me that it was a mistake on my part to equate I still think delegation is an important part of this proposal and should not be left out of the discussion. However, I think non-poll cases (notably iterator delagation) are concisely solved without a sugar: || {
for x in iter_a {
yield Some(x);
}
for x in iter_b {
yield Some(x);
}
None
} Hence my focus on poll-like cases. But you could still change my mind on that. Edit: Generalized coroutine delegation actually makes the most sense in a universe where we do I've been drafting my version of this complete proposal at samsartor.com/coroutines-2. That isn't because I think this is settled (e.g. you literally just opened me up to the possibility of |
I don't see that implication at all. For a normal block, we only allow yield if it is in a closure context, because yield controls the behavior of the closure. For an async block, I don't see why anything would change besides wrapping the yielded values in Sure, yield closures will be the backing of async/await, but the async/await magic doesn't need to expose that to anyone.
Except
You're going to need to expand more on why having yield in async blocks inside closures necessitates having yield in async blocks generally.
👍
Hard disagree. I think delegation is something that will be hard to get right, and I think it's something that many people will disagree on. I think adding too much to this proposal, especially when it could be easily left out for future coverage, could drag down the RFC. We can provide notes about why it would be useful and that we have talked about it, but I think it's better handled in a separate RFC, since it would be essentially a new operator. tl;dr: Keep the RFC as light as possible while still meeting the necessary features. |
Imagine I construct a closure like: let x = aysnc || thing.await; and then call it twice: let a = x();
let b = x(); At this point, nothing has happened. Even though it looks like we might have resumed our closure twice, This trips up a lot of Rust users since it is tempting to think that: let data = async_function();
expensive_action();
data.await will add parallelism by running async_function in the background. After all, it is what JS and other async languages do. But it doesn't. So even if we add a yield point: let x = async || {
yield first.await;
second.await
} it is the same thing: let a = x();
let b = x();
// The body of x hasn't run yet. We can even drop it and then resume the body via a and b.
drop(x);
spawn(a);
spawn(b); So clearly that yield must apply to the returned let x = || async {
yield first.await;
second.await
} Now it is clear that It would be technically possible to go out go our way to forbid Am I making any sense? Edit: sorry about the typos, I am on a phone right now
I think it is fine being left out of an RFC. I just want to discuss it now in case it turns up new design considerations, which I think it has. |
I highly expect that
|
Edit: deleted 2 previous comments since I can make a more cohesive argument here at my PC
@CAD97 I'm not sure what else it would desugar to. Imagine if the closure takes arguments, something like: async |x| { yield x.await; ... } Now what happens? When should it be resumed with I think needing to pass both is a confusing mix of implicit/explicit arguments. It would be better as: |ctx, x| { yield poll!(x, ctx); .. } But if it takes one and then the other, you need some kind of type-level distinction. I don't think it is possible to first take The only other possibility is to construct a closure which takes You could require that the closure takes no arguments. But then what is the point of even using the closure syntax? It would be far less confusing to have something like: pollable! {
yield x.await;
...
} I like that a lot actually! I think it is certainly better than allowing
I agree. I personally don't think |
I disagree. I think the desugar of let x = async || {
yield first.await;
second.await
}; should be let x = || {
yield async { first.await }
async { second.await }
}; let a = x();
let b = x(); Would be identical to if you were to do let x1 = async || first.await;
let x2 = async || second.await;
let a = x1();
let b = x2(); Which makes it clear that What you said does make sense with the desugaring you envisioned. I think mine makes a little bit more sense and solves the issues you brought up.
from comment on the tracking issue:
It also luckily allows my desugar to be possible. |
would desugar as |x| {
yield async { x.await };
} So it yields a future which is resumed with Now for a more difficult example, one with internal state that each future mutates: let x = async || {
let mut i = 0;
loop {
yield something_async(&mut i).await;
}
}; desugars as let x = || {
let mut i = 0;
loop {
yield async { something_async(&mut i).await };
}
}; You could execute the futures out of order like so let a = x();
let b = x();
b.await;
a.await; which would cause out of order execution. I don't know if that's a problem. I wonder how streams handle this with |
I don't think that desugar is possible in general. Consider instead something like let x = async || {
yield something;
let s: Config = read_config();
yield do_op_a(&s).await;
yield do_op_b(&s).await;
loop { panic!("exhausted") }
} Borrowing across It's for that reason that I think that trait AsyncYieldClosure {
fn resume(Pin<&mut self>, ...args) -> impl '_ + Future;
} That is,
The resulting state machine would be flat, and something like entry (args) -> created, yield AsyncYieldFuture(&mut self)
created (&mut context) -> yielded_something, yield Done
yielded_something (args) -> working_on_a, yield AsyncYieldFuture(&mut self)
working_on_a(&mut context) -> working_on_a, yield Pending
working_on_a(&mut context) -> yielded_a, yield Done
yielded_a (args) -> working_on_b, yield AsyncYieldFuture(&mut self)
working_on_b(&mut context) -> working_on_b, yield Pending
working_on_b(&mut context) -> exhausted, yield Done
exhausted (args) -> exhausted, yield panic Trying to resume a state expecting to be polled like a future by giving it the semicoroutine's arguments, or vice versa, would cause it to enter a panicking state. |
@CAD97 I could be wrong, but that solution feels super overengineered to me. Like, the state machine given by the user is already flat. There is no need to flatten it with runtime checks if you don't try to split it into multiple potentially-independent regions via let x = mut |ctx| {
yield something;
let s: Config = read_config();
yield Ready(poll!(do_op_a(&s), ctx));
yield Ready(poll!(do_op_b(&s), ctx));
panic!("exhausted")
} which (thanks to the lack of additional resume arguments) could easily be sugared back to: let x = pollable! {
yield something;
let s: Config = read_config();
yield do_op_a(&s).await;
yield do_op_b(&s).await;
panic!("exhausted")
} which is totally free of borrow-checked return-value attachment, runtime panics (beyond the obvious), additional scheduler overhead, etc. all while being barely less verbose and significantly less magical. And additional resume arguments don't cause any problems. I guess I just don't see what we would gain from having a more sophisticated Is there some major advantage of an |
The reason is those extra arguments. The closure can take arguments, which should be supplied anew at each So the flat I'll add the argument here just to give us something to work from: let x = async |args: &Args| {
yield something(args);
let s: Config = read_config();
yield do_op_a(&s, args).await;
yield do_op_b(&s, args).await;
loop { panic!("exhausted") }
} Normal usage would look like let args: Args = Args::new();
let something = x(&args).await;
let op_a_res = x(&args).await;
let op_b_res = x(&args).await; This is exactly what normal usage would look like for a non- Once more, to be explicit: for In the case where it's an |
The state machine doesn't have to be flattened at all, that's just an interesting (maybe) optimization. Not doing the flattening might be better, as you can avoid the cross-contamination of states that require the runtime checking. The returned " |
@CAD97 That is valid. However, I think that non-trivial async closures should absolutely not be a part of this proposal even if they are feasible. I think I can explain where I am coming from here. In the async interviews boats said something along the lines of:
I've thought about that quote a lot and in the last few days I've become sure this proposal proves him wrong: suspended functions can actually be really simple. In fact, they are so incredibly dumb that our proposal should really be able to stand on its own without any asyncyness at all. On the other hand, "create[ing] something that lets you write iterators and streams” is really where the design space levels the block. The moment you map suspendable functions onto higher level concepts, the number of possible syntax sugars absolutely explodes. And it is largely because those sugars are really interesting to think about! I myself am guilty of being tempted away from the true path by But the brilliance of yield closures, the reason why this proposal is so appealing to me, is that all the sugar in the world is effectively irrelevant. Seize the means of suspension and the proletariat can thoroughly explore the design space as members of the ecosystem (e.g. async_stream which presently uses a capacity=1 channel and await as a replacement for yield) or privately in their own projects! Or maybe I'm just bitter and won't let you include async yield closures when I don't get to include generalized await ;) |
Definitely agree here. All of this should be relegated to future extensions. It's somewhat important to show that it is extendable to support these things, but really the core (and the important part) is The first RFC really should completely ignore |
Before I get into anything
Absolutely agree. We can just disallow
This looks less difficult than the example I gave in which there's a mutable reference. Why can't this case desugar as the following? let x = || {
yield async { something };
let s: Config = read_config();
yield async { do_op_a(&s).await };
yield async { do_op_b(&s).await };
loop { panic!("exhausted") }
} I think we could guarantee you can't call Rust actually already takes care of execution order for mutable references: playground I do think treating async-yield-closures as a special case is probably a better way of handling it, though. Even if not purely necessary. It would as you've pointed out allow for a direct Stream impl without the intermediate "sequence of Futures" step.
I think this can be covered by the type system in most if not all cases by holding a mutable reference.
In addition to what @CAD97 pointed out, specifying the return value without |
You could always just drop the future without polling it, as you've noted. I think the only reasonable behavior there is for it to panic, honestly.
Ah, right, yes, you do need to make sure it's a mutable borrow being held to enforce linearity, or to actually move the value being borrowed. |
Good point, I agree. We could make the future drop impl place the yield closure in a poisoned state, and any later call or future would run into that. Orrrrr... we do it the way streams do, where, since the future wasn't executed, the next call returns an identical future. Use the mutable reference to tell rust not to let us have two futures from the async-yield-closure at once, and this could work. Only issue is what to do if a future is partially executed, I haven't experimented to see how streams handle that. |
@CAD97 @pcpthm @pitaj The language team is currently implementing a different Pre-RFC process which begins with an MCP in the lang-team repo and then further discussion in Zulip. I think it is actually fantastic chance to get this out in the world and get people discussing it! So I've written up an MCP for us to submit. Give me some feedback and then let's get this thing shipped! |
I think this needs to be I'm personally in favor of yield closures poisoning after a |
The MCP document is pretty great! I'm impressed by how it has well-motivated examples and still concise. Thanks for writing this. I didn't find any issues in the document. It looks good as a major change proposal. I think it's time for this proposal to reach the eyes of more people. |
Two more small nits: pub fn read_to_stream(read: impl AsyncRead) -> impl TrySteam<Item=u8> { That's probably supposed to return Also, you use |
Summary and problem statement One of the main benefits of this proposal is the flexibility of the behavior, especially in how well it lends itself to being built upon. For instance, withoutboats's propane could be built upon this even more simply than with the current unstable generator implementation. I'd like to see this aspect present in the summary. I think it should be made clear that for this version of coroutines, the yield type and return type are one in the same. Motivation, use-cases, and solution sketches For the read_to_stream example, you may want to specify Does "Once Closures" refer to existing FnOnce or the idea of a closure which does not loop? If the second, the naming is a little confusing, but I can't think of a better name besides "Mut Closures" which I don't particularly like either. Under "Once Closures", you may want to make it more evident that we prefer As for solution sketches, we'll probably want to throw up some desugared examples and work out the exact details a little better. (I'm not very familiar with the term, so I may be totally off here) Links and related work May want to mention Propane and of course the original coroutines eRFC |
Good call. I'll drop a mention of propane in next to async_stream and add a link to the eRFC.
Yah I know. But I couldn't think of a better title either, so I'm just counting on the explanation below to clarify.
I'll revisit this phrasing a bit but the point of the MCP really is to create further discussion, not provide a complete solution. Especially since CAD97 is on the other side of this! Either way I'm stealing myself to battle opt-in vs default poison in Zulip later.
I'm intentionally leaving the semantics just a little ambiguous. People can always ask questions in the Zulip stream or come look at the comments here if they feel lost. The MCP template starts with "Describe the problem(s) this group is trying to solve as concisely as you can" and I'm really trying to stick to that. Once I finish making those last changes, I'll go ahead and post this up. See you all there! |
@CAD97 @pcpthm @pitaj Our MCP got some good reception! Alas, the lang team currently has higher priorities. I put together some design notes to help with work on this feature in the future. The notes don't have to be super high quality but I'd appreciate it if you all sanity checked them a bit. The PR is open here. |
It's probably worth noting in there somewhere that what you're calling coroutines is formally a "semicoroutine". The difference is that a "semicoroutine" only returns to one place, its caller; whereas a "full coroutine" specifies another coroutine to continue from when it yields. The distinction probably doesn't matter too much, but for people who know the difference, it'd be nice to clarify that the proposal described there only returns to its caller rather than being the "fully explicit cooperative multitasking" that full coroutines describe. The wikipedia page is actually uncharacteristically clear in regards to this topic: https://en.wikipedia.org/wiki/Coroutine (though the list of languages with support for coroutines seems to be in somewhat of a disarray, as I'm pretty sure most of them only have semicoroutines/generator syntax...) |
Closing this now that https://lang-team.rust-lang.org/design_notes/general_coroutines.html is available. I do hope sometime in the future a design along these lines is taken, but the design notes capture what is covered here. |
cc @samsartor
Rendered
I think the building blocks for putting this together as a proper proposal are here; let's put them together!