Future-proof the Futures API Summary
This is an experiment
My goal is to document pros/cons of objectively, in ways that everyone can agree with. The goal here is that we shift our more of working from advocating for our position to collaboratively trying to produce a good summary. The document is not world editable, but it is “world commentable”. I ask that you observe the following rules:
- If there is something you think is unfairly worded or untrue, leave a comment that provides an alternate way to say the same thing which you think is fair. This comment should capture the same spirit but be more precise or less biased. (If you can’t think of an alternate wording, that’s ok, but at least try to be precise about what is bothering you, and I will try to see if I can adjust or refine.)
- Please avoid accusations of bad faith. We are at least striving for fairness here, even if maybe not achieving it.
- Please try to avoid arguing in the comments, although providing your own clarifications is fine. Ideally, you could take the refined statement that they provided and further refine it, without losing the essence of what they said.
- The goal is to be moving towards a steady state at all times.
- If I see comments that do not seem to have this character, I will delete them, and (after 1 warning) I will ask the offending person not to comment more on this document.
For the most part, I have deliberately not included citations here, though in some cases I added links to supporting comments or for more details. I apologize if I took some of your text without citation, consider it a complement. The reason for this is that the document is not meant to be any one person’s argument, but rather an overall summary.
What the change is
The proposal is to modify the poll callback in the Future trait. Presently, it takes a &, which is effectively a callback that the future can invoke once its value is ready (the task will then poll the future again).
The new method would take a &mut Context<'_>. The Context struct would initially (and perhaps forever) have a single method, waker, that offers access to the Waker.
Arguments pro and con
I’ve decided to interleave the arguments in favor of making the change (marked as pro) and those against (marked as con). I used indentation to indicate when one argument was “in response” to another. I also tried having two sections, one for pro and one for con, but overall I found that harder to follow, since one often had to jump back and forth. However, I believe the heart of the argument is covered in the first bullet point. In general, I tried to order the more nitty gritty disputes coming later; if you feel the ordering should be changed, feel free to leave a comment with your reasoning and I’ll consider it.
- Pro: Clearly, futures need to be given a Waker, but frameworks (like tokio) often want to give the future access to other parts of the runtime (e.g., to spawn new tasks), and we may find other bits of data we want in the future. There is a bit of a catch-22: we won’t see widespread adoption until we stabilize, but if we stabilize we lose the ability to add more parameters. Therefore, we should add a flexible Context struct that we can extend later.
- Con: This feature has been under development for a long time. Uses of the std::future API haven’t changed significantly since its initial introduction, even though some of the details have changed. We also have the benefit of being able to look at other futures-based ecosystems. At this point, if there were any additional bits of context that needed to be exposed to the future directly, we would have found them. In particular, we should be able to come up with at least one example of a future change we would like to make that is not better made another way. (The counterarguments or alternatives for all uses proposed thus far are covered below. —ed)
- Pro: The best way to make some changes is under dispute. Rejecting this change leaves only one viable option for threading add’l state, TLS, which has significant downsides (see below). This change permits us to add more data into Context, but does not require us to do so. Adding new data into Context would still require a new RFC, and we could discuss the pros/cons of each piece of new data in detail then. These discussions should be easier once we have concrete experience.
- Con: The current Future trait exposes the core concept precisely and in an “easy to work with” fashion — you invoke poll, providing a way to be woken up if data is not ready. Combined with Pin, it is everything we need for async fn. Adding additional “indirection” (in the form of a Context struct) makes the overall design harder to understand and work with. The Context type becomes one more thing to explain.
- Pro: This is not an “end-user API”, so ergonomic concerns are secondary, and the present indirection is a small change so it should not be that much harder to explain.
- Con: Ergonomic concerns may be secondary, but the &Waker argument is significantly easier to work with than the &mut Context<'_> type. This argument may, in the course of a future’s impl, need to be threaded around to many places, and having a shared reference makes that easier — further, Waker can be cloned to get a Waker type with no lifetimes. In contrast, an &mut Context<'_> type inherently has lifetimes — and, to preserve forward compatibility, it cannot be cloneable, since then we would lose unique access to the contents. (Of course, one could extract the Waker from the context and just thread that around, but other code — e.g., other futures or helper functions you might invoke — would likely be expecting the full Context.)
- Con: To be accessed by an async fn, any additional parameters would require new syntax. This is because the parameters to Future::poll are not exposed directly to users.
- Pro: Who is to say we will not add new syntax in the future (perhaps in an upcoming edition)? Further, there could be performance or debugging-oriented additions that wind up being used from (e.g.) the desugaring of the await keyword.
- Pro: It may be useful to have state exposed to manually written futures that async fn does not make use of.
- Pro: If we do find additional arguments or context we would like to supply, passing it through TLS or some other “side channel” introduces a lack of uniformity with the way we pass the Waker.
- Con: This is because those additional bits of context are different from Waker — they are secondary and not part of the “core functionality” of the trait.
- Con: But it makes that data accessible to async fn (discussed above).
- Con: But, , the sense was that TLS was right choice for task-local data and it may well be right for other things (discussed below).
- Con: But we could potentially adjust the Waker struct (discussed below).
- Con: Based on experiences from other ecosystems, we should be able to add async stack traces and other “hooks” without exposing more methods or parameters to users. Examples include V8’s “” and Node’s (a centralized point to gather information about all runtime calls, including (nested) Promise spawning).
- Con: When it comes to task-local data, (this would also make it readily accessible from an async fn). If necessary, other components could be passed the same way. Note that this TLS wouldn’t necessarily be directly accessed by the end-user: e.g., they might invoke std::task::spawn(), which would internally access the required context from TLS.
- Pro: TLS is always available even with this PR if we decided it is correct.
- Pro: The dynamic scoping of TLS, like global variables, makes it more complex to use properly. If futures use internal thread-pools (e.g., rayon-like processing) then TLS-based solutions have to be integrated with that.
- Pro: TLS is slightly slower to access (). It is disputed whether this would make a difference at scale; for example, , but more details about the scenario and overall impact would help to clarify.
- Pro: TLS is not available on all platforms, particularly embedded platforms, which means that those platforms are further limited to using globals, with all the threading hazards that implies.
- Con: However, . Specifically, they felt that threads are an OS concept. Implementing threads requires split stacks (one per thread). Being able to support split stacks, implies you can implement a heap and TLS.
- Con: Apart from TLS, we could also expose more state in the future by extending the Waker struct.
- Pro: However, the name of the Waker type would be surprising in that case. Further, we changed the name from a more general one (Task) to Waker in part because the older, more general name was a common source of confusion.
- Pro: Increasing the size of Waker might also be a perf hazard; we’ve previously .
- Pro: Moreover, the argument presently has type &Waker, which limits the kind of data that could be placed in there. For example, the callee cannot have unique access to the data (& not &mut) and the Waker type has no lifetime parameters (and ought not to have them).
- Con: The other proposed extensions thus far involve exposing components of the runtime, such as the spawner or reactor. Those are orthogonal features that are irrelevant to async/await and do not belong in the core API. Furthermore, exposing these components could lead to increasing coupling or other unwanted dependencies. For example, using task-local data to pass around components of the request would mean that spawning new tasks for performance reasons might lose access to that data (much as TLS doesn’t play nicely with rayon).
- Pro: The core mechanism of async-await may not require those components, but they may be desired for things that an async fn must interact with. (This seems to just circle back to the debates over whether TLS etc is acceptable. — ed.)
Questions for the libs team
- Is there precedent for this sort of “future compatibility” shim in the stdlib or ecosystem that comes to mind?
- Are there cases where not including future compatibility of this kind caused problems down the line?
- Are there cases where trying to anticipate future uses caused problems for usability (or other sorts of problems)?
- General consensus on whether to introduce the Context parameter or simply to stick with Waker?