Weak References

Data Types and Implementation


Bruno Haible
ILOG GmbH
2005-04-24


This talk explains the benefits and drawbacks of weak references. It generalizes the data types of weak pointer, weak list and weak hash-table. It explains how to implement these data types correctly and efficiently.

The User's Point of View

An object is a piece of memory that is explicitly allocated by the programmer and can refer to other objects in memory. A garbage collector is used to reclaim unused objects. How does the GC determine which objects are unused? It starts from a root set, usually the stacks of all threads and the global symbol table. It follows all pointer references recursively. An object is called reachable if there is a chain of pointers starting in the root set and ending at the given object. The GC thus determines which objects are reachable and then reclaims the memory occupied by the unreachable objects.

What is a weak pointer?

A weak pointer holds an object in a way that does it not cause to be reachable. It means, the object can be GCed. When this happened, accessing the weak pointer's contents returns NIL instead of the original object. In this case we say that the weak pointer has been "broken".

(make-weak-pointer value) => #<weak-pointer value>
(weak-pointer-value weak-pointer) => value, T or NIL, NIL

What is a weak hash-table?

A weak hash-table is a hash-table which does not automatically cause its key - value pairs to be reachable. It means, the GC can reclaim hash-table entries.

There are four kinds of weak hash-tables:
Example: In a debugger that deals with source code forms and filename+linenumber objects:
The table mapping source code forms to filename+linenumber would be of type :key.
The table mapping filename+linenumber to source code forms would be of type :value.
The table mapping source code forms to the function containing it would be of type :key-and-value.

Weak references, a strong feature

The following are examples of uses of weak pointers and weak hash table in practice.

Drawbacks

Weak references represent an extra cost for the garbage collector. The GC has to keep all weak references in an extra list, and perform extra work when determining which objects are reachable. Whereas the time spent for collecting a heap of size N is O(N), the extra time spent in GC for W weak references is O(W2) in some implementations, O(W log W) in other implementations, and O(W) with a higher O constant in other implementations.

Therefore programmers should normally try use a minimum of weak references.

Weak datastructures

There are more weak datastructures, each tailored to particular use cases. All of them are implemented in GNU clisp 2.33.80.

A minimal set of weak datastructures

One can go on inventing weak datastructures similar to these... But what is the minimal set of weak datastructures that an implementation needs to support? There are two answers.

For practical purposes, only the weak pointers, weak :key mappings and weak hash-tables are essential. The others can be reduced to them:
For theoretical purposes, only the weak :key mappings are essential.
So, a good implementation should support all three:

Implementation Support

Here is a survey of the support for weak pointers in various programming systems featuring a garbage collector. We distinguish between different levels of support:

Level 1 - Implementations with weak pointers or equivalent

Common Lisp: GNU clisp 2.33.80, OpenMCL 0.14.1, Allegro CL 6.2, LispWorks 4.3, Corman Lisp 1.1, CMUCL 19a, SBCL 0.8.20
Scheme: GNU guile 1.7.1, MIT Scheme 7.7.1, BBN Scheme, MzScheme 205, Scheme48
Other Lisp: XEmacs 21.4, GNU Emacs 21.1, jlisp 1.03, mindy 1.2
Java 1.5
.NET CLR (mono 1.0.1, pnet 0.6.10)
Smalltalk: GNU Smalltalk 2.1.10
Python 2.4

Notes:
In CMUCL 19a and SBCL 0.8.20, the garbage collector fails to break weak pointers when it should.
In OpenMCL 0.14.1 the weak lists and weak association lists exist but are undocumented.
In pnet 0.6.10, the mere allocation of N weak pointers is O(N^2).
In Python 2.4, weak pointers can only point to objects for which the ability to be used as a weak pointer target has explicitly been declared in the class. And it costs one slot per instance. Which means that most classes cannot be used as weak pointer targets. This defeats many uses of weak pointers.
The list of implementations of languages other than Lisp and Scheme is not complete: There are other implementations of .NET and Smalltalk that support weak pointers.
The list of languages with weak pointer support is not complete: Some implementations of ML and Haskell also support weak pointers.

Level 2 - Implementations with severely restricted weak :key mappings or weak hash tables

Common Lisp: GNU clisp 2.33.80, OpenMCL 0.14.1, Allegro CL 6.2, LispWorks 4.3, CMUCL 19a
Scheme: GNU guile 1.7.1, MIT Scheme 7.7.1, BBN Scheme, MzScheme 205
Other Lisp: XEmacs 21.4, GNU Emacs 21.1
Java 1.5
Smalltalk: GNU Smalltalk 2.1.10

Level 3 - Implementations with weak :key mappings or weak hash tables

Common Lisp: GNU clisp 2.33.80, LispWorks 4.3
Other Lisp: XEmacs 21.4, GNU Emacs 21.1

Level 4 - Implementations with scalable weak :key mappings or weak hash tables

Common Lisp: GNU clisp 2.33.80, LispWorks 4.3
Other Lisp: XEmacs 21.4, GNU Emacs 21.1

Notes: In LispWorks, XEmacs, Emacs, the garbage collection of a chain of N weak :key mappings is O(N2) worst-case. Enough to make for a 3-minutes GC for N = 10000.

The Implementor's Point of View

In the simplest case, the garbage collection consists of two phases:
  1. Mark phase: Recursively mark all reachable objects, starting from the root set.
  2. Sweep phase: Move the marked objects to their new location, and update all pointers to point to the new locations. Then free unused memory pages.
Since the handling of weak objects requires the knowledge which objects are reachable, but the weak mappings and weak hash tables handling can augment this set, the right moment for this handling is right after the mark phase:

