How often do you want non-send futures?

I understood as much, but if you have a codebase that is completely uninterested in the Send property, you’d have to write async(?Send) everywhere. Now, I’m no stranger to boilerplate and don’t particularly have a huge problem with that , but I can definitely see how that would annoy some folks.

In the end, it makes the property less hidden though, even if the default is not visible. It has a nice symmetry to ?Sized, IMHO.

2 Likes

This is true (though only in the parts of your code that actually use !Send state). I personally don't think this is such a huge annotation overhead, but I don't think it is impossible for any reason to write a proc macro that visits every async keyword and modifies it to async(?Send).

1 Like

Thanks for emphasizing that -- I also was thinking last night that there may have been some confusion on this part. I tend to agree with you that:

  • Clearly ?Send is an important use case, not to be overlooked;
  • But I suspect that Send is more important and closer to the correct default.

In general, I think Rust is oriented to steer you towards writing "parallel-safe code by default" -- and this is one of its strengths. Obviously the borrow checker has this effect in general -- and it's one of the reasons we don't support a &readonly sort of thing (there are others too). It's also the motivation behind auto traits in the first place (i.e., that diverging from thread-safety in a library is unidiomatic and unlikely, so it's ok to implicitly assume people meant to be thread-safe).

Still, we've managed to steer that line without making it an undue burden to "lock yourself" into a single thread when desired (e.g., using Rc is easy). This seems to be the sticking point here.

9 Likes

It doesn’t seem unreasonable to default to Send. That then raises a question of “what should a non-default async function look like”. We want to make that experience relatively painless, if possible.

1 Like

I don’t think async(Send) vs. async(?Send) is just an issue of picking the good default. It seems to me also a question of user and compiler complexity. Both of these add some measure of complexity but in differing amounts. It seems to me that the former is conceptually simpler because we can understand it as async('ln + Bm) fn foo() -> R being transformed to fn foo() -> impl 'ln + Bm + Future<Output = R>. Meanwhile, ?Send introduces an unbound like ?Sized and does so in what appears to be an inconsistent manner from a users POV. The unbound ?Sized is a contextual unbound as well and I think a non-trivial amount of users struggle with understanding it. Moreover, I think an unbound is more complicated in terms of specification and compiler complexity also.

Overall I do think we should also consider the amount of ad-hoc rules and complexity we are willing to afford async fn, a somewhat a domain-specific feature, inside of a general purpose language. To that end, it would be good to fully consider a solution based on naming the output type of a function and bounding that by Send. On initial inspection it seems to me strictly more powerful than async(Send) if albeit more verbose.

12 Likes

Auto-traits are already special, especially since you can impl !AutoTrait. I don’t think adding a ?AutoTrait to async bounds would be incongruent.

That said, I still believe RPIT and unspecialized async fn should behave similarly.

I’m curious; how do you think auto-trait inference from parameters compares to implementation leakage for auto-traits from a specification standpoint, @Centril? I personally like the locality of it, and it’s fairly simple (from the user pov) to opt out in the correct direction with + AutoTrait/+ ?AutoTrait when confronted with a (local!) compiler error.

I just wish that a migration path for that behavior was tenable.

1 Like

Considered in isolation, I agree that additive bounds feel simpler to me. I don't think the scenarios are precisely comparable, but I do feel hesitant to add ?Send bounds into the language. It feels consistent with Rust in some ways ("parallelizable by default") but inconsistent in others (e.g., other impl traits and associated types of traits don't have this property -- though also not directly comparable, since async fn desugars to impl traits).

One important point is that I think people will want to write traits that are ?Send in either case: e.g., as discussed in this thread, tokio supports both use cases, and presumably it will continue to want to do so. This implies that we will ultimately want some way to write the bound that can be put onto the "spawn" function, and we would like it to be relatively ergonomic.

Another question I've been pondering is how much this will impact framework authors vs ordinary users. It seems pretty likely though that this will impact both. It seems easy to imagine people wanting to use ad-hoc traits to abstract over things in random "client code" -- and some of those bits of code will want to spawn and create futures.

If we were going to write a bound that bounds the output future type of an async fn, what would it look like? Recall that such types are also heavily parameterized. So if you had

trait Process {
    async fn process(&self);
}

that "desugars" to a GAT like:

trait Process {
    type ProcessResult<'a>: Future + 'a;
    async fn process(&self) -> Self::ProcessResult<'_>;
}

which means that the bound (if written in terms of that desugared form) is probably something like for<'a> T::ProcessResult<'a>: Send. Not great. We would definitely want some nicer way to write that (but we will want this anyway).

1 Like

I do think a fundamental difference here between special things like auto trait leakage and impl !AutoTrait is that while they are ad-hoc, they are still general in the sense that they are dealing with a category of traits on equal footing rather than one specific auto trait (Send in this case).

From a specification POV I think leakage is something we already have, so the cost has already been paid. Adding anything new would add new costs. As for auto trait inference from parameters it does not seem awfully complex to specify but I also don't have any typing rules to work with as a reference so I'm speculating a lot by saying it is not awfully complex.

Possibly some sort of outputof operator or typeof(self.process()): Send?

