[gclist] Finalization, collection, etc.
Jerry Leichter
leichter@smarts.com
Thu, 14 Mar 1996 15:54:51 -0500
The problem of finalization running "soon enough" in a GC environment is a special case of an underlying problem. David Gelernter wrote a couple of
papers on some of these issues in the context of parallelism back in the early
'80's.
Consider a variable and the value bound to it. There are two separate
attributes we can assign to it: Its visibility - from what combination of
execution location and time the variable is visible - and its
lifetime - the range of execution time over which its value must be preserved.
In most languages today, we use static scoping, so only execution location, not
time, determines visibility. Give this, the two attributes are, in p
principle, decoupled: Visibility is tied to space, lifetime to time. However,
in languages with stack allocation, *for the most part*, we bind the two
together: A variable's value remains live exactly as long as the variable
itself remains visible (i.e., the stack frame it's in continues to exist). For
the most part, we specify visibility, imputing lifetime. (The ALGOL OWN
declaration - or the C static storage class for an object declared within a
lexical scope - explicitly define lifetime. In these, and in most cases, the
only lifetime you can specify is "until the program exits".)
The notion that lifetime and scope are one and the same is so pervasive in
traditional languages that people don't even realize that the two notions are
distinct. C++ constructors and destructors for stack-allocated objects, for
example, are defined with this as the underlying assumption: The constructor is
called exactly when the object
it constructs comes into scope; its destructor is called exactly when that
object goes out of scope. All sorts of C++ techniques rely on this close
connection - e.g., classes that take out a lock on construction and release the
lock on destruction.
Dynamically allocated objects in traditional, non-GC languages have a problem:
Explicitly allocated memory has a lifetime that extends to its deletion point.
This is completely independent of scoping, which is exactly why you can get both
memory leaks and dangling pointers.
Languages with GC tie lifetime to accessibility, and accessibility is in turn
the transitive closure of visibility. *In principle*, the lifetime of an object
ends the moment it is no longer accessible. The problem is that, unlike
visibility from a stack frame, accessibility is usually expensive to check.
Only reference counting can practically detect loss of accessibility the moment
it occurs (at least in the acyclic case).
Consider again C++ destructors for stack objects. They execute only at block
end, even in cases where one could detect that the object has become inacces-
sible earlier (i.e., just after the last reference to the variable in its
declared block). So C++ destructors don't execute "as soon as needed" either;
in fact, a reference-counted implementation might very well execute finalizers
much earlier than C++ would!
What the C++ design gives the programmer is a clear synchronization point: At
the end of the block, all destructors "pending" for the block are executed.
This seems natural, again, because in C-like languages we identify visibility
with lifetime. For non-stack allocated objects, the delete statement is the
(perhaps incorrect, as there may be other pointers) synchronization point.
In the general GC case, there are no obvious synchronization points. One could,
I suppose, call the GC at the exit from each block - recovering exactly the C++
destructor semantics for objects that were only accessed from locals within the
block. Combining a generational collector with appropriate compiler
optimizations (escape analysis), this could probably even give decent
performance. For the general case, where the actual access pattern isn't really
stack-like, there is no obvious synchronization point. The programmer will have
to provide one. Using a delete statement as a hint - it not only kills the
reference to the object, but if the object has a finalizer triggers a GC "very
soon" to see if the finalizer needs to be called - will work, but puts the onus
on the *user* of the abstraction, not is implementor. The way to put it back on
the implementor is to allow him to declare the object as "needing quick
finalization". Any time a pointer to such an object is deleted, a GC is
triggered "very soon". (Of course, we could just say that having a finalizer
implies this treatment! Simpler, but less flexible, since there are probably
many finalizers that *don't* need special treatment.) In a statically typed
language where this flag is an attribute of the type, this can be implemented
with little overhead. (It's much more expensive in other cases.)
Of course, any such triggered GC'ing is unlikely to be compatible with
incremental, much less real-time, collectors. Reference counting seems to me
the only hope in such cases.
-- Jerry