@RalfJung, thanks for writing this up! Great post. Iâm currently on vacation in Greece, and what time I have I have just spent catching up on this thread. I just wanted to leave some scattered thoughts, since I donât have time to write a comprehensive post.
Some thoughts on dynamic checking, frame rules, and open worlds =)
To my mind, the most important part of this design is that each function is responsible for âprotectingâ its own assumptions. So, as a simple example, if I call foo(x, y)
, then I retain the locks on my other variables (e.g., z
), and hence if foo
should try to mutate z
, an error arises. Whatâs nice about this setup is that I do not have to know about all data that exists: I only protect the values reachable from my local variables (in a transitive sort of way).
I think this is a key property for having things be dynamically testable, and in particular for doing dynamic testing when interfacing with random C libraries and other unsafe code. Ultimately I envision that when running cargo test
on a project with unsafe code, we will be using a sanitizer â perhaps running in some environment like valgrind. This means that the Rust code canât expect âfullâ understanding of whatâs going on â i.e., what memory is allocated and how. But it can expect that if it has a local variable z
of type &mut T
, and it invokes a safe function without giving it access to z
, then *z
is not accessed.
Why I think locks make sense
As for the decision to use locks, I was initially opposed to this â I wanted to use a model where, when you dereferenced a pointer, we checked at that point if the reference had been invalidated. But after talking with @arielb1 at the compiler design sprint I changed my opinion. In particular, @arielb1 pointed out if that you have some code like this:
let x: &mut usize;
*x += 1; // A
foo();
*x += 1; // B
Ideally, we would remove statement B and replace statement A with *x += 2
. This is possible in the locking based model â we retain the lock when calling foo()
, and hence we know that if anyone were to try and read *x
(and hence observe our optimization), that would be UB.
However, in the validating model, we canât do the optimization. The idea of a validating model would be that we would check at statement B that foo()
had not accessed *x
in any way. That might seem equivalent to holding a lock, but itâs not if foo()
never terminates, or if foo()
were to panic. That is, imagine foo()
were to (illegally, through some alias) access *x
, and then abort. It would in that case observe that we had added 2
instead of 1
, invalidating our optimization. Locks avoid this problem.
As Ralfâs post stated, this locking model is currently really only intended to capture the patterns that safe code uses. This implies something kind of like the âtootsie popâ model; however, itâs certainly possible to imagine applying Ralfâs proposal to unsafe code, though I think it will invalidate a lot of the extant code.
Sachertorte: Three-layer tootsie pops
That said, I still think the basic idea of âtootsie popâ makes sense as a way for us to get the best of both worlds: highly optimized safe code and correct unsafe code. I would like to have more discussions around this point, but I feel like any kind of âone size fits allâ rules will have to give on some of those points (i.e., optimize less, or invalidate a lot).
I do however think my initial proposal had a lot of flaws. The way I now think of it is that there are three layers to consider. Iâm going to call this model the Sachertorte model, since Sachertorte has three layers (well, three kinds of layers, anyway):
- Safe actions â do not need any additional permissions to execute correctly beyond the ones implied by their operands.
- Unchecked actions â in Ralfâs terms, these functions do not require additional locks to execute correctly, but may require other conditions be met. An example of an unchecked action is calling
String::from_utf8_unchecked
â as far as the types are concerned, this could be a safe function, but we do require the additional condition that the buffer is valid UTF-8. At present, there are no âbuilt-inâ unchecked actions, but users could declare some by labeling a function as unchecke (indeed, we already have this naming convention).
- Unsafe actions â require additional locks to execute beyond those implies by the types of its operands. The primary example of an unsafe action is dereferencing a raw pointer (
*p
) â the operand here is p
, and its type (*const T
or *mut T
) implies no locks at all, so clearly we must have some other reason to think we hold the locks. There are other built-in intrinsics which are unsafe actions â transmute, ptr::offset()
, etc. And of course users can declare unsafe
functions of their own, and calling such a function is considered an unsafe action.
Currently, we conflate unchecked and unsafe in the language with a single keyword. I think this is the root of why the unchecked_get
example caused problems.
To be clear, in this Sachertorte model, we would make unchecked be a part of the language (e.g., with an unchecked
keyword). You could then write unchecked fn get_unchecked(&self, u: usize) -> &T
as the proper declaration for get_unchecked
. To call such a function, you would write an unchecked { .. }
block â such a block would be allowed to do unchecked things, but not unsafe things (i.e., no raw pointer deref, or calling unsafe functions).
Accordingly, functions that contained only unchecked actions would retain all the normal locking rules of safe code (and hence be optimizable in the same way). Functions that take unsafe actions (but declare a safe interface) would be handled as @RalfJung described (releasing their locks on entry). Functions declared as unsafe in their interface would neither take nor acquire locks.
Grumble grumble problems
I donât think the Sachertorte model is perfect. Iâd like to dig a bit more into its flaws. Here are two that come to mind (leaving aside the basic philosophical objection to distinguishing âsafeâ and âunsafeâ code to begin with, which is of course also important):
Limitations on optimizing unsafe code. One flaw with this proposal is that an unsafe fn
â i.e., with an unsafe interface â is allowed to access all of its callerâs state (subject to other rules that prohibit locals from being accessed). Itâd be nice to have the ability to give more limits there. This may not be such a big problem, though, since unsafe functions tend to be private, and hence in the same codegen-unit â this makes them amenable to more global compiler analyses.
Module boundaries? I like this idea of unchecked, but there is still some uncertainty in my mind in terms of how to deal with the scoping. In the original post, I talked about the need to sometimes extend âunsafetyâ to the surrounding module. This makes sense, in some sense, because unsafety and privacy are highly aligned, but also feels uncomfortable. The main reason to need this is that people sometimes write private functions (e.g., fn deref(x: *const u32) { unsafe { *x } }
) that in fact have an unsafe interface, in the sense of requiring addâl locks, but are declared as safe. Itâd be interesting to drill more into such examples and see how they play out. It may be that if we had dynamic checking, we could render such cases UB (and detect them reliably), and sidestep this problem.
Other ideas
So yeah, I have to go. Iâm not satisfied with this post, in particular Iâd like to make a more affirmative case why I think that having layers is important to being able to have all the optimizations we want. But I wanted to toss these ideas out there anyway. =) More later!