Destructors and finalizers in Rust
17 January 2013
Rust features destructors and, as of this moment, they are simply not sound with respect to many other features of the language, such as borrowed and managed pointers. The problem is that destructors are granted unlimited access to arbitrary data, but the type system and runtime do not take that into account. I propose to fix this by limiting destructors to owned types, meaning types that don’t contain borrowed or managed pointers.
Dangers today
The root of our problems lies in the fact that if you have a struct
type S
that has a destructor, it is legal to place an instance of
S
into a managed box (@S
). This is problematic because it implies
that the destructor will run when the managed box is collected, which
can occur at any arbitrary time (in fact, if the garbage collector
were to run on a different thread, it could even occur in parallel
with the owning thread!). I will use the term finalizer
to mean a destructor associated with an object that is owned by a
managed box. In other words, a destructor that can run asynchronously
with respect to the main program.
Note: Many of the thoughts in this post were inspired by Hans Boehm. For those seeking a deeper undestanding, I recommend his paper “Destructors, Finalizers, and Synchronization”.
Problem number one: finalizers and borrowed pointers
In our current system, there is nothing to prevent a borrowed pointer from being stored in a managed box. Although it is sometimes surprising to people that it is legal, this scenario is generally harmless. Although the managed box may outlive the data that the borrowed pointer references, the type system will guarantee that the managed box will never be dereferenced once the loan expires. In other words, you can put a pointer into your stack frame into a managed box, but you could never return that managed box to your caller or store it into any data structure that outlives your stack frame. So we know for certain that if we were to run the garbage collector, that box would be collected. Finalizers change this equation. A finalizer provides a backdoor that would allow borrowed pointers in managed boxes to be dereferenced. See issue 3167 for examples of dangerous programs and more details.
The only way I can see to address this unsoundness is to create a new intrinsic trait that indicates when data can safely be placed into a managed box. I have some thoughts on this at the end.
Problem number two: finalizers and managed data
There is another dangerous situation that can arise which has nothing to do with borrowed pointers. Imagine we have a cycle of managed data and two objects on that cycle have a finalizer. Which finalizer do you run first? Normally, you want to finalize an object X before you finalize any object Y that X references, but because there is a cycle that is impossible to guarantee. Different systems have solved this problem in different ways, none of which are wholly satisfactory.
Problem number three: finalizers and mutable state
Another more subtle problem which can occur with finalizers is that the finalizer may have access to mutable state which is not yet dead. Imagine, for example, a struct whose job is to increment and decrement a counter automatically:
struct SomeDataStructure { value: uint, ... }
struct Counter { s: @mut SomeDataStructure }
impl Counter: Drop {
fn new(s: @mut SomeDataStructure) -> Counter {
s.value += 1;
Counter { s: s }
}
fn drop(self) { self.s.value -= 1; }
}
As long as this counter is stored on the stack frame, everything
should be fine. But if you were to place this counter into a managed
box, suddenly you have a ticking time bomb: now the field s.value
will be decremented at some random time, whenever the garbage
collector elects to collect this managed box. Even if the garbage
collector does not run in parallel with the mutator thread, this can
essentially cause s.value
to be decremented in between virtually any
statement, leading to race conditions that are very similar to those
problems you face with threads and mutable state. Note that due to
compiler optimizations and so forth it is entirely possible for value
to be decremented earlier than you might expect as well as later.
Of all the problems, I am perhaps most worried about this one, because it is relatively easy to overlook. It’s not a soundness issue per se but it can lead to very surprising bugs, particularly in light of aggressive compiler optimization. Hans Boehm goes so far as to say that finalizes require a multithreaded, shared memory context to make any sense, precisely because of Problem #3. Basically, the asynchrony inherent in finalizers is more natural in a parallel language and you have tools like locks to defend against it. If finalizers run in the mutator thread, locks lead to deadlocks and not having locks leads to bugs.
You might think that moving data into a managed box can only cause the destructor to be delayed from when it would otherwise run, but this is not the case. In fact the destructor can also run much earlier than you might expect. Consider this Java program from Boehm’s paper:
class X {
Y mine;
public foo() { Mine m = mine; ...; m.bar(); }
public void finalize() { mine.baz(); }
}
Here, in the foo()
method, the this
pointer may actually be dead
right after the first statement, and so this
can be collected before
m.bar()
is called. Boehm’s point with this example is that, in
Java, this could result in m.bar()
and mine.baz()
executing in
parallel, but in general the behavior is very surprising. I recall
that similar problems were prevalent with Apple’s failed attempt at an
Objective-C garbage collector.
Restricting to owned data
All of these problems are solved by limiting destructors to types which contain only owned data. Borrowed pointers and managed pointers are disallowed, so problems one and two cannot arise. Problem three cannot arise because there is no way for the destructor to directly access shared, mutable state.
Limiting to owned data still permits many interesting use cases for
destructors. You can embed a file descriptor and guarantee it gets
closed. You can ensure that random C resources, such as database
descriptors or blocks of memory obtained from malloc()
, are cleaned
up, since these are typically described by unsafe pointers anyhow.
You can also embed a channel and use it to send messages from the
destructor.
There is one very useful scenario that is ruled out, however, which is basically the “auto counter” (or any “auto adjustment”) type from problem number three. That is, it is often very useful to have some adjustment that will automatically occur when a stack frame exits, and destructors are one common way to achieve that. Of course this is dangerous if abused, as we have seen, but what about the good guys, who don’t put an auto-object into managed data?
The good news is that even with the limitation I propose there are still two valid ways to achieve the auto-pattern, depending on your precise needs. First, if you don’t care whether the auto code executes on failure—and you probably don’t, remember that Rust failures are unrecoverable—you can just use a function with a closure argument:
fn auto_adjust<R>(s: @mut SomeDataStructure, f: &fn() -> R) -> R{
s.value += 1;
let v = f();
s.value -= 1;
return v;
}
Now in your code you can write:
do auto_adjust(s) { ... }
But what if you really do care about failure and you need access to the current stack frame when unwinding? We should be able to provide a function in the standard library to handle this case. That function would look something like:
do defer(|| {
/* This code will execute once the block below exits, even on failure */
}) {
/* This code executes immediately */
}
Naturally we can play around with the precise signature of this function a bit, but you get the idea: you supply two closures to a library function, it executes them as appropriate. Internally, the function would use unsafe pointers and a destructor, but it would never expose the object that carries the destructor to the outside, and thus could ensure that this object is never placed into a managed box.
Caveat: Not quite future proof
In some sense, I am advocating the conservative approach: we begin
with a narrow set of types that can have a destructor, and we can then
expand later if that proves to be insufficient. However, there is a
catch. If we ever wanted to permit borrowed pointers to be referenced
by destructors, the only way that this can be made sound is to limit
the set of types that can be placed into a managed box. Since, at the
moment, any type can be placed into a managed box, this is a backwards
incompatible change. To see what I mean, consider a function like
box()
:
fn box<A>(a: A) -> @A { @a }
This function is legal today, but it would become illegal. This is because
there is no guarantee that A
can be placed into a managed box. So you’d
need to write something like:
fn box<A:Manageable>(a: A) -> @A { @a }
where Manageable
is the hypothetical intrinsic trait that
characterizes types that can safely be placed into managed boxes. Of
course we could change the defaults, so that <A>
no longer means
“any type at all” but rather “the usual set of types you want to do
the usual set of operations” (in which case, perhaps A:
would mean
“any type at all”, I don’t know). But that too is backwards
incompatible.