[Python-Dev] Cyclic GC issues
Jason Evans
jasone at canonware.com
Sun Oct 10 21:39:58 CEST 2004
Since the spring of 2003, I have been developing Crux, which is a computer
program for phylogenetic inferencing (bioinformatics) research. In March
of 2004, I switched Crux to using Python from having used a different
embeddable interpreter. For the most part, I have been very happy with
Python, but Python's garbage collector has been a major source of
frustration.
Below, I describe my trials and tribulations with Python's GC. I also
offer some suggestions for changes to Python; if any of the proposed
changes receive favorable feedback, I am willing to help develop patches to
Python. Naturally, if I am somehow abusing Python, and there are better
ways to do things, I'd be happy to hear how to improve Crux.
The important aspect of Crux is that it deals with trees. These trees are
unrooted (there is no up or down), and multifurcating (nodes can have an
arbitrary number of neighboring nodes). Thus, the trees are
self-referential, and without the cyclic GC capabilities of Python, there
would be little hope of making these trees integrate well with Python.
Following is a diagram that illustrates the linkage between various objects
for a simple tree. Crux exposes all of the components of the trees as
Python objects. All lines in the diagram represent bi-directional
references (except for the T-->N line). Every object refers to the tree
object; those lines are left out in order to reduce clutter.
T: Tree N N
N: Node \ /
E: Edge R R
R: Ring \ /
E E
\ /
R---------R
/ \ / \
/ \ / \
| \ / |
| \ / |
| T--->N |
| | |
\ | /
\ | /
\----R----/
|
E
|
R
|
N
At the C (not Python object) level, the R-E-R construct is actually a set
of structures that are allocated/deallocated as a single unit. Edges are
*always* connected to two rings, so there's no point in allocating these
separately.
Also, lone ring objects actually are rings with one member; they refer to
themselves (prev and next pointers).
That should be enough information to understand the problems I encountered.
1) I originally had lone rings refer to themselves (ob_refcnt started out
at 3; 2 self-references and one reference held by the associated edge).
This didn't work. It appears that the cyclic GC does not actually
calculate the number of live references to objects (references that are
reachable by traversing all objects accessible from the root set);
instead it assumes that if tp_clear() doesn't drop the reference count
to a number that equals the number of references from live objects,
there must still be references from live objects. Unfortunately,
visit()ing self does not work, so there is no way to convince Python
that all references are from unreachable objects.
Working around this in Crux requires a lot of extra reference counting
complexity, because there are three different cases for reference
counts, depending on how many members there are in a ring (1, 2, or 3+
members).
2) This issue is really just a different manifestation of issue (1).
At the C (not Python object) level, each node only actually stores a
pointer to a single member of the associated ring. Given a single ring
member, it is possible to traverse the ring and reach all other ring
members. As mentioned in issue (1), the cyclic GC expects tp_traverse()
to call visit() once for each reference held. It is not enough for a
node to visit() one ring member; it must visit() all ring members, in
order for the GC to count how many references are from unreachable
objects, versus reachable from the root set.
In summary, issues (1) and (2) are due to how the cyclic GC does the
"marking" phase of mark/sweep GC. My expectation of mark/sweep GC is
that it should be sufficient to assure that all objects reachable from
the root set are visit()ed at least once; it should not be important how
many times each unreachable object is visit()ed.
I don't have a deep enough understanding of the Python interpreter to
give a detailed suggestion for improvement. I suspect that determining
the root set is not an easy operation; if this is the case, then I think
we're stuck with the current design. If determining the root set *is*
easy (or even possible), then I would suggest using a standard
mark/sweep collector, where unreachable objects are scheduled for
destruction. tp_traverse(), tp_clear(), and tp_dealloc() would retain
the same structure; the only difference would be the logic that
determines which objects can be destroyed.
3) A strange thing can happen when tp_clear() is called. Suppose that an
edge object is being cleared, and it drops its references to the
associated rings. If ob_refcnt of one of the rings drops to 0 as a
result, Python will tp_dealloc() the ring *right* *now*, without ever
calling tp_clear() on the ring. That means that I have to keep track of
whether tp_clear() has been called on objects, if it is at all important
that tp_clear() be called, so that I can manually do so in tp_dealloc(),
if necessary. It is in my opinion reasonable to have cleanup code in
tp_clear(), with the assumption that it will be called precisely once,
but Python makes me do extra work to make sure that this happens.
This should be pretty easy to change. A single bit per object is needed
to keep track of whether tp_clear() has been called. I think this only
needs to be done for object types that support cyclic GC.
4) There is no way to control the order in which objects are
tp_dealloc()ed. This is a problem for the R-E-R construct, since at a
low level, these objects are always linked together. What I would like
to do is defer tp_dealloc() on the edge until after both rings have been
deleted. Instead, I am forced to use a reference-counted deletion
function.
Not calling self->ob_type->tp_free() on the edge in tp_dealloc() until
later is not a reasonable option, because this defers deletion of the
edge until a later round of garbage collection.
This could be addressed in the Python interpreter by paying heed to the
return value of tp_dealloc(). If the return value is non-zero, move the
object to the end of the list of objects to be destroyed, so that
destruction is tried later. This allows the module to impose its own
destruction ordering.
I look forward to feedback.
Thank you,
Jason Evans
More information about the Python-Dev
mailing list