-
-
Notifications
You must be signed in to change notification settings - Fork 2.8k
Description
Previous attempt: #1744.
Summary
The proposal aims to improve the I/O traits in a few ways:
- Enable reads into uninitialized memory without
unsafeexcept in the leaf implementations (TcpStream,File, ...). - Remove the
poll_read_bufandpoll_write_buffunctions from the traits
Improving the API to better support reads into uninitialized memory will be done by matching this RFC.
Removing poll_read_buf and poll_write_buf from the traits implies that vectored operations will no longer be covered by the AsyncRead / AsyncWrite traits. Instead, vectored operations will be provided as methods on structs that support them (TcpStream, ...).
Motivation
See the std RFC for the motivation behind better supporting reads into uninitialized memory.
As for poll_read_buf and poll_write_buf, these functions require function level generics. Including them on the trait causes problems when using trait objects. These functions were originally added to AsyncRead and AsyncWrite to provide vectored operation support at the trait level. However, vectored operations are not proving to be very useful on the traits.
First, very few implementations of AsyncRead / AsyncWrite actually implement these functions. Second, the default implementation of these functions is worse than just calling poll_read and poll_write. The default implementations just forward the first buffer to poll_read and poll_write. However, when vectored operations are not available, the best strategy is to create an intermediate buffer instead of many small buffers. The cost of copies is lower than the cost of many syscalls.
Including vectored operation functions on the trait makes it difficult for the caller to decide how to interact with the byte stream. The fact that callers need to behave differently with the byte stream when the byte stream supports vectored operation vs. when it doesn't implies that vectored vs. non-vectored operations are separate concerns.
In the future, we could provide vectored variants of AsyncRead / AsyncWrite (maybe AsyncVectoredRead and AsyncVectoredWrite). However, as adding this would be forwards compatible, this decision can be punted.
Proposal
The proposed traits are:
pub trait AsyncRead {
fn poll_read(
self: Pin<&mut Self>,
cx: &mut Context,
buf: &mut ReadBuf<'_>
) -> Poll<Result<()>>;
}
pub struct ReadBuf<'a> {
buf: &'a mut [MaybeUninit<u8>],
filled: usize,
initialized: usize,
}
impl<'a> ReadBuf<'a> {
// functions here, see std RFC
}pub trait AsyncWrite {
fn poll_write(
self: Pin<&mut Self>,
cx: &mut Context,
buf: &[u8]
) -> Poll<Result<usize>>;
}See the std rfc for the full ReadBuf type and examples of how to use it.
Changes for the end-user of Tokio
This change will not materially change how the end-user of Tokio interacts with read/write types. Most users go via the AsyncReadExt and AsyncWriteExt helper traits. The functions on these traits will stay the same for the most part. For example:
use tokio::io::AsyncReadExt;
let mut my_buf = vec![0; 1024];
let n = my_stream.read(&mut my_buf).await?;Here the read fn remains the same. The implementation of the read fn wraps the supplied buffer, which is fully initialized in a ReadBuf.
To support reads into uninitialized memory, a new helper function is added to AsyncReadExt:
async fn read_buf<T: bytes::BufMut>(&mut self, buf: T) -> io::Result<usize> { ... }This function uses the BufMut trait provided by the bytes crate to handle reading into uninitialized memory. The function bridges the trait with the ReadBuf struct.
Where should the traits be defined?
A common question is "where should the traits be defined?". Should they be in Tokio, the futures crate, or std?
Tokio aims to release version 1.0 by the end of the year. The 1.0 release will come with stability guarantees as well as a clear support timeline. To do this, all public types must provide at least as strong of a guarantee as Tokio. All types in std provide such a guarantee. As of now, the futures crate does not include stability guarantees.
If the linked RFC is accepted, the ReadBuf type will be included by std at some point. We could then evaluate if using the ReadBuf in std is possible.
We could also propose equivalent AsyncRead / AsyncWrite traits for std. The path would be to first land this proposal in Tokio 0.3 to gain experience and propose the traits as an RFC after that.
That said, there may be an argument to maintain the ReadBuf and traits in Tokio itself for future-proofing Tokio for possible io_uring integration.
Potential io_uring future-proofing
io_uring is a submission-based async I/O API in modern Linux kernels. It is still under active development but is already showing impressive results. Being a submission-based API makes it challenging to integrate w/ Tokio's current I/O model. Also, because it is still under active development and not available on most of today's Linux boxes, supporting io_uring is not a priority for 1.0. However, it would be nice to be able to support it at some level before Tokio 2.0.
At a high level, io_uring works by passing ownership of buffers between the process and the kernel. Submitting a read involves passing ownership of a buffer to the kernel to be filled with data. Submitting a write involves passing ownership of a buffer containing the data to write to the kernel. In both cases, when the kernel completes the operation, ownership of the buffer is passed back to the process.
If Tokio owns the ReadBuf type, it could provide a specialized API for io_uring resources to "take ownership" of the buffer:
use bytes::BytesMut;
struct ReadBuf<'a> {
inner: enum Variant {
Owned(&mut BytesMut),
Slice(....),
},
}
impl ReadBuf<'a> {
fn try_take_buf(&mut self) -> Option<BytesMut> { ... }
fn try_join_buf(&mut self, buf: BytesMut) -> Result<(), BytesMut> { ... }
}A TcpStream implementation backed by io_uring would be able to try to take ownership of the buffer from the ReadBuf. If successful, the buffer could then be passed directly to the kernel without any additional copying. The caller would then call poll_read again once the read has completed. At that point, TcpStream would attempt to "re-attach" the buffer. This, again, would be a no-copy operation.
This works because BytesMut is a ref-counted structure. Re-attaching is done by ensuring that two BytesMut instances originally came from the same allocation. If re-attaching fails due to the caller passing a different buffer, then the read falls back to copying the data into the given read buffer.
The same could be done with AsyncWrite:
trait AsyncWrite {
fn poll_write(self: Pin<&mut Self>, cx: &mut Context, buf: &mut WriteBuf<'a>) -> io::Result<usize>;
}
struct WriteBuf<'a> { ... }
impl WriteBuf<'a> {
// If the WriteBuf is backed by `Bytes`, this is an Arc ref inc.
fn try_clone_buf(&mut self) -> Option<Bytes> { ... }
// No join is necessary
}Risks
It is still much too early to know for sure if the future-proofed API will work well in supporting io_uring. However, the risk of future-proofing AsyncWrite is minimal. The WriteBuf wrapper around a &[u8] does not add overhead besides friction when using the traits. Most users would not interact directly with AsyncWrite and, instead, use the helper functions provided by AsyncWriteExt. As with AsyncReadExt, these functions would not expose the WriteBuf struct. Instead, they would bridge the input buffer with the WriteBuf struct.
Open questions
Should we attempt to future-proof AsyncRead / AsyncWrite traits for io_uring?