Skip to content

io-async: Use ReadReady/WriteReady type state to avoid generating await points #680

@chrysn

Description

@chrysn

A large concern moving from nb to async is that async reads lock the destination buffer across an await point: doing a let mut buf = [0; 128]; let len = await data.read(&mut buf)?; process(&buf[..len]).more().await(); turns the buf into a part of the future state machine, whereas a manually constructed state machine branch match self { AwaitRead => { let mut buf = [0; 128]; let len = data.read(&mut buf)?; DoMore(process(&buf[..len])) }} can do with a short-lived stack allocation.

The ReadReady/WriteReady traits work well in their way for the (blocking or immediate) embedded-io crate, but the state machine building process of the -async variant could use more guarantees. After ReadReady came back successfully, there will be an opportunity to read data immediately (or an error), but even if all sorts of link-time optimization were factored in into constructing the states (AIU they are not), the compiler can not see eg. through an OS's state that promises (under penalty of internal panics) that something is available immediately.

As mitigation, I suggest altering the ReadReady trait (and WriteReady analogously) such that calls can be made like this:

let ready_reader = reader.read_ready().await?;
let mut buf = [0; 128];
let len = ready_reader.read(&mut buf); // No await here!
let processed = process(&buf[..len]);
// drop(buf) happens here implicitly
more(processed).await?;

It could be implemented roughly like this:

enum ReadReadyResult<'r, R: Read> {
    Ready(&'r mut R)
    Error(R::Error)
}

impl<'r, R: Read> ReadReadyResult<'r, R> {
    // really just a convenience thing; all actual work happens in the Read
    #[inline]
    fn read(self, buf: &mut [u8]) -> Result<usize, R::Error> {
        match self {
            Ready(r) => R::read_after_ready(self, buf),
            Err(e) => Err(e),
        }
    }
}

trait Read {
    async fn read_ready(&mut self) -> ReadReadyResult<'_, Self>;

    fn read_after_ready(rrr: ReadReadyResult<'_, Self>, buf: &mut [u8]) -> Result<usize, Self::Error>;
    // maybe this could even be provided: that might require turning the reference into a pinned reference so
    // it can be polled once with an assertion that it is Ready. The async read_ready would probably manage
    // to get such a pin.
}

Note that I think it might be a good idea to just make ReadReady async-return only returns the boolean/type readiness (ie. we'd have a struct ReadIsReady(R) instead of the enum ReadReadyResult) and then waits for the actual read to flush out any error – but then, I don't know why it was implemented that way in the first place.

If the team prefers to discuss this over a PR-style implementation, I can write it out into it (but my guess is that this benefits more from a preliminary discussion over jumping right in).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions