portable-interoperable
portable-interoperable copied to clipboard
AsyncRead and AsyncWrite
Tracking issue for work on AsyncRead and AsyncWrite. The eventual goal is that we have a single, standardised version of these traits in std which are used by all (or at least most mainstream) async runtimes.
Known technical issues:
- should these traits use
poll_read
/poll_write
functions orasync fn read
/async fn write
- how to handle writing to uninitialised memory in
AsyncRead
- how to simultaneously read and write
- how to do vectored IO
- working in no_std scenarios (I believe this only requires moving the
Error
trait to libcore, which is work in progress - ensuring these traits work well as trait objects
- using async drop for shutdown?
There are also several closely related traits such as AsyncSeek
, and in various libraries, extension traits and AsyncBufRead
.
Current implementations:
- Tokio: AsyncRead AsyncWrite
- Futures: AsyncRead AsyncWrite
- Futures-lite: AsyncRead AsyncWrite
- Async-std: Read Write
Smol re-exports the futures versions.
Some resources:
- Async vision doc
- Async foundations issue
- Paper doc on uninit memory
- Blog post on unint memory
- RFC 2930 (on extending Read trait to allow using uninit memory)
- Tokio discussion: 1744, 2716
- Futures crate discussion: 2209
- should these traits use
poll_read
/poll_write
functions orasync fn read
/async fn write
async fn is better, but it was blocked by async-trait which is unlikely to be stabilized in the short term. 😞
I think we have a solution to dyn-safe async traits that doesn't require 'inline' async.
ReadBuf (RFC 2930) has landed on nightly now (https://github.com/rust-lang/rust/pull/81156)
Async-std: Read Write Smol re-exports the futures versions.
Note that async-std
also re-exports the futures-io
traits, but does so using an alias. This does mean the traits are compatible though. In hindsight we probably should've kept the Async{Read,Write}
terminology, but we didn't know that at the time.
Another known blocker that I haven't seen mentioned yet: the working group needs to make a decision on the feasibility of async overloading. This will have consequences for the shape and location of the async IO traits.
Async overloading is being tracked on the roadmap, but does not yet have an initiative owner.
Note that async-std also re-exports the futures-io traits
From the docs, they appear to have different definitions? The async-std versions have significantly more methods. Not sure if it a re-export of an earlier version of the futures definition or something more complex than that.
From the docs, they appear to have different definitions? The async-std versions have significantly more methods.
yeah, that touches on another thing we probably shouldn't have done: the methods on async_std::io::{Read,Write}
don't actually exist on them; they are only compiled in for the docs, to create the appearance that they do. The way they are made available is by importing async_std::io::prelude::*
which includes ReadExt
and WriteExt
. It's very much a hack, but it allowed us to keep using the futures-io
types, while still providing a cohesive feel.
The reason why we did this is because we wanted to push the "async-std is an async version of std" idea as far as we could. We wanted to prove out that it is indeed possible to map std's abstractions and usage patterns 1:1 to async Rust. And it worked; we now know that that it indeed can be done - modulo some missing language features like "async closures", "async drop" and "async traits". But perhaps if we had to try this again, we could've just exposed it as async_std::io::{AsyncRead,AsyncReadExt}
. Though obviously that's speaking with the benefit of hindsight.
A proposed design: https://www.ncameron.org/blog/async-read-and-write-traits/
I should flesh out the alternatives with examples and/or why they don't meet the stated goals.
Just one comment on the proposed design (which looks fine to me although I'm not really the target audience):
- For the examples where
let mut buf = [0; 1024];
is done after theready
call, I'm trying to figure out how this saves memory, since usually stack space is reserved based on the maximum extent required. Does this depend onbuf
not carrying over any.await
, which means it goes onto the real stack instead of into the coroutine context structure? So the aim is to not have the buffer held across any.await
?
For the examples where let mut buf = [0; 1024]; is done after the ready call, ...
These are simplified examples, in real life we might use one shared buffer or allocate space on the heap to be used in other functions, etc.
Shouldn't AsyncRead
and AsyncReady
be renamed Read
and Ready
in the "Complete version" code block ?
Shouldn't AsyncRead and AsyncReady be renamed Read and Ready in the "Complete version" code block ?
Whoops! They absolutely should. Fixed now, thanks!
Feedback from Fuchsia:
The proposal suggests that for optimal performance, Ready should be used followed by a read call later. I think that might be quite challenging for us to implement because it would require locking between the ready notification and the read (to prevent the kernel discarding pages under memory pressure) and AFAICT, there's no indication of how much should be locked in the ready call.
And wonder if we could add a byte count to Interest
.
Discussion ongoing on Zulip
Some discussion on a possible alternative where we have a type like smol's Async
and some of the API is on that type rather than the io traits (I believe the benefit here is that we keep the differences between the sync and async APIs restricted to a single location).
The read_with
style helpers from smol::Async may also be helpful for making the memory-optimal path more ergonomic in some cases.
I've started to write up the designs from the blog posts in https://github.com/nrc/portable-interoperable/tree/master/io-traits
Filed https://github.com/nrc/portable-interoperable/issues/7 related to this issue.
The current version of the traits described in the README include vectored methods. In practice, I have found this to be a mistake because it is not possible to have a good default implementation. The user of Read/Write must have a different implementation depending on whether the I/O handle can support vectored ops. What this means in practice, a library like Hyper that takes a T: Read + Write
must assume the T
does not support vectored ops and avoid calling those methods.
I think, for vectored ops, it should be a separate trait. Converting a T: Read + Write
-> VectoredRead + VectoredWrite
means wrapping it with a buffer.
Do you think the
fn is_vectored_writeable() -> bool;
interface that can be found in existing AsyncWrite/AsyncRead/Write/Read solves the problem?
Would it work? Possibly. However, once you start adding boolean checks to see if a trait impl supports a feature or not, this seems to strongly suggest there should be two traits.
That's true.
But if it is split into two traits, then what if the user has an IoSlice and doesn't care about the efficiency anyway?
@Noah-Kennedy I wonder is it possible for async fn read
to work with io-uring without copying.
Suppose that:
- The future returned by
async fn read
under io-uring is lazy and does not actually issue anySQE
until it is polled (and pinned). - The future returned by
async fn read
is notUnpin
(hasPhantomPinned
), thus it must bePin
ed. AndPin
ed object must either be leaked, or must bedrop
ed.
With these two assumptions, can we implement async fn read
in io-uring without copying it into an internal owned buffer?
Nope, because when dropping a future for an in-flight op, it would be unsound still.
You can still make this work via IORING_OP_POLL_ADD, which lets you do readiness-based IO via uring.
Nope, because when dropping a future for an in-flight op, it would be unsound still.
Can we add a drop
implementation that cancels and wait for cancellation/IO completion?
It will be great if we have async drop though.
No, because this would block the runtime.
No, because this would block the runtime.
Thanks, sounds like this can only be fixed with async drop.
Yup