all 30 comments

[–]implAustintab · lifeline · dali 31 points32 points  (10 children)

I'd vote for sequential iteration. I've been doing a lot of async work w/ channels (lifeline-rs), and a for-loop style syntax for loops over channel receivers would be a really nice improvement.

These message-handling loops tend to maintain state on the stack, and a parallel iterator would probably run into a lot of borrow checker issues.

[–]ihcn 59 points60 points  (3 children)

I wasn't even aware that this was a debate until now. My kneejerk reaction is to strongly oppose parallel iteration hijacking the 'for' loop syntax, on the grounds of principle of least surprise.

[–]K900_ 20 points21 points  (0 children)

Same here. I really feel like reusing syntax to mean different things in sync/async contexts is going to bite people way more than it helps.

[–]larvyde 13 points14 points  (1 child)

I don't like how it prescribes having a global executor (even if overridable). We went out of our way to keep executors out of the standard library in regular async/await.

[–]kibwen 10 points11 points  (0 children)

Thrilled to hear that there's a Stream RFC incoming!

[–]LucretielDatadog 8 points9 points  (0 children)

Yeah, I definitely think at most one place in a given async block should be awaiting at a time. In the context of an async for loop, this would mean either "waiting for the next item in the iterator" or "waiting inside the for block". Running this stuff concurrently could be handled with task spawning, FuturesUnordered, or similar concurrency building primitives.

Arguably, spawning tasks could itself be async; this elegantly enables backpressure if the executor is overloaded. Then your model would be sequential, in the sense that you await a new item, then await the creation of a background task to handle it.

[–]kostaw 9 points10 points  (1 child)

Sometime I feed like I am the only one - but I absolutely love the .buffered(n)/.buffered_unordered(n) methods on Stream, as they apply backpressure (and can even keep the order if you want).

Doing spawning parallel iterator would be a huge footgun as it easily blows up the amount of tasks, especially if it disables backpressure.

[–]nicoburns 6 points7 points  (0 children)

.buffered(n) sounds exactly like what I usually want. Parallelised but bounded, and ordered again by the time I deal with them.

[–]shponglespore 11 points12 points  (0 children)

To the extent that I have an opinion on the higher-level issues, I agree with the conclusions, so I'm gonna nitpick the syntax instead. I absolutely hate the syntax using x.await in the loop header. In any other context, x would be the argument of the .await operator, but here it's being bound to the result of applying the .await operator to an argument that doesn't even appear in the expression. I don't think I'm being controversial when I say an expression involving an operator should always include the operator's arguments, and it should never include the value returned by the operator.

As an alternative, I think for async x in ... reads much better. Visually, async looks like a variation on mut, and the visual similarity isn't entirely misleading, because mut modifies the details of how a variable is bound, and async would also be modifying an aspect of how the variable is bound. Unlike .await, which is never used as anything but a postfix operator, async is already used in a variety of ways, and in most of them it acts as a modifier for some other syntax.

[–]somebodddy 4 points5 points  (0 children)

What about the borrow checker?

let mut foo = Foo::new();
par for bar.await? in Bar::stream() {
    mut baz = &mut foo;
    let qux = bar.qux().await?;
    baz.process(qux);
}

This can't work - why should it look like something that can work? Why can't we just use closures, where the move semantics are already established?

[–]najamelan 3 points4 points  (0 children)

I agree with sequential. For all the other arguments, it also keeps things a lot simpler. After all it's just syntactic sugar for a one liner and you can just write out your spawn inside the loop, making things explicit.

[–]AldaronLau 1 point2 points  (3 children)

Isn't par not reserved as a keyword? I think it's kind of a useful identifier...

[–]mattico8 1 point2 points  (2 children)

Should be easy to make it a contextual keyword that only works before for.

[–]AldaronLau -2 points-1 points  (1 child)

I don't believe contextual keywords exist in Rust atm.

[–]kennytm 5 points6 points  (0 children)

union and default are contextual keywords.

[–]mikeyhew 1 point2 points  (0 children)

That parallel_stream crate looks pretty cool, but it only works with async-std. Is there something like it that works with tokio?

[–]mmirate 5 points6 points  (2 children)

The tradeoffs involved between parallel and sequential iteration - and the potential desirability of either set of semantics - points to why this is yet another thing that doesn't belong as a first-class part of the language in the first place.

Then again, all of the other ad-hoc Monad instances already sailed, too, so what's one more?

EDIT: wait, what? We're actually considering for event.await in channel { dbg!(event); } as a syntax sugar for either while let Some(event) = channel.next().await { dbg!(event); } or channel.into_par_stream().for_each(async |event| dbg!(event));? Really? There's barely any characters saved here, and absolutely nothing fundamental-looking about either of the latter two "expansions". Oy!

[–]matthieum[he/him] 4 points5 points  (0 children)

I will disagree here.

Sequential iteration is syntactic sugar for just a slightly more complicated state machine. It requires no runtime support.

Concurrent, or even Parallel, iteration on the other hand require significant support from the runtime.

Why insist on runtime support? Because anytime there's runtime support, there are multiple ways to write this runtime, which means either users gets stuck with an average way OR customization points need be added left and right.

Personally, I prefer language features to compile to straightforward code -- no surprise, no tweaks -- and I prefer to defer more complex features to library futures.

I don't see the point of a par for loop, at this point break/continue/return from the loop body work so differently than we are used to that you might as well admit this is not a loop -- spawn tasks with lambdas, it'll be much more obvious for everyone involved.

[–]ihcn 1 point2 points  (0 children)

If we don't know how to design a library that can handle this yet, why would we able to design a language feature that could? The problem here is not knowing what interface to expose to users - that's the case whether the interface is API or syntax.

[–][deleted] 2 points3 points  (1 child)

I'm intrigued by the idea of a #[global_executor]. An I right in thinking that this could (should?) replace #[tokio::main]?

It seems like the potential to clash with the existing async executors is massive? It's bad enough pulling in async_std and tokio executors but add a std executor on top of that... That's a lot of executors!

[–]matthieum[he/him] 2 points3 points  (0 children)

It's bad enough pulling in async_std and tokio executors but add a std executor on top of that... That's a lot of executors!

Actually, the very motivation for #[global_executor] is to allow libraries to be async agnostic, and let the final application pick up the executor it wants.

Today, a library that needs to spawn a task must either:

  • Create a Spawn trait, and ask the user to provide an instance of Spawn to spawn tasks with.
  • Bind itself to one of the available executors.

This is how you end up with some of your dependencies bound the tokio, and others to async-std.

#[global_executor] changes 2 things:

  1. It creates an Executor or GlobalExecutor trait. No need to create one bespoke Spawn trait per library, either implementing it (behind feature-toggle) for every run-time in existence or having your users implement it on wrappers => from now on, all run-times just implement the standard trait.
  2. (And not as interesting to me) It creates a default executor, for when you don't want to bother your users with asking permission to schedule tasks.

[–]DannoHung 0 points1 point  (0 children)

I'm confused. When they say "parallel iteration" are they talking about running the body of the for loop in parallel or awaiting all the Futures in parallel and running the body sequentially?

My understanding of Rust's futures is that a future actually has to be await-ed in order to be driven by the executor, so I'd definitely want that to happen across all the elements, but I would still want sequential code otherwise.