Understanding Pin (for C and C++ Developers)
Pin is pretty important for Rust's recently-released async.await
features. I read the docs. I didn't get it1. This exercise is what it
took for me to understand why Pin
is important.
Opening up the documentation, the page starts with a discussion about
Unpin
. Unpin
is weird. Basically, Unpin
says "yeah I know this
is pinned but you are free to ignore that." My gut reaction to Unpin
was "why would you need this at all?" Doesn't this defeat the purpose
of Pin
? Why is everything Unpin
by default??
Continuing on, there's a list of rules which must be adhered to in the
unsafe
constructor for Pin
. I found this constraint for types
which are !Unpin
to be particularly mysterious:
It must not be possible to obtain a
&mut P::Target
and then move out of that reference (using, for examplemem::swap
).
Other guides to Pin
also noted that calling mem::replace
, which
also takes a mutable reference, cannot not be allowed.
Let's look at this again:
It must not be possible to obtain a
&mut P::Target
and then move out of that reference (using, for examplemem::swap
).
Clearly moving is significant here, what does that mean exactly, and why is this such a big deal?
C++
I'm more familiar with C++ and my familiarity is probably where my misunderstandings are coming from. Let's start by understanding what it means to move something in C++.
Consider the following struct
:
struct Thing { Thing(uint64_t id) : id(id) { } // The move constructor is only required to leave the object in a // well defined state Thing(Thing&& other) : id(other.id) { other.id = 0; } Thing& operator=(Thing&& other) { id = other.id; other.id = 0; return *this; } // non-copyable for clarity Thing(Thing const&) = delete; Thing& operator=(Thing const&) = delete; uint64_t id; };
C++ says that a move ctor must leave the object moved from in an undefined, but valid state.
int main() { Thing a(10); Thing const& ref = a; Thing c = std::move(a); // moves a, but leave in defined state printf("ref %zu\n", ref.id); // prints 0 }
Next, consider this2 implementation of swap
and it's usage:
template <typename T> void swap(T& a, T& b) { T tmp = std::move(a); // lots of moves a = std::move(b); // move again b = std::move(tmp); // oh look, move again! } int main() { Thing a(1); Thing b(2); Thing& ref = a; swap(a, b); printf("ref %zu\n", ref.id); // prints 2 }
As far as I know, this is totally valid C++. The reference is just a pointer to some chunk of memory, and, all of the moves that we did are defined to leave the moved-from object in a "valid" state (you might just have to be careful with them).
Let's consider one last struct.
template <typename T, size_t N> struct ring_buffer { std::array<T, N+1> entries; // use one extra element for easy book-keeping // Store pointers. This is bad, there are better ways to make a ring // buffer, but the demonstration is useful. T* head = entries; T* tail = head+1; // ... };
head
and tail
both point to elements of entries. C++ will
generate a default move constructor for us, but the default is just a
memcpy
. If it runs, we'll end up with pointers that point into the
wrong array. We must write a custom move constructor.
ring_buffer(ring_buffer&& other) : entries( std::move(other.entries) ) , head( entries.data() + (other.head - other.entries.data())) // adjust pointer , tail( entries.data() + (other.tail - other.entries.data())) // adjust pointer { other.head = other.entries.data(); other.tail = other.head + 1; }
So, in C++, a move
is just another user defined operation that you
can take advantage of in some special places.
Rust
Let's do the same exercises again in Rust, starting with the Thing
struct.
struct Thing { pub id: u64 } impl Thing { pub fn new(id: u64) -> Self { Self { id } } }
Trying to port the first example directly into Rust won't work.
fn main() { let a = Thing::new(10); let r = &a; let c = a; // this is a move, but won't compile println!("ref {}", r.id); }
The compiler doesn't like this. It says:
error[E0505]: cannot move out of `a` because it is borrowed --> ex1.rs:16:13 | 15 | let r = &a; | -- borrow of `a` occurs here 16 | let c = a; // this is a move, but won't compile | ^ move out of `a` occurs here 17 | 18 | println!("ref {}", r.id); | ---- borrow later used here
Rust is telling us that it knows we moved the value, and, since we moved it, we can't use it anymore. What does this mean though? What is actually going on?
Let's try to find out with some unsafe and undefined-behavior inducing Rust. The first time I tried something like this, I wasn't sure what to expect, but hopefully this example is clear.
fn main() { let a = Thing::new(1); let r: *const Thing = &a; let c = a; println!("ref {}", unsafe { (*r).id }); }
This code is UB, so the output may not be stable. At the time this article was
written though, this prints "1" because the compiler reused the stack space used
by the object named a
to store the object named c
. Unlike C++, where an
"empty husk" of a
would need to be left behind, after a
is moved, the
compiler "knows" that no one can access it anymore, so it can reuse the storage.
This behavior is very different from the C++ move. The Rust compiler
knows about the move and can take advantage of the move to save some
stack space. Without writing unsafe code, there is no way you'd ever
be able to access fields from a
again, so how the compiler wants to
use that space occupied by a
after the move is entirely the
compiler's decision.
Rule number 1 of Rust move: The compiler knows you moved. The compiler can use this to optimize.
The next C++ example was a swap
. In C++, swap
calls some move
constructors to shuffle the data around. In the C++ swap example,
these (implicit) move
constructors where just memcpy
.
Swap in Rust isn't as straightforward as the C++ version. In the C++ version, we just call the user defined move constructor to do all of the hard work. In Rust, we don't have this user defined function to call, so we'll have to actually be explicit about what swap does. This version of swap is adapted from Rust's standard library:
fn swap<T>(a: &mut T, b: &mut T) { // a and b are both valid pointers unsafe { let tmp: T = std::ptr::read(a); // memcpy std::ptr::copy(b, a, 1); // memcpy std::ptr::write(b, tmp); // memcpy } }
Roaming again into undefined-behavior territory:
fn main() { let mut a = Thing::new(1); let mut b = Thing::new(2); let r: *const Thing = &a; swap(&mut a, &mut b); println!("{}", unsafe { (*r).id }); // prints 2 }
This example is nice because it does what you'd expect, but it
highlights something critical about Rust's move semantics: move
is
always a memcpy
. move
in Rust couldn't be anything other than a
memcpy
. Rust doesn't define anything else associated with the struct
that would let the user specify any other operation.
Rule number 2: Rust move is always just a memcpy
.
Now, let's think about the ring buffer. It is not even remotely idiomatic to write anything like the C++ version of the ring-buffer in Rust3, but let's do it anyway. I'm also going to pretend that const generics are finished for the sake of clarity.
struct RingBuffer<T, const N: usize> { entries: [T; N+1], head: *const T, // next pop location, T is moved (memcpy) out tail: *mut T, // next push location, T is moved (memcpy) in }
The problem now is that we can't define a custom move constructor. If this
struct is ever moved (including the move-by-memcpy
in swap/replace), the
pointers stored will point to the wrong piece of memory.
The rust solution to this is to mark your type as !Unpin
.
Once something is marked as !Unpin
, getting a mutable reference to
it becomes unsafe. If you get a mutable reference to a pinned type
which is !Unpin
, you must promise to never call anything that moves
out of the type. I have thoughts on the actual feasibility of
following these rules, but that's a topic for another time.
Futures/async.await
Hopefully now, we can understand why this is prerequisite for async.await support in Rust.
Consider this async function:
async fn foo() -> u32 { // First call to poll runs until the line with the await let x = [1, 2, 3, 4]; let y = &x[1]; let nxt_idx= make_network_request().await; // next call to poll runs the last line return y + x[nxt_idx]; }
The compiler will roughly translate this function into a state machine
with 2 states. That state machine is represented by some struct, and
the state is updated by calling the poll
function. The struct used
to store the data for this state machine will look something like
this:
struct StateMachineData_State1 { x: [u32, 4], y: &u32, // ignore lifetime. This will point into `x` }
Since y
is a reference (pointer), if we move
(memcpy
) the
intermediate state, we'll be messing up our pointers. This is why
Pin
matters for async.