[Blog] Safe pinned initialization

Continuing my efforts to try to remove unsafe from the linux kernel's rust API to initialize a Mutex<T> (you heard me, initializing a Mutex is unsafe). I have discovered this RFC for a feature called placement by return. It got me thinking, if it could be used to solve the pin initialization problem. While reading I noticed quiet a few issues that would need to be resolved in order for it to be useful and compiled these ideas into this blog post:

https://y86-dev.github.io/blog/return-value-optimization/placement-by-return.html

I would love to know what you think of this.

7 Likes

Here is my newest blog post about safe pinned initialization:

https://y86-dev.github.io/blog/safe-pinned-initialization/overview.html

9 Likes

Here is the follow-up post about my library and ways to turn it into a language feature:

https://y86-dev.github.io/blog/safe-pinned-initialization/in-place.html

2 Likes

This is neat, though I’m not yet sold on the <- syntax. One note I had was that I’m not sure you need Init and PinInit. You say

But because we do not want to force everyone to stay pinned after initialization, we create a new trait instead[.]

But that only applies to types that don’t implement Unpin, and the pinning guarantee only starts at the first pin anyway, not at construction. Given that, it seems like there are four cases to consider:

  1. The init does not care whether the resulting data is pinned. This seems fine whether or not the result is produced as pinned, because the pinning guarantee starts when calling Box::into_pin or whatever.

  2. The init relies on pinning, and the result is produced as pinned. This is also fine.

  3. The init relies on pinning, but the result is returned unpinned, and the type is Unpin (like a plain i32). This is also fine by the definition of Unpin, though likely a bit weird; what was the point of relying on pinning during initialization? (This is also unlikely to come up in practice; a pin-agnostic initializer is way more likely for such a type, e.g. a length-prefixed string buffer.)

  4. The init pins the data as part of initialization, and the result is returned unpinned, and the type is !Unpin. This is a bug and the library should prevent it.

Given this case analysis, I think it would be “safe” to say that you only need Init as a trait and that it must always uphold the pinning guarantees (unless the type is Unpin). If all you do is field-by-field initialization, that will be true automatically; if you do something funky with this, that’s on you, and no worse than the current implementation.

EDIT: using Unpin to guard non-pinned results does rule out one scenario that your current traits support: in-place construction of a !Unpin type that you nonetheless plan to move. I can’t think of when that would be important, though.

A couple of observations on the first half of the blogpost:

  • I feel like the T and E generic parameter on the Init and PinInit traits could be associated types, it makes more sense if an initializer can initialize only one type and return only one type of error.

  • You could add a blanket implementation of PinInit for any type that implements Init, this way you can both guarantee the invariant that PinInit::pinned_init calls Init::init and also reduce the boilerplate for the caller.

Thanks for the feedback!

I had an intermediate iteration where this library only had Init. I ultimately dropped it because

  • PinInit<T> & Init<T> vs Init<T: !Unpin> & Init<T: Unpin> is a bit more verbose to understand. Especially if you are coming to pinning the first time, I think not having to worry about if the type implements Unpin or not (made more difficult by the fact that is an auto trait) is important. You can immediately see how the initializer is designed to be used.
  • there might be (I have not been able to come up with one, but I also could not rule out the possibility) a usage where you need both fn() -> impl Init and fn() -> impl PinInit<>. So I thought I should keep it this way instead of changing in the future.

Also note that I want things like inserting into a linked list at construction and that needs pinning at construction.

It certainly makes more sense and I havent thought of that. On the other hand, it makes declaring an init function much worse ergonomically:

impl Thing {
    pub fn new() -> impl Init<ToInit = Self, Error = !> {
        ...
    }
}

I can also not specify ! as the default for errors. I think keeping them as generics will not really be bad, because they are not really designed to be implemented. When you require a type to implement Init, you almost always also need the initialized type anyway, so I do not see an issue here. Tell me if this would improve something I havent mentioned.

That is a great idea!

see also

https://mcyoung.xyz/2021/04/26/move-ctors/

https://mcyoung.xyz/2021/12/19/move-ctors-2/

(you linked that, but to make it easier to reach)

An interesting observation is that in-place construction is very similar to #[may_dangle] and the dropck eyepatch. #[may_dangle] says that the only thing I do with T is drop it; in place initialization says that the only thing you do is move the value into place.

I think placement by return could be made to work, although I agree that the implementation probably looks like moveit and your constructors.

(The split to PinInit is important, as what PinInit is saying is that the place is pinned at construction like a C++ object is, whereas Init follows the normal Rust rules of not being initially address sensitive. In theory you could have Init: PinInit with Init being an empty marker trait that the ctor is address insensitive.)

2 Likes

Unfortunately that cannot be done due to downstream crates may implement trait [...]. So I think it needs to stay this way...