I have not previously engaged with the Rust project in matters of async language/library design, so let me tell you a bit about where I'm coming from.
My very first programs were graphical, often interactive ones — existing in a fundamentally event-loop-based environment — which ran on machines that had a single core, no memory protection, and no threads. (Even in later times when threads were available, they were clearly a complication that had to be used carefully due to all the unmarked hazards of lack of synchronization.) When the Internet became available, I started writing networked programs — one of the notable ones being a multiuser interactive game server (MUD) which had at its core a select(2)
-based IO/timer multiplexer.
Later, I got involved with the object-capability community, which among other things took the idea of event loops and ran with it as an entire programming paradigm, seeing it as a way to build programs that avoided hazards from simultaneous mutation — in the same way that the "actor pattern" we know as Rust developers does, but serially concurrent rather than parallel. In either case, we break up the life of a stateful entity into a sequence of “events” or “messages”, each of which is processed mostly in isolation — limiting causality to (mostly) known incoming and outgoing channels. Threads could be made safe by having each thread run separate actors or separate event loops which communicated only by messages rather than shared memory.
(Even with Rust's safety features, there are advantages to these paradigms; it's easier to avoid deadlock when you have fewer locks, and subtler application-specific state management problems can sometimes be avoided. And RefCell
and Cell
have stronger guarantees than RwLock
and Mutex
.)
As a consequence of these experiences, I tend to see message/event-based architecture, and even cooperative multitasking, not as a specialty tool for high-performance network services (as some Rust developers would tell you) but one of the fundamental paradigms in which code can be written. Rust's Future
s and async
are a great language/library feature to have available, and I want to see enhancements that make it easier to use them wherever they fit.
The particular feature we are discussing adding is trivial in API:
// in mod std::thread
impl<T> IntoFuture for JoinHandle<T> {
type Output = Result<T>;
type IntoFuture = JoinFuture<T>;
fn into_future(self) -> Self::IntoFuture {...}
}
/// future type supporting the IntoFuture implementation
pub struct JoinFuture<T> {...}
impl<T> Future for JoinFuture<T> {...}
There are very few plausible alternatives to this; the only one I have thought of is impl Future
instead of impl IntoFuture
.
The question at hand, then, is not what the design should be but whether we should do this at all.
I believe we should.
The opposing arguments that I have seen made or that I can think of, which I will address in passing while making the arguments in favor, are:
Suppose that an application in fact needs to do this. It is possible to implement already, with the aid of a oneshot channel implementation. Any program that intends to run blocking tasks from async, with a return value, likely does something similar to this (though it may be in a loop run by a thread pool).
use std::future::Future;
use std::{thread, io};
use futures_channel::oneshot;
pub fn spawn_thread_with_future_output<T: Send + 'static>(
thread_builder: thread::Builder,
body: impl FnOnce() -> T + Send + 'static,
) -> io::Result<impl Future<Output = Result<T, oneshot::Canceled>>> {
let (tx, rx) = oneshot::channel();
thread_builder.spawn(move || {
let _ = tx.send(body());
})?;
Ok(rx)
}
This implementation has the following disadvantages compared to what std
could offer:
In the most general form as presented above, it's pretty clunky; note the io::Result
and thread::Builder
which most applications won't care about, but which some will. On the other hand, impl IntoFuture for JoinHandle
makes “take the result as a future” an orthogonal choice from the thread's builder (or lack thereof via std::thread::spawn()
), its scoped-ness, and the code in its closure. This orthogonality is not critical, since it usually doesn't make sense that a library API would produce or accept a JoinHandle
, but it's a nice API structure.
It requires an additional library for the channel implementation. That's not necessarily a problem (we do not expect std
to contain everything it could), but it feels overkill compared to the problem, and there are no policy decisions to make here; there is no reason to want to swap in a different implementation, except perhaps for fine points of scheduling behavior, where one would want to write something custom and explicit anyway.
(Counterargument: perhaps std
should provide a oneshot channel, making this moot.)
It does not catch panics from the thread in the same way both std::thread::JoinHandle
and tokio::task::JoinHandle
do; in order to do so, you need to make it yet more complex with a catch_unwind
that sends the panic payload rather than just dropping the channel.
It makes an additional Arc
memory allocation for the channel, notably in addition to the thread/JoinHandle shared data (private struct std::thread::Packet
) which already serves essentially the same job of communicating success or failure. (This is probably not significant, and impl IntoFuture for JoinHandle
would mean adding an Option<Waker>
-ish field to Packet
.)
Rust suffers from a perceived and actual division between "sync and async" worlds ("function coloring", etc.). Some of this is intrinsic and would require language support to address (generic functions with possibly-async callbacks), but some of this can be addressed by adding simple interop features. In particular, having impl IntoFuture for JoinHandle
would lower the cost of calling a blocking function from an async function, and of moving that async/blocking boundary when new requirements or refactoring demands it, because there would be fewer entities and lines of code that need to be repositioned.
Rust programmers who are using Tokio (or a similar async runtime) have the option of tokio::task::spawn_blocking()
, which for some purposes is a superior choice since it uses a thread pool. However, one of the obstacles to async adoption is the perception (accurate or not) by library authors that “I have to make a choice of executor and my code won't be as general any more”; by offering the convenient impl IntoFuture for JoinHandle
, we can remove this obstacle to a gradual introduction of async usage to a crate.
Rust programmers who are writing a program they want to keep simple, and not bring in anything that feels heavyweight-with-configuration-knobs like a thread pool, might yet find themselves in a situation where they want to, say, handle the results of several threads as they come in, or even do something more heterogenous-select than that. In that case, impl IntoFuture for JoinHandle
(plus having some executor) lets them do that.
let something_local = ...;
let t1 = thread::spawn(|| {...});
let t2 = thread::spawn(|| {...});
block_on(join!(
async { handle_result_1(&something_local, t1.await.unwrap()) },
async { handle_result_2(&something_local, t2.await.unwrap())) },
))
In this example, I'm assuming the result processing requires local data that might be !'static
and !Send
, so it can't just be sent to each thread. It does include block_on
and join
, which are both not yet features of std
, but they are also things that can be provided by small libraries, and might (in some possible paths for Rust) become part of std
.
The same effect can be obtained with no async, using a MPSC channel, but it's not necessarily as straightforward:
enum
of different output types from the multiple threads, or create keys to identify each of an unbounded set of different itemsThus, even if you intend to write a largely thread-based program, futures made from threads may be able to help you write your structured concurrency.
(Also, instead of a vanilla block_on()
, the program might be using an odd executor, like async-winit
, which isn't and shouldn't be in the business of providing general-purpose executor features like task management and spawn_blocking()
, but has good reason to be async
anyway.)
The above isn't just hypothetical; in the original Zulip discussion thread, @The 8472 brought up some cases where something async-ish was already being done. In particular, https://github.com/rust-lang/rust/blob/5bd5d214effd494f4bafb29b3a7a2f6c2070ca5c/src/tools/tidy/src/main.rs#L51-L93 is a limited-concurrency thread spawner which could be replaced by a simple use of futures::stream::StreamExt::buffer_unordered()
, if only the spawned threads could be turned into futures.
We should offer good-enough tools to make it easy to do these things cleanly rather than messily, even if an dedicated, elaborated async framework like Tokio could do them more efficiently. Not every part of every program has to be high-performance, and offering correct, robust, flexible synchronization features improves Rust's “fearless concurrency”.
std
's job is interop and it should do lots of thatstd
provides common types for Rust libraries to use and share with each other; therefore, it provides various conversion functions to go from one type to another, so that when a caller needs a slightly different type than they have, they can obtain it easily. There are many From
implementations, and many conversion methods, like Vec::into_boxed_slice()
. Many of these conversions could easily be written in other ways; convenience, “do the obvious thing”, is valuable.
Therefore, we should provide interop features between async
concurrent code and thread-based concurrent code. This does not necessarily mean the specific IntoFuture
discussed here; rather, there just should be something available, which there isn't currently.
Some of the conversions std
offers are purposefully lossy — for example, Result::ok()
discards the Err
value if any — because what is lost may reasonably be irrelevant to the task at hand. We choose to offer these convenient conversions even though they could be used to make a mistake. Somewhat similarly, offering async access to threads' results could be used to make a poor implementation of spawning which creates threads in excess (rather than using a thread pool), and defeats the value of async. However, I believe we should provide the interop anyway; the mistake in this situation would be in unbounded thread::spawn()
ing, not in feeding the results thereof into async-land.
Similarly, I believe std
should offer a trivial executor like pollster::block_on()
, because there is more-or-less only one way to do it and it enables lots of blocking/async interop, even though it could be used to make the mistake of trying to run an IO/timer future on the wrong executor. But that's not the main topic today, and I do not think that we should wait to build one-directional interop on having a plan for bi-directional, because one is still useful without the other.