1. Mark phase.
1a. Weak object phase.
2. Sweep phase.

All weak data structures are kept in a list that is not part of the root set. In some implementations, this list is built up incrementally: each weak object is entered there when it is allocated. In other implementations, this list exists only during the garbage collection, and is built up during the mark phase.

Implementation - First try

The weak object phase looks like this:

Walk across the weak objects list. When you encounter a weak reference, look whether its target object has been marked. If yes, leave it in place; if not, replace it with #<unbound>.

This loop is adequate for level 1 and level 2, and its running time is O(W), where W is the number of weak objects.

This loop is not adequate for level 3, because it does not mark any object. In other words, it does not change the set of reachable objects. It therefore cannot implement rules like those essential for the weak :key mapping: "If the key is marked, then mark the value as well."

Implementation - Second try

The weak object phase looks like this:

Repeatedly:
Set modified := false.
Walk across the weak objects list.
When a weak object is unmarked, skip it.
When you encounter a weak :key mapping, look whether its key object has been marked. If yes, and if the value is not yet marked, mark the value (like it would have been done in the mark phase), and set modified := true.
Stop when nothing was modified.
[At this point the set of marked objects will not change any more.]
Walk across the weak objects list a last time.
When a weak object is unmarked, skip it.
When you encounter a weak pointer, and its target object has not been marked, replace it with NIL.
When you encounter a weak :key mapping, look whether its key object is marked. If yes, the value object must be marked as well. Otherwise replace both the key and the value object with #<unbound>.

This loop implements weak pointers and weak :key mappings for level 3. However, in the worst case, the number of repetitions in this phase is O(W), thus this phase has a runtime O(W2+Nw), where Nw is the size of the part of the heap that is being marked during this phase! In other words, the GC will handle 1000 weak objects without problem, but will hang the application when 1000000 weak objects are used and the reference graph is unlucky.

LispWorks 4.3, XEmacs 21.4, GNU Emacs 21.1, Hugs98 Mar2005 all have this bug. But it's possible to do better!

Implementation - Third try

The fix to the O(W2) problem of the previous algorithm is to watch more closely the effects of the marking done during the weak object phase, and re-process only small parts of the weak objects list instead of the entire list.

To this purpose, we define the watch set of a weak object to consist of
  1. the weak object itself,
  2. those weak references inside the weak object that act as a "trigger" for the marking of other objects. For weak-pointers this is empty; for weak :key mappings this is the key object; for weak “and” relations and weak “or” relations this consists of all the references inside the weak object.
The total watch set consists of the union of all watch sets of all weak objects in the list. More precisely, it is the union of all the (wobj, wref) pairs over all weak objects wobj. The algorithm makes use of an efficient data structure for the reverse mapping

wref --> set of all wobj such that (wobj, wref) is in the total watch set

This data structure can be an array, sorted by addresses, so that the lookup in it (through binary search) will be O(log W) in the worst-case. Or it can be a hash table, so that the lookup in it is O(1) on average.

Also we need a queue data structure, whose elements are the elements of the total watch set.

The weak object phase now looks like this:

Walk across the weak objects list and build the total watch set, i.e. the list of (wobj, wref) pairs.
Build up the reverse mapping datastructure.
Set queue := empty.
Walk across the weak objects list.
When a weak object is unmarked, skip it.
When you encounter a weak :key mapping, look whether its key object has been marked. If yes, and if the value is not yet marked, mark the value (like it would have been done in the mark phase); however if during this process a wref is marked that is in the total watch set, lookup the reverse mapping and add all corresponding wobjs to the queue.
While the queue is not empty:
Dequeue a wobj from the queue.
When it is unmarked, do nothing.
If it is a marked weak :key mapping, look whether its key object has been marked. If yes, and if the value is not yet marked, mark the value (like it would have been done in the mark phase); however if during this process a wref is marked that is in the total watch set, lookup the reverse mapping and add all corresponding wobjs to the queue.
[At this point the set of marked objects will not change any more.]
Walk across the weak objects list a last time.
When a weak object is unmarked, skip it.
When you encounter a weak pointer, and its target object has not been marked, replace it with NIL.
When you encounter a weak :key mapping, look whether its key object is marked. If yes, the value object must be marked as well. Otherwise replace both the key and the value object with #<unbound>.

Every element of the total watch set can be added to the queue only once (since when it is added, its wref makes a transition from the unmarked state to the marked state). Therefore the total number of elements that are dequeued from the watch set is bounded by the size of the total watch set, i.e. O(W).

The computation time of the entire weak object phase is therefore O((W + Nw) log W) worst-case, if an array was used as a data structure, or O(W + Nw) on average, if a hash table was used.

This is the algorithm implemented in GNU clisp 2.33.80.

Finalizers

A finalizer is a hook that is executed after a given object has become unreachable. The user provides a function taking one argument. When the GC has determined that the object is unreachable, it still keeps the object alive for a moment, and calls the function with the given object. At the same time, the finalizer is unregistered; this guarantees that it is invoked not more than once for a given object.

The handling of finalizers in the GC is similar to the handling of weak mappings:
  1. The reachability status of some objects need to be considered before the sweep phase.
  2. The finalizers can cause some unreachable object to become reachable nevertheless.
For these reasons, the handling of finalizers must be integrated with the weak object phase.

Haskell implementations go even one step further: They merge the weak :key mappings and the finalizers into a single primitive.

References

[1] Simon Peyton Jones, Simon Marlow, Conal Elliott: Stretching the storage manager: weak pointers and stable names in Haskell.

[2] J. J. Hallett, A. J. Kfoury: A formal semantics for weak references.