IO Delegation for Arc
— 2020-03-03

When Stjepan and I were designing async-std, one of the core design principles was to make it resemble the standard library as closely as possible. This meant the same names, APIs, and traits as the standard library so that the same patterns of sync Rust could be used in async Rust as well.

One of those patterns is perhaps lesser known but integral to std’s functioning: impl Read/Write for &Type. What this means is that if you have a reference to an IO type, such as File or TcpStream, you’re still able to call Read and Write methods thanks to some interior mutability tricks.

The implication of this is also that if you want to share a std::fs::File between multiple threads you don’t need to use an expensive Arc<Mutex<File>> because an Arc<File> suffices. And because of how we designed async-std, this works for async_std::fs::File as well!

For simplicity in this post we'll be using std::io::* examples instead of the futures-io variants because they're shorter. But since async and sync IO traits are closely related, everything in this post applies to both.

Problems with delegation

A simplified version of async-h1's's accept function has the following bounds (simplified):

fn accept<IO>(url: http_types::Url, io: IO) -> http_types::Result<()>
where
    IO: AsyncRead + AsyncWrite + Clone;

Each call to accept takes a url and some IO object that implements Clone and async versions of Read and Write. The Clone bound is important because it enables freely copying owned handles of the same value within the function. This was the only way to enable this API under Rust's current borrowing rules 1.

1

Once we get "streamable streams" / "non-overlapping streams" we might be able to express this without cloning at all because we can keep references alive in more cases. But that's not scheduled to land anytime soon.

You might expect that if we wrap an IO type T in an Arc that it would implement Clone + Read + Write. But in reality it only implements Clone + Deref<T>. This means that if we want to access any of the Read or Write functions we must first dereference it using &:

let stream = TcpStream::connect("localhost:8080");
let stream = Arc::new(stream);

&stream1.write(b"hello world")?; // OK: Read is available for &Arc<TcpStream>.
stream1.write(b"hello world")?;  // Error: Read is not available for Arc<TcpStream>.

However, there's an escape hatch here: we can create a wrapper type around Arc<TcpStream> that implements Read + Write by dereferencing &T internally:

#[derive(Clone)]
struct Stream(Arc<TcpStream>);

impl Read for Stream {
    fn read(
        &mut self,
        buf: &mut [u8],
    ) -> io::Result<usize> {
        (&mut &*self.0).read(buf)
    }
}

impl Write for Stream {
    fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
        (&mut &*self.0).write(buf)
    }

    fn flush(&mut self) -> io::Result<()> {
        (&mut &*self.0).flush()
    }
}

There are a few shortcomings here though: it's not generic over any type and it's yet another wrapper type around Arc. Wouldn't it be much nicer if Arc exposed Read and Write without the need for another wrapper type?

A better way forward

We implemented a proof of concept of conditional support for Read, AsyncRead, Write, and AsyncWrite on Arc<T> as the io-arc crate. The way it’s implemented is as follows:

/// A variant of `Arc` that delegates IO traits if available on `&T`.
#[derive(Debug)]
pub struct IoArc<T>(Arc<T>);

impl<T> IoArc<T> {
    /// Create a new instance of IoArc.
    pub fn new(data: T) -> Self {
        Self(Arc::new(data))
    }
}

impl<T> Read for IoArc<T>
where
    for<'a> &'a T: Read,
{
    fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
        (&mut &*self.0).read(buf)
    }
}

impl<T> Write for IoArc<T>
where
    for<'a> &'a T: Write,
{
    fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
        (&mut &*self.0).write(buf)
    }

    fn flush(&mut self) -> io::Result<()> {
        (&mut &*self.0).flush()
    }
}

Using higher ranked trait bounds we’re able to implement Read for T if &T: Read. This removes the need entirely for intermediate structs, and allows working directly with an Arc-like construct using any io type. This should be possible to implement directly on Arc, which would remove the need for wrapper types all together.

Are there any alternatives?

The way we solved this in async-std was to implement Clone for TcpStream directly. This shouldn't be a bottleneck in practice, but we haven't done this for other types yet such as File. And similarly: we can't expect std to be able to make the same tradeoffs. Arc<T> exposing Read + Write if available is superior in all regards.

What are the downsides?

All of the bounds in this post are entirely conditional, don't introduce any extra overhead, and don't make use of unsafe anywhere. This should make them a fairly uncontroversial candidate for inclusion in std -- all they do is make an existing pattern easier to use.

What’s next?

It'd be great if these bounds could be made part of std. I haven't contributed much to std yet, and am somewhat daunted by making my first contribution. But maybe this could be a first contribution?

If these bounds get accepted in std for the std::io traits, they'd make an excellent addition for the futures::io traits as well. And finally it'd be great if we could support BufRead and AsyncBufRead. There's an open issue for it on the repo, but we haven't quite figured it out yet.

Conclusion

In this post we’ve shown how Arc interacts with &T: Read + Write today, and explained existing ways of working around its shortcomings. We’ve introduced a novel approach to work around this and published io-arc as a proof of concept how these bounds could be implemented as part of std.

All in all this seems like exactly the kind of quality of life improvement that would make people's lives easier. The trait bounds for conditional detection of &T: Read / Write were incredibly tricky to write, but the resulting usage is quite straight forward!


Thanks to llogiq for helping with the post and the library. And stjepang for helping with the post, and coming up with most of the ideas shared here.