-
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
async trait for runtime #13
Comments
It would be simpler to not have separate process/net/file/stdio APIs but rather provide a single unified pub trait Runtime {
#[cfg(unix)]
type Fd: Fd;
// true for io_uring, false for epoll, used to determine whether file APIs should just use `spawn_blocking`
fn supports_non_socket_fds(&self) -> bool;
}
// unix-only
pub trait Fd: From<OwnedFd> + Into<OwnedFd> + AsyncRead + AsyncWrite {} and |
I doubt that will be a good idea, since the async executor for embedded environment or for some special use cases, e.g. runtime for the main loop of games, does not need to support process/net/file/stdio APIs. Instead, they could simply just implement |
Ah, you misunderstood me. I meant that process/net/file/stdio should all be together, but time and task spawning should still be separate. |
Sorry for that, integrating these traits does sound reasonable, but I am still not completely sure this is a good idea though. |
Oops... |
Having this method:
in |
Also, we need to find a way to obtain the runtime. I personally think the proposal of having a "context" is the best solution for this, since it is zero-cost (does not require thread-local variable) and the crate can easily put a bound on the "context". |
After a second thought, I think it will be better to keep it separated. While it's true that process/net/file/stdio can be put together, the implementation of the runtime often split them into multiple features. For example, tokio provides feature flags for each one of them to speedup compilation and reduce bloat. Similarly, async-std also provides feature flags. Thus, I think it will be better to leave them as separate traits so that the runtime can choose to provide feature flags for each of them. |
Under my design, types like TcpStream, process::Child, Stdin etc would still be equally feature flagged. All that wouldn't be feature flagged would be the underlying low-level system call wrappers which would be common between the high level net/process/io interfaces anyway. |
I am a bit confused. Do you mean that process/net/file/stdio would be in the same trait be feature gated? |
Each runtime would just provide an implementation of On top of it, the standard library can provide high-level typed wrappers like // generic, runtime-independent file API
pub struct File<F: Fd> {
fd: F,
}
impl<F: Fd> File<F> {
pub async fn open(runtime: &F::Runtime, path: &Path) -> io::Result<Self> {
let fd = runtime.open(path).await?;
Ok(Self { fd })
}
}
impl<F: Fd> async Read for File<F> {
async fn read(&mut self, buf: ReadBufRef<'_, '_>) -> io::Result<()> {
self.fd.read(buf).await
}
}
// etc |
This actually sounds good. I think we need the runtime to implement AsyncHandle (can be constructed from OwnedFd or windows's OwnedHandle), Socket for async accept, (multishot) async recv/send implementation, process spawning, timer, fs operations. |
@SabrinaJewson I've adopted your suggestion and updated the proposal. |
@nrc I have written down a detailed proposal and it seems that we need at least type GAT for spawning tasks. |
Amendment Proposal: Archive zero-cost abstractionMotivationCurrently, in order to implement the portable None of the solutions above is zero-cost. Reference counting requires boxing and every time a new Global Thus, I decided to propose this to archive zero-cost abstraction. ModificationIn order to archive zero-cost, we must eliminate the need of reference counting To do so, we must add lifetime to every type that implements There is two way to accomplish this, one is to use lifetime GAT and the other one is Lifetime GATWe would need to apply the following modifications: trait Handle: Clone {
type JoinHandle<'a, T>: JoinHandle<T>;
}
trait RuntimeAsyncHandle: Runtime {
type AsyncHandle<'a>: AsyncHandle;
}
trait AsyncHandle: Read + Write + Seek {
type ReadBuffered<'a>: AsyncHandle + BufRead;
type WriteBuffered<'a>: AsyncHandle + BufWrite;
type ReadWriteBuffered<'a>: AsyncHandle + BufRead + BufWrite;
}
trait AsyncHandleNet: AsyncHandle {
type AcceptStream<'a>: Stream<Item = Result<AsyncHandle>>;
type RecvMsgStream<'a>: Stream<Item = Result<(Buffer, Auxiliary)>>;
}
trait RuntimeProcess: Runtime {
type Command<'a>: ...;
}
trait RuntimeTime: Runtime {
type Interval<'a>: Interval;
}
#[cfg(unix)]
mod unix {
trait RuntimeSignalExt: RuntimeSignal {
type Signal<'a>: Stream<Item = Result<SigInfo>>;
}
} This requires each type in the traits to be modified and add This also means that we would have to depend on GAT and it might further
|
Hey @NobodyXu I haven't looked in detail at the amendment above, I'm still at the information and requirements gathering stage of work on executors. But two comments: I expect using lifetime GATs is fine in general, they seem on a solid path towards stabilisation. I don't think being zero-cost in the sense of having no runtime overhead at all is necessary, the cost of a few statics or an occasional reference count incr/decr is well worth it for the sake of improved ergonomics (if that is in fact the trade-off here). |
It's good to hear that!
Well, I don't think using a few statics or reference counting improve ergonomics. The problem here is zero-cost runtime abstraction requires lifetime GAT, but if that is fine, then I don't think it will have a lot impact on ergonomics. In fact, I would argue the current API makes more sense. Users of this portable runtime can clearly see how their For example, beginners might attempt to create When using that function In that case, the code will compile, but fail at runtime with a panic. With this portable runtime proposal, this won't happen, because users need a |
In additional to the ergonomic improvement I mentioned in the last comment, the amendment proposal also improves ergonomic for Currently, |
@nrc The original proposal also uses specialisation: // Provided function, automatically call spawn if f is Send, otherwise call spawn_local.
fn spawn_auto<T>(&self, f: impl Future<Output = T> + 'static) -> Self::JoinHandle<T>; MotivationSuppose that a user want to write some data into However, Another option is to just use Thus, This will make the portable |
This proposal definitely needs more feedbacks, espeically from whom maintains the async runtime. @rust-lang/wg-async, @rust-lang/libs-api |
Well, I looked it over and it seems rather complicated. But perhaps that's unavoidable. I don't think the current location of your |
Thank you @joshtriplett @Noah-Kennedy @ibraheemdev @SabrinaJewson @nrc @Darksonn for providing me with feedback! async portable runtime 0.2.0In portable runtime 0.2.0, I split the functionalities into executor and reactor to make the interface simpler, more compositable and easier to standardize. This does not mean that all runtimes have to support plugable executor. Portable runtime 0.2.0 also avoids use of Executor 0.2Checkout #13 (comment) for executor 0.3, which provides better APIs. ReactorBelow is the basic interface for the reactor: use std::io;
use std::marker;
use std::task::{Context, Poll};
trait ReactorPoller {
fn register(&self, pollable: Pollable<'_>, interest: Interest) -> io::Result<Registration<'_>>;
fn deregister(&self, pollable: Pollable<'_>, registration: Registration<'_>) -> io::Result<()>;
fn poll_read_ready(
&self,
cx: &mut Context,
registration: &mut Registration<'_>,
) -> Poll<io::Result<()>>;
fn poll_write_ready(
&self,
cx: &mut Context,
registration: &mut Registration<'_>,
) -> Poll<io::Result<()>>;
fn clear_read_ready(&self, registration: &mut Registration<'_>);
fn clear_write_ready(&self, registration: &mut Registration<'_>);
}
/// An opaque type holding platform-dependent fd/handle
struct Pollable<'a>;
impl<'a> Pollable<'a> {
#[cfg(unix)]
pub fn from_fd(fd: std::os::unix::io::BorrowedFd<'a>) -> Self;
#[cfg(windows)]
pub fn from_handle(handle: std::os::windows::io::BorrowedHandle<'a>) -> Self;
#[cfg(windows)]
pub fn from_socket(socket: std::os::windows::io::BorrowedSocket<'a>) -> Self;
}
/// An opaque type.
struct Registration<'reactor>([usize; 2], marker::PhantomData<&'reactor ()>);
/// A bitflag that contains READABLE and WRITEABLE.
struct Interest;
use std::time;
trait ReactorTimer {
fn poll_sleep(&self, duration: time::Duration, cx: &mut Context) -> Poll<()>;
}
trait ReactorCompletion {
fn poll_for_response(
&self,
pollable: &RequestDesc<'_>,
cx: &mut Context,
) -> Poll<io::Result<()>>;
fn get_response(&self, pollable: RequestDesc<'_>) -> Option<RawResponse<'_>>;
fn consume_response(&self, raw_response: RawResponse<'_>) -> Poll<io::Result<()>>;
fn register_pollable(
&self,
pollable: Pollable<'_>,
cx: &mut Context,
) -> Poll<io::Result<Request<'_, PollableDesc<'_>>>>;
fn unregister_pollable(
&self,
pollable_desc: PollableDesc<'_>,
cx: &mut Context,
) -> Poll<io::Result<Request<'_, ()>>>;
fn register_buffer(
&self,
buffer: OwnedBuffer,
cx: &mut Context,
) -> Poll<io::Result<Request<'_, BufDesc<'_>>>>;
fn unregister_buffer(
&self,
buf_desc: BufDesc<'_>,
cx: &mut Context,
) -> Poll<io::Result<Request<'_, ()>>>;
fn accept(
&self,
pollable_desc: &PollableDesc<'_>,
cx: &mut Context,
) -> Poll<io::Result<Request<'_, OwnedPollable>>>;
fn register_multishot_accept(
&self,
pollable_desc: &PollableDesc<'_>,
cx: &mut Context,
) -> Poll<io::Result<MultishotRequest<'_, OwnedPollable>>>;
unsafe fn read(
&self,
pollable_desc: &PollableDesc<'_>,
buf_desc: &mut BufDesc<'_>,
cx: &mut Context,
) -> Poll<io::Result<Request<'_, usize>>>;
unsafe fn write(
&self,
pollable_desc: &PollableDesc<'_>,
buf_desc: &BufDesc<'_>,
cx: &mut Context,
) -> Poll<io::Result<Request<'_, usize>>>;
}
/// An opaque type
struct RequestDesc<'reactor>(u64, marker::PhantomData<&'reactor ()>);
struct Request<'reactor, T>(
&'reactor dyn ReactorCompletion,
Option<RequestDesc<'reactor>>,
PhantomData<T>,
);
impl<'reactor, T> Request<'reactor, T> {
pub unsafe fn new<R: ReactorCompletion>(
reactor: &'reactor R,
request_desc: RequestDesc,
) -> Self;
}
impl<T> Future for Request<'_, T>
where
T: FromRawResponse,
{
// poll_for_response => get_response => FromRawResponse::from => consume_response
}
struct MultishotRequest<'reactor, T>(RequestDesc<'reactor>, marker::PhantomData<T>);
impl<'reactor, T> MultishotRequest<'reactor, T> {
pub fn request(&self) -> Request<'reactor, T>;
}
struct RawResponse<'reactor> {
cqe_addr: *const (),
index: usize,
phantom: marker::PhantomData<&'reactor ()>,
}
trait FromRawResponse {
unsafe fn from(raw_response: &RawResponse<'reactor>) -> Self;
}
/// Similar to Pollable but owned.
struct OwnedPollable;
/// An opaque type
struct PollableDesc<'reactor>(u64, marker::PhantomData<&'reactor ()>);
struct OwnedBuffer(*mut u8, usize);
impl OwnedBuffer {
unsafe fn new(ptr: *mut u8, len: usize) -> Self {
Self(ptr, len)
}
}
/// An opaque type
struct BufDesc<'reactor>(u64, marker::PhantomData<&'reactor ()>); |
async executor 0.3After posting about async executor 0.2, I realized it is possible to design the APIs of executor without any generics, And I also realized that the
|
So I've taken some time to look through this again. Regarding this approach, I don't think this gets us much. First, starting with the spawn methods, having them be unsafe like this makes the whole abstraction useless. If the purpose is for library authors to be able to not care what runtime they are on, then it is self-defeating to make these methods unsafe and tied to whatever lifetimes the runtime supports, as the library authors don't know this information. These methods should simply require Second, I think we are taking the wrong approach here. Currently, we seem to be trying to define the behavior that runtimes should adopt in a very tight-fitting manner, and I don't think we actually solve any problems here. Library users don't care if there is work stealing, and they don't really benefit from an API that stubs out a reactor like this, as this is an implementation detail of the runtimes that should be encapsulated from them. I think what we really need here is to have common traits for different types, and to have a factory object of some sort which allows us to construct types matching those traits via it's associated types. This is more similar to the initial proposal. This allows library authors to write code independent of the runtime they are on, so long as that runtime offers a factory that they can use. It would be quite simple actually from an authors perspective, especially as compared to the other options discussed. This factory object could also provide an API for task spawning, but it should be as simple as |
The std library would provide wrappers over these The std library would implement the With the existing
I presume you are talking about It is not a trait exposed to ordinary users, but rather the reactor implementation so that they can register their own poller to the executor and co-op with any executor, inspired by @joshtriplett when discussing on zulip. Of course, this is kind of my wet dream since its APIs are indeed too tight, so I will probably remove it for now.
I actually changed that to this because of concerns raised by @joshtriplett . The previous one uses generics and associated items (directly or through use of @joshtriplett also mentioned that I have the completion APIs packaged into the existing polling APIs, making it harder to change the completion APIs and add more APIs in the future. With these concerns in mind, I decided to make the new reactor a low-level API that does not use generics or associated items at all, and split the completion and polling APIs into Then we can have a high-level API wrapping these lowlevel For example, That will remove a lot of duplicate code to deal with For The high-level APIs can use specialisation to detect presence of trait Reactor: ReactorPoller + Sealed {
fn to_reactor_completion(&self) -> Option<&dyn ReactorCompletion> {
None
}
}
impl<T: ReactorPoller + ReactorCompletion + Sealed> Reactor for T {
fn to_reactor_completion(&self) -> Option<&dyn ReactorCompletion> {
Some(self)
}
} which can be used in generic functions or as a trait object, then we would have highlevel APIs like this: struct TcpStream {
inner: std::net::TcpStream,
/// Registration for ReactorPoller
registration: Registration,
}
impl TcpStream {
pub async fn connect<A: ToSocketAddrs>(addr: A) -> Result<TcpStream>
with reactor: &Reactor;
} |
I guess you realize that there is not just tokio and async-std to consider. I want to implement this interface for Stakker, which is one-runtime-per-thread never-blocking actor-model runtime, with typically If a crate that Stakker is hosting via this interface wants to spawn threads of its own, no problem, but apart from a cross-thread waker of some kind, the runtime can't have anything to do with what it's doing in other threads, since the entire runtime is non-Send/Sync. Okay, maybe not all crates will be able to exist within those limitations (i.e. maybe I can't host everything), but I think the vast majority of protocol stuff should be able to work at least. Right now, the only stuff that's portable to Stakker is stuff that's very low-level, like |
block_on is now implemented as try_enter_block_on and try_exit_block_on #13 (comment) .
Why would spawn_blocking cause deadlock?
You can simply implement spawn using spawn_local. And there will be an interface called spawn_auto that automatically spawns locally if the future does not implement Send so that users of the portable runtime doesn't have to implement these themselves. |
Maybe I misunderstood the purpose of spawn_blocking. So it doesn't actually block. Yes, I can implement a thread-pool if that is what is required. I guess there are some additional complexities to handle, e.g. non-blocking I/O through this interface from these tasks needs a reactor in those threads (potentially?), also they may spawn normal tasks (back in the runtime thread?). The whole thing needs thinking through.
Okay. I don't think Stakker will be the only single-threaded runtime for Rust (I think there are others already), so ideally things should be arranged so that the base API supports single-threaded, and people coding against this interface will understand that they are limiting their portability if they use certain additional methods. If everyone uses 'spawn' by default then straight away a big chunk of the ecosystem is cut off. It's not just this kind of shard-per-thread runtime approach, but also embedded/etc where maybe there aren't even threads. Whenever things have settled down a bit I will try to implement the interface. |
Okay, got it -- so 'spawn' won't be a problem. |
It is for blocking io, e.g. doing file io on a polling runtime.
If your reactor does not implement
I plan to let them use
You got it, |
I think the whole idea of spawn_auto is problematic. On many runtimes, you cannot spawn a local task from any context, so implicitly falling back to local spawning if the future is We do need a way of spawning that works for both runtimes where tasks are not sendable (as there are advantages to "isolated" thread-per-core runtimes), as well as work-stealing runtimes. @uazu brought up a really great point here, and we need to try and figure out a solution. Give me some time, and I can get a proof-of-concept of my ideas hacked out later today. |
Also, thinking on this more, we do not need Also, another note since this ticket is getting heated at times: Folks, let's please keep in mind that the problem we need to address is that library authors and others seeking to write systems that can work on multiple runtimes are unable to do so easily, due to the coupling to a runtime's spawn method primarily, and secondarily due to the need to couple to a runtime's specific IO types. We don't need to solve this by crafting a framework that tries to abstract over every implementation detail of every runtime. We just need to look at the minimum set of abstractions capable of making this easier, and make sure that these abstractions are broadly capable of being met by different classes of runtimes. |
@Noah-Kennedy We do need a std::task::block_on though. |
@joshtriplett why? What use case does it help with? |
@Noah-Kennedy "I'm in synchronous code, and I need to await a future; I need some way to switch from sync mode to async mode." (The reverse of the |
How common is this for a library to need to do though? I cannot think of an actual time to use this. I am trying to weigh this against the fact that not all runtimes can do this (a lot of thread-per-core designs can't). |
@Noah-Kennedy Also, sorry, I responded quickly to that one point without acknowledging and appreciating the rest of your message:
I really appreciate and agree with this point, and thank you for expressing it. |
I've found it quite common in application code, extremely common in documentation/example/test code, and moderately common in library code (notably in library code that isn't 100% inside the async world). I don't think either
As far as I can tell, any design can, but it may have to go through more steps to do so. I would like to understand better how a runtime couldn't, rather than just that it doesn't naturally fit into its model and requires an extra layer. Just as I am hoping that the executor trait can serve as a backend for |
In Stakker there are two contexts: outside the main loop, which is synchronous, and stuff called from the main loop (actor methods and future polling), which can never block. So how can I implement Outside the main loop, I guess it could be used to start a standard main loop. Yes, that makes sense if that's going to be the standard way to start a new runtime. (I'll have to select what kind of main loop and what's pulled in with cargo features I guess, but that's my problem.) Maybe the key is to have one interface for "running outside the runtime" which includes |
@joshtriplett There are a lot of different designs for what I am going to refer to as "isolated worker" thread-per-core runtimes, so I'm going to generalize a bit. A lot of "isolated worker" thread-per-core designs try to basically model all of the worker threads as parallel systems, allowing for a lot less synchronization overall. This can be a very effective paradigm for server applications in conjunction with things like SO_REUSEPORT, and this is a very common pattern for custom runtimes written for a specific performance use cases or categories of workloads as a result. Generally the setup process for these runtimes involves first spawning a bunch of threads pinned to different CPU cores, so With these designs you can support spawning from outside the runtime context quite easily via a global queue or other means (not going into this, there are lots of weird designs though). Generally, these designs are optimized under the assumption that spawning from outside the runtime is less common than spawning from inside the runtime, so the latter path is much faster. These runtimes may seem a bit odd for those of us coming from async-std or tokio, but this is actually a well-established pattern that has considerable benefits for many applications, and which I suspect may become more common in the rust space due to the fact that it works really well with io_uring. I'm trying to keep these designs in mind wrt our proposals here. |
@Noah-Kennedy If you can support spawning from outside the runtime context, in theory you could implement @uazu Generally speaking, some runtimes do have restrictions that you can't call |
Maybe we can make this more explicit, by stating that spawning/block_on can only be used when any type that implements the |
@NobodyXu I don't think that would work; I would expect spawning (and the executor context) to be available everywhere, while block_on may be limited to contexts that aren't in a future (but the executor should still be available for spawning). |
Yeah, I also expect the executor context to available on any future and inside the For reactor, this is more complex as it might not implement
Not sure how to ensure we are not in a future though, I wish there is such mechanism in the language to express this because now several functions in |
I agree that it's a footgun, and it's nice when implementations avoid that footgun, but despite that, block_on is still useful and important. |
From Stakker's point of view, if I implement block_on, and it is running inside the runtime, I will panic. The only other alternative would be to spawn the future, but that probably also breaks the user's expectations. If I implement
There is such a mechanism, but you have to buy into it totally. This is how Stakker works. There are borrows that are passed down the stack, and if you don't have that borrow, then you don't have access to a whole bunch of stuff. This is all tied into QCell and friends (similar to GhostCell). It also means that Stakker only ever runs a (relatively) shallow stack before returning to the main loop, because if you get any deeper you lose the important borrows. I wrote this all up HERE. Unfortunately for futures, there is no way to pass the borrow through the Edit: Specifically, it's THIS PAGE that explains how the borrowing works in Stakker. |
Yes, that sounds ideal. Perhaps we can have a way to express levels of support, e.g. Stakker might support level 2, and tokio/async_std level 4, or whatever. Then if a crate claims to only require level 1, then it's clear which runtimes it can run on. (Or maybe expressed as a required-feature list.) |
That will be an effect system, I'm not sure if rust wants to support that. Perhaps we could work with the keyword generics wg to see if they have any idea to solve this? P.S. @uazu Thanks for the links, using QhostCell sounds interesting and I will have a read at the links you provided. |
Note that this will not work, since users can come up with their own context. For example, the crate futures has its own context type so passing the token in context just wouldn't work. @uazu Perhaps you can also use the "with context" which is planned to be used to pass around executor/reactor to pass around your token? |
I think this blog post has proposed a more elegant solution: Instead of creating Reactor traits, it proposes extending Context to allow blocking callback to be added if not already set. This would avoid the global variables, the Reactor traits and need of context parameters. But if some library crates need to abstract over functionalities provided by async runtime reactors, they would still need the reactor traits. |
I like the idea of extending context |
I propose to add a new trait for the runtime, which provides portable APIs for creating sockets, pipe,
AsyncFd
, timers and etc.Motivation
As mentioned in this comment:
portable runtime trait 0.2.0
Executor 0.3: #13 (comment)
Reactor 0.2: #13 (comment)
portable runtime trait 0.1.0
Here is a rough scratch of what I expected, many APIs are elided because there are too many APIs:
(NOTE that these APIs are heavily influenced by
tokio
because it is the most familiar async runtime API to me)Amendment Proposal: Archive zero-cost abstraction
After I written up this proposal, I realized that it is not zero-cost.
So I created the Amendment Proposal: Archive zero-cost abstraction.
The text was updated successfully, but these errors were encountered: