Wednesday, December 1, 2021

Linear Types Can Help

There’s been a lot of discussion, recently, about how to improve async behavior in Rust. I can’t pretend to have internalized the entire discussion, but I will say that Linear Types feels like it should resolve several known foot-guns when attempting to support async Rust, while also being a general improvement to the language.

Bottom-line up front: Carl Lerche’s example (from https://carllerche.com/2021/06/17/six-ways-to-make-async-rust-easier/) of a surprisingly buggy async application looked like this:

async fn parse_line(socket: &TcpStream) -> Result<String, Error> {
    let len = socket.read_u32().await?;
    let mut line = vec![0; len];
    socket.read_exact(&mut line).await?;
    let line = str::from_utf8(line)?;
    Ok(line)
}

With linear types support, we could change async to async(!ScopeDrop) to make implicit Drop a compile-time failure, avoiding the bug; or perhaps a compiler flag could be used for the crate, to make futures !ScopeDrop by default, so that the exact same code could be run, without introducing an “accidental cancellation” foot-gun.

I’m planning to write three blogs about this, of which this is the first, where I try to indicate how this might work by going through the same examples from Carl Lerche’s great blog post on the subject, using an alternative linear-types approach for the solution. Next time, I’ll talk through the proposal; and then at the end, try to address expected objections. I’ve wanted some version of linear types in Rust for years; now with asynchronous Rust, they seem potentially more relevant than ever.

Does this meet requirements?

select!

Well, a linear-types future can’t be used with select!, since select! is defined to implicitly drop futures other than the first to complete. The language would push you to use a task, just as in Carl’s example.

AsyncDrop

Per Carl’s example, AsyncDrop is difficult in today’s Rust, because there isn’t an explicit .await point. I’d suggest a different AsyncCleanup trait, that reasonably works with linear types, to support behavior something like Python’s context managers:

trait AsyncDrop {
    async fn drop(&mut self);
}
fn with<T, F, O>(object: T, continuation: F) -> O
where
    T: AsyncDrop,
    F: Fn(&mut T) -> O
{
    let retval = continuation(&object);
    object.drop().await;
    retval
}

Then the bug that Carl pointed out here:

my_tcp_stream.read(&mut buf).await?;
async_drop(my_tcp_stream).await;

would be prevented at compile-time: the .await? on the first line would trigger a compilation failure. One would avoid this by using the context-manager approach:

with(my_tcp_stream, |my_tcp_stream| {
    my_tcp_stream.read(&mut buf).await?;
})?;

Get rid of .await

Not necessary with this change. I lean against removing .await, personally: my bias for systems languages is that I want to be able to predict the shape of the machine’s behavior from reading the source code, and getting rid of .await seems likely to make that harder, but I don’t really want to think about that further, others have other biases that are valid for them. More to my point here: linear types encourage a smaller change to the existing ecosystem than guaranteed-completion futures do.

Scoped tasks

Supported (I think) by having task::scope() return a linear type. On the other hand, I’m not yet comfortable with how executor runtimes handle unwinding from panics in unsafe code, so I’m likely missing something important.

Abort safety

I’m not sure this is desirable, at least at first. The #[abort_safe] lint introduces another “what color is your function” problem to the language. That said, if we did want this, we could define another trait, FutureAbort, as below:

trait FutureAbort {
    async fn abort(self);
}
impl<T: ScopeDrop> FutureAbort for T {
    // dropping a ScopeDrop future aborts it.
    async fn abort(self) {}
}

And revise items such as select! to abort() all un-completed futures. This can be made to prevent abort-safe functions from calling non-abort-safe functions relatively easily:

// because the returned future isn't ScopeDrop, it won't
// be abort_safe by default.
async(!ScopeDrop) fn foo { /* not shown */ }
#[abort_safe]
async fn bar() {
    foo().await // compiler error: abort_safe futures cannot await
                // non-abort_safe futures.
}

The default behavior will still be abort-safe, but users are allowed to opt-in to behavior where abort safety isn’t wanted.

I think this covers the bulk of Carl’s uses, and therefore suggests . Linear types are not really an async-Rust feature, but they do (in my opinion) apply nicely, here. The shape of my current thinking about how these can work is more-or-less inferrable from the above, but I wanted to keep this post relatively short, so I’ll save the actual proposal for next time.

Thanks for reading!

No comments:

Post a Comment