From: Ralf Jung Date: Tue, 21 May 2019 14:07:44 +0000 (+0200) Subject: post on Stacked Borrows 2.1 X-Git-Url: https://git.ralfj.de/web.git/commitdiff_plain/f4ff855bd04f015aca638a9b6e6f50bb68a1b7ef?ds=sidebyside post on Stacked Borrows 2.1 --- diff --git a/personal/_posts/2019-05-21-stacked-borrows-2.1.md b/personal/_posts/2019-05-21-stacked-borrows-2.1.md new file mode 100644 index 0000000..3e4cbc1 --- /dev/null +++ b/personal/_posts/2019-05-21-stacked-borrows-2.1.md @@ -0,0 +1,122 @@ +--- +title: "Putting the stack back into Stacked Borrows" +categories: rust research +--- + +Less than a month ago, [I announced Stacked Borrows 2]({% post_url 2019-04-30-stacked-borrows-2 %}). +In particular, I hoped that that version would bring us closer to proper support for two-phase borrows. +Turns out I was a bit too optimistic! +Last week, @Manishearth [asked on Zulip](https://rust-lang.zulipchat.com/#narrow/stream/136281-t-lang.2Fwg-unsafe-code-guidelines/topic/safety.20of.20stable.20containers) why Miri rejected a certain program, and it turned out that the issue was related to two-phase borrows: in combination with interior mutability, behavior wasn't always what we wanted it to be. +So, I went back to the drawing board and tried to adjust Stacked Borrows. + +In the end, I decided to give up on "proper" support for two-phase borrows for now, which I [explained here](https://github.com/rust-lang/rust/issues/56254#issuecomment-493756649). +But I also made some tweaks to Stacked Borrows that affect all accesses (not just two-phase borrows), and that's what this post is about. +I am referring to this as "Stacked Borrows 2.1". + + + +## Stacked Borrows: Table of Contents + +Before we start, here's a list of all posts about Stacked Borrows so far. +I am assigning them each a version number to hopefully make it a bit less confusing (I did not exactly foresee an entire series of posts on this subject). + +* [Stacked Borrows 0.1]({% post_url 2018-08-07-stacked-borrows %}) is my initial idea of what Stacked Borrows might look like before I started implementing it. This post is interesting for some of the historical context it gives, but is largely superseded by the next post. +* [Stacked Borrows 1.0]({% post_url 2018-11-16-stacked-borrows-implementation %}) is the first version that got implemented. This post is self-contained; I was not happy with some of my explanations in the original post so I decided to give it another shot. +* [Stacked Borrows 1.1]({% post_url 2018-12-26-stacked-borrows-barriers %}) extends Stacked Borrows 1 with partial support for two-phase borrows and explains the idea of "barriers". +* [Stacked Borrows 2.0]({% post_url 2019-04-30-stacked-borrows-2 %}) is a re-design of Stacked Borrows 1 that maintains the original core ideas, but changes the mechanism to support more precise tracking of shared references. In the following, I will assume you have read at least this post. + +## Is there a stack? + +As I [explained the last time]({% post_url 2019-04-30-stacked-borrows-2 %}#why-not-a-strict-stack-discipline), there's not really a "stack" any more in Stacked Borrows. +I showed a widely-used pattern that would be ruled out by a strict stack discipline, and at least the basic optimizations that motivated Stacked Borrows are fine with this weaker model. +But one thing that I realized is that that pattern just shows that *read accesses* cannot strictly maintain a stack. +So maybe write accesses could? + +And indeed, that's one of the things that changed with Stacked Borrows 2.1: on a write access, instead of removing just the incompatible items above the granting item, we remove *everything* above (and including) the first incompatible item above the granting item. +Or, if we inline the definition of "compatible": +* When writing to a `Unique` item, we remove everything above (this is unchanged to Stacked Borrows 2.0). +* When writing to a `SharedReadWrite`, we keep the *adjacent* `SharedReadWrite` above it, but then remove *everything* above the first "other" item. + +The last point is a change in behavior: the following code used to be legal, and is now UB. +{% highlight rust %} +fn main() { unsafe { + let c = &UnsafeCell::new(UnsafeCell::new(0)); + let inner_uniq = &mut *c.get(); + // stack: [c: SharedReadWrite, inner_uniq: Unique] + + let inner_shr = &*inner_uniq; // adds a SharedReadWrite + // stack: [c: SharedReadWrite, inner_uniq: Unique, inner_shr: SharedReadWrite] + + *c.get() = UnsafeCell::new(1); // invalidates inner_shr + // stack: [c: SharedReadWrite] + + let _val = *inner_shr.get(); // error because the tag is not on the stack any more +} } +{% endhighlight %} +(I am writing variable names in the stack to refer to those variable's tag.) +In stacked Borrows 2.0, the stack at the end would instead have been `[c: SharedReadWrite, inner_shr: SharedReadWrite]`, thus permitting the final access. + +## Let there be structure! + +I am not deeply invested in allowing or forbidding that last example. +However, this change has the nice side-effect that it gives rise to some structure in how the stack looks like and how it can be interpreted, and I think that is helpful when understanding Stacked Borrows. + +Remember that the stack consists of items that grant `Unique`, `SharedReadWrite` or `SharedReadOnly` permission. + +However, `SharedReadOnly` only occurs at the top of the stack (there can be multiple of these at the top, but there will never be another permission above a `SharedReadOnly`). +For example, the stack might end in `[..., a: Unique, x: SharedReadOnly, y: SharedReadOnly]`. +Here, `x` and `y` are read-only shared references that are currently allowed to access this memory. +They are either derived from `a`, or derived from another one of the read-only shared references. +(I am using the non-transitive version of "derived from" here, i.e., I am talking about the *direct* predecessor.) + +Further down in the stack, if we have two neighboring `Unique`, that says that the one further up the stack was "derived from" the one further down. + +But what about `SharedReadWrite`? +If we think of an `&mut UnsafeCell` (which will have a `Unique` permission), then each time we create a shared reference from it, that will add a `SharedReadWrite` above the `Unique`. +We might end up with `[..., a: Unique, x: SharedReadWrite, y: SharedReadWrite]`. +Here, `x` and `y` can all be used interchangeably: reading from or writing to any of these `SharedReadWrite` references will not invalidate any of the others! +The way I think about it is that a bunch of adjacent `SharedReadWrite` permissions together form one "block" on the stack. +Writing to the `Unique` from which they all come (`a` in our example) invalidates the entire block. + +This is a lot like the "block" of `SharedReadOnly` that may exist at the top of the stack. +Except that, from an `&UnsafeCell` with `SharedReadWrite` permission, we can derive a `&mut T` with `Unique` permission! +So we might have a stack like `[..., a: Unique, x: SharedReadWrite, y: SharedReadWrite, b: Unique]` where `b` is derived from `x` or `y` (we don't know which, but that has no effect on what programs are allowed). +In case of nested `UnsafeCell`, we might even have another (block of) `SharedReadWrite` further up the stack! + +So, in general, the stack looks as follows (starting at the bottom): +* First a "stem" consisting of a sequence of "blocks". A "block" in the stem is either a single `Unique` permissions and or a bunch of consecutive `SharedReadWrite` permissions. +* Finally, optionally, a "cap" consisting of a "block" of consecutive `SharedReadOnly` permissions. + +For all of these blocks, we have that each pointer in a block is derived either from a pointer in the next block down the stack, or another pointer in the same block. + +In this framework, we can re-cast the new rule for write access as: when writing to a pointer in some block, remove all blocks further up the stack. + +## But what about reads? + +However, it turns out that the old rule for read accesses wrecks havoc with this block-based intuition that I just described. +Remember that on a read, we removed all read-incompatible items above the granting item---which, when inlining the definition of "compatibility", means removing all `Unique` above the granting item. +If this `Unique` item sits between two (blocks of) `SharedReadWrite` items, that has the unfortunate side-effect of *merging* these two blocks! + +For example, prior to the read access, we might have had a stack like `[x: SharedReadWrite, a: Unique, y: SharedReadWrite]`. In this situation, writing to `x` would (under the new rules) invalidate `y` because it is in a block further up the stack. +However, after reading from `x`, the stack changes to `[x: SharedReadWrite, y: SharedReadWrite]`. +The two blocks got merged into one, and now `x` and `y` can be used interchangeably! +That seems like a strange effect. +Furthermore, this breaks the invariant that in a block of `SharedReadWrite`, each pointer is derived from another pointer in the same block or in the next block down the stack: `y` is derived from `a`, not from `x`! + +To fix this and make sure that the structure I described in the previous section actually holds, I changed read accesses to avoid merging two blocks of `SharedReadWrite`. +Instead of just removing `Unique`, we replace them by a `Disabled` "permission" that grants no access, but just serves to separate two blocks. +With that, the stack in our example becomes `[x: SharedReadWrite, a: Disabled, y: SharedReadWrite]`. +This keeps the two (singleton) blocks of `SharedReadWrite` apart, so even after reading from `x`, a subsequent write to `x` will still invalidate `y`. + +## Conclusion + +That's it already. +You can find a complete description (but without explanation) of Stacked Borrows 2.1 [in the UCG repository](https://github.com/rust-lang/unsafe-code-guidelines/blob/master/wip/stacked-borrows.md). + +I mostly wanted to talk about this structure of "blocks" that still looks very much like a stack, just a more refined version of what we had in Stacked Borrows 1. +Back then, such a block of `SharedReadWrite` was basically represented by a single `Raw` item, not differentiating *which* pointers are allowed to access the memory in this way. +Similarly, the block of `SharedReadOnly` was represented by the "freeze" flag, which excluded some shared references but was still not very precise in encoding which pointers are allowed to read. +The key contribution of Stacked Borrows 2, then, was to refine these rather coarse notions and keep track exactly of which shared references are part of which block. + +For the future, it will be interesting to figure out if we can track raw pointers the same way we are now tracking shared references with interior mutability, or if that will rule out too much existing code. +I don't really have a timeline for this problem though, the next immediate steps involve writing a paper about Stacked Borrows and then writing a PhD thesis about... lots of stuff. ;)