(but not written that way because ProcessResult is not a name the user has access to)

These points have occurred to me as well, but seem outweighed by other considerations.

First, I expect that we will someday have an outputof operator regardless of what we do, but that writing where outputof(path::to::myself): Send (or ?Send) will be too obscure, verbose, and confusing, and that a direct shorthand for “the future this async item evaluates to is Send (or is ?Send)” will be a highly desired feature which will be well justified.

Second, I expect that this will be necessary regardless of the default: in addition to the issue with trait methods, @betamos has already identified a desire to specify locally that a future is Send even if its nowhere used locally. So I see this sugar for this specific, unusually common usecase for outputof as inevitable regardless of the default we select.

From that perspective, the difference between the defaults is between users writing async(Send) vs async(?Send). I agree more relaxing bounds with ? is a drawback, but I think other practical UX benefits of defaulting to Send (such as the fact that I believe it is much more common) outweigh that drawback.

1 Like

Gramatically I would expect this to take any bound at all, but only ?Send would be meaningful at least at first.

Someday I could see extending it to support other auto trait bounds as well, like async(Sync) (adding + Sync to the requirements), and also - if we ever returned to the idea of having non-self-referential async functions implement Unpin - async(Unpin) could be supported as well.

2 Likes

I'll ponder your previous comment later; for now, ...

...I don't see a good reason for this syntactic pre-expansion limitation when it would be fairly easy to have async('l_0 + ... + 'l_n + B_0 + ... B_n) just be copied into $bounds within -> impl Future + $bounds. Notably, this is not how ?Sized works as #[cfg(FALSE)] fn foo<T: ?Moo + Send>() {} compiles just fine on stable. What I would expect here is that async($x) would expect syntax::ast::GenericBounds in place of $x.

1 Like

Would it be possible to stabilise with a default in 1.37 and feature gate the syntax for other cases? Considering that we are shipping as MVP?

Another option for a minimal viable product is stabilizing the async block in functions and .await but not async fn.

async fn foo() -> T {
    //..
}
// is sugar for
fn foo() -> impl Future<Output = T> {
    async {
        //..
    }
}
2 Likes

It would break fuchsia, our largest nightly user, not to have any support for non-Send futures in the MVP. Seems untenable to me.

Hm, I don’t quite understand. Is this the only thing blocking fuchsia of getting off or would it mean high churn if the syntax changes? In that case, I definitely agree.

What is special about Send here? What about Sync? Since Sized came up, what about !Sized? (Well, I realize return values can’t be !Sized, so I suppose that’s out of the question anyway.)

Perhaps there should be a more general "trait bounds for async" syntax? Something like…

async<B> foo() -> Result<Bar, Baz>
    where B: Send;

(I realize this ups the noise factor for whichever bound, Send or ?Send, is not the default, and I’m not going to weigh in on what the default should be…)

4 Likes

Arguably, with good enough error messages and a simple enough fix, breaking some copy-and-paste code might be a good thing.

I know that Fuschia would very much like to make it possible to write async fn that do not require nightly because that enables them to have more of a “Rust ecosystem” – @cramertj can confirm, but how I understand it is that Rust crates that use nightly are confined to be “in tree” so that they can be kept up to date as nightly evolves. But I don’t really know a lot more than that, so further details would have to come from @cramertj or other Fuschia folks. :slight_smile:

1 Like

I think it's a reasonable question whether there are other traits that would want to be the default -- but there are some special things about Send.

  • We know that some futures need to be send but others don't -- and imposing Send involves limiting the sorts of things the future can do (i.e., it can't access a shared Cell or RefCell, use an Rc, etc).
  • The Sync trait says that something can be safely shared across threads (as opposed to transferred from thread to thread). But sharing a future across threads doesn't make sense: the poll method on Future is &mut self, so you couldn't do anything with a shared future.
  • Other traits like Debug would probably be useful, but as @withoutboats noted elsewhere, we would ideally implement these uniformly for all async fns, and there isn't really a downside to implementing them (but doing this well would require some form of specialization, I imagine, so that we can have a fallback).
4 Likes

I have an executor wrapping the non-blocking APIs of the MPI library that, depending on how the MPI library was compiled or initialized, can only be polled from the exact same thread the first poll happened.

Before the first poll, I can send the future across threads without issues. I’m not sure if this means my futures need to be !Send ? Right now, I just Pin my futures before starting to poll them, and then the Pin guarantees that they won’t be moved across threads until they are dropped or the Future completes. Is that correct?

AFAICT, whether my Futures are Send or not will depend on which data they contain, e.g., if the user stores a reference to something that isn’t Send inside a future, then the Future will be automatically !Send.

Often (depending on compilation options/initialization), the MPI executor needs to live in the main thread, and applications using them are also often single threaded. That is, the executor is not polled continuously in a separate thread, but manually told to poll all futures “once” (until they can’t make more progress) at specific points within the main thread, and then the executor yields back to the main thread, so that the thread can do some more work, until the executor needs to be polled again (at some point there is a blocking poll). That is, storing !Send data-types inside a Future isn’t an issue there, and something that applications might reasonably want to do.

1 Like