As I said in my note from an hour ago, I've been digging pretty thoroughly into some of the low-level coyotos data structures and how they get translated into Rust. I've hit an issue that I don't know how to deal with, so I want to lay it out and see what advice people may have.
Let me preface this by saying that I've now been working with the borrow checker long enough to pretty much have my head around it. I definitely see benefits for concurrent applications. I also see the benefits of the linear typing that emerges as an (intended) side effect. That said, I now feel a fair bit of confidence that the Rust borrow checker is categorically incompatible with the way Coyotos guards concurrency, and I don't see a path from where Coyotos is to a performant rewrite in safe Rust.
Before getting into the crucial example, a quick reminder: the Coyotos kernel doesn't ever want to deallocate memory in the sense of free(). Literally all of the references being thrown around are &'static T, so borrowing (or, more precisely, dropping) doesn't actually make any sense for them for memory release purposes. The remaining use is to implement the &mut T vs &T exclusion for concurrency purposes, which, as I'm about to describe, is also a problem for the Coyotos kernel.
To illustrate, let's talk about how Coyotos builds [explicitly guarded] cyclic reference graphs pervasively. The most obvious example of this is the troika of Capability, ObjectHeader, and OTEntry:
-----> OtEntry <--------
/ | \
Cap | Cap2, ... CapN
\ v /
----->ObjectHeader<-----
The purpose of the OTEntry is to serve as a canary when target objects are removed from memory. It allows us to unswizzle or invalidate capabilities that didn't get the memo about the object's removal and GC them back to unswizzled form later. Note that there are no back-pointers from objects to their capabilities. That's a change from EROS and KeyKOS, and it took a lot of re-work (and was comprehensively dictated by cache impacts).
There are both invariants and mutexes that keep this troika concurrency-safe. The first is that the Cap necessarily lives inside an object that we already hold a mutex over. The other has to do with the checks required to extract the ObjectHeader*, which can only be distracted by calling (in Rust terms) a method on the capability that performs the following checks on the capability:
- Capability is swizzled and valid (according to OTEntry). Lock the target object for the duration of the system call, fetch the ObjectHeader* from the capability, and proceed.
- Capability is swizzled but not valid (stale - would eventually be GC'd).
- If the OTEntry says target was destroyed, unswizzle to void capability and re-start the system call.
- Otherwise unswizzle the capability to its valid-but-unswizzled form and re-start the system call.
- The capability is unswizzled. Look up the target object by (type, OID). Sub-cases:
- Not found in memory. Initiate object fetch, queue on completion, and abandon current system call.
- Allocation counts do not match. Rewrite capability as void and restart system call.
- Allocation counts match. Rewrite capability in swizzled form and proceed.
In Rust terms, the sequence above is (effectively) the implementation of a borrow on the object named by a capability.
Side notes:
- Changing a capability from swizzled to unswizzled (or the other way) requires marking its containing object dirty, which can trigger a system call re-start as well. That's OK. Actually, it's exactly what we want in this circumstance. Doesn't matter that the borrow hasn't (yet) succeeded, because we'll abandon the control flow if we can't mark the container dirty.
- At the end of this operation, we haven't established that it's OK to write to the target object. That requires a further step that exists mainly to ensure the target object is marked dirty before we write it.
- "Lock" means "to the currently executing CPU and system call." It's completely OK to do that more than once on the same object.
The OTEntry references here could hypothetically be replaced by cursors, but the offset computation introduced to turn those cursors into array entry references would actually be noticeable at the margin. Computing that offset is a dependent and depended-on computation. Every cycle we lose in the kernel is multiplied everywhere.
Unfortunately, the ObjectHeader references can't be turned into cursors. ObjectHeaders appear at the "front" of each durable object type (exception: pages, where they are a side table). Each durable object type lives in a type specific array. Sometimes more than one, because system physical memory isn't actually contiguous on real machines, and that can't be cleaned up in the virtual map. This means that translating a cursor into a reference is both type-dependent and complicated. And not really necessary from a safety perspective, since the type field of the capability serves as a type tag telling us what the containing object type is for this particular ObjectHeader structure and we already have the concurrency guard logic that I've described above. There are a (very) few places where we use this tag to justify a type up-cast to the containing type.
Increasing the size of these references is not an option. Adding a word to every capability because of a fat reference could very well turn into terabytes of added storage requirement on the disk.
I can see a case where we have the prepare() call return a target object &T reference (at least in the case where it returns). Taking mutable reference to the target requires some work that the Rust compiler doesn't know about, because we have to mark the target object dirty along the way. I'm actually not sure if it's possible to intercede in the middle of a reference's mutability upgrade in Rust. Haven't looked yet.
Note that we can have multiple capabilities referencing the same object, and in perverse cases we can end up with two or more being passed during the same system call. This is not an error.
Note also that we have two logically distinct kinds of mutations going on to the target object. One is for the purpose of changing the object's metadata (to mark it dirty). That operation requires a "fence" on the object, but we do not want it to invalidate outstanding references for read purposes. The other (which doesn't always arise) is for modifying the object's data. The Rust reference taking mechanism doesn't recognize that these are logically distinct.
At the moment, I'm not seeing a Rust path through this example without resorting to raw pointers. Not because the pointers are invalid, but because the borrow checker's concurrency guards are so thoroughly in opposition to the Coyotos concurrency guards and object graph. The ObjectHeader* pointers appear so pervasively that I think this amounts to abandoning the baked-in Rust concurrency guards for all cross-CPU purposes.
I'm totally willing to keep going for a bit to find out what the impact really is. Even if it turns out to be a negative result, it's an interesting negative result.
What do others see here in terms of "how to convert to Rust" that I am failing to see?
Jonathan