mirror of https://github.com/python/cpython
GH-117759: Update GC docs for incremental collection (GH-126695)
This commit is contained in:
parent
ff0ef0a54b
commit
3966d8d626
|
@ -108,7 +108,7 @@ As is explained later in the
|
|||
[Optimization: reusing fields to save memory](#optimization-reusing-fields-to-save-memory)
|
||||
section, these two extra fields are normally used to keep doubly linked lists of all the
|
||||
objects tracked by the garbage collector (these lists are the GC generations, more on
|
||||
that in the [Optimization: generations](#Optimization-generations) section), but
|
||||
that in the [Optimization: incremental collection](#Optimization-incremental-collection) section), but
|
||||
they are also reused to fulfill other purposes when the full doubly linked list
|
||||
structure is not needed as a memory optimization.
|
||||
|
||||
|
@ -351,38 +351,90 @@ follows these steps in order:
|
|||
the reference counts fall to 0, triggering the destruction of all unreachable
|
||||
objects.
|
||||
|
||||
Optimization: generations
|
||||
=========================
|
||||
Optimization: incremental collection
|
||||
====================================
|
||||
|
||||
In order to limit the time each garbage collection takes, the GC
|
||||
implementation for the default build uses a popular optimization:
|
||||
generations. The main idea behind this concept is the assumption that most
|
||||
objects have a very short lifespan and can thus be collected soon after their
|
||||
creation. This has proven to be very close to the reality of many Python
|
||||
In order to bound the length of each garbage collection pause, the GC implementation
|
||||
for the default build uses incremental collection with two generations.
|
||||
|
||||
Generational garbage collection takes advantage of what is known as the weak
|
||||
generational hypothesis: Most objects die young.
|
||||
This has proven to be very close to the reality of many Python
|
||||
programs as many temporary objects are created and destroyed very quickly.
|
||||
|
||||
To take advantage of this fact, all container objects are segregated into
|
||||
three spaces/generations. Every new
|
||||
object starts in the first generation (generation 0). The previous algorithm is
|
||||
executed only over the objects of a particular generation and if an object
|
||||
survives a collection of its generation it will be moved to the next one
|
||||
(generation 1), where it will be surveyed for collection less often. If
|
||||
the same object survives another GC round in this new generation (generation 1)
|
||||
it will be moved to the last generation (generation 2) where it will be
|
||||
surveyed the least often.
|
||||
two generations: young and old. Every new object starts in the young generation.
|
||||
Each garbage collection scans the entire young generation and part of the old generation.
|
||||
|
||||
The GC implementation for the free-threaded build does not use multiple
|
||||
generations. Every collection operates on the entire heap.
|
||||
The time taken to scan the young generation can be controlled by controlling its
|
||||
size, but the size of the old generation cannot be controlled.
|
||||
In order to keep pause times down, scanning of the old generation of the heap
|
||||
occurs in increments.
|
||||
|
||||
To keep track of what has been scanned, the old generation contains two lists:
|
||||
|
||||
* Those objects that have not yet been scanned, referred to as the `pending` list.
|
||||
* Those objects that have been scanned, referred to as the `visited` list.
|
||||
|
||||
To detect and collect all unreachable objects in the heap, the garbage collector
|
||||
must scan the whole heap. This whole heap scan is called a full scavenge.
|
||||
|
||||
Increments
|
||||
----------
|
||||
|
||||
Each full scavenge is performed in a series of increments.
|
||||
For each full scavenge, the combined increments will cover the whole heap.
|
||||
|
||||
Each increment is made up of:
|
||||
|
||||
* The young generation
|
||||
* The old generation's least recently scanned objects
|
||||
* All objects reachable from those objects that have not yet been scanned this full scavenge
|
||||
|
||||
The surviving objects (those that are not collected) are moved to the back of the
|
||||
`visited` list in the old generation.
|
||||
|
||||
When a full scavenge starts, no objects in the heap are considered to have been scanned,
|
||||
so all objects in the old generation must be in the `pending` space.
|
||||
When all objects in the heap have been scanned a cycle ends, and all objects are moved
|
||||
to the `pending` list again. To avoid having to traverse the entire list, which list is
|
||||
`pending` and which is `visited` is determined by a field in the `GCState` struct.
|
||||
The `visited` and `pending` lists can be swapped by toggling this bit.
|
||||
|
||||
Correctness
|
||||
-----------
|
||||
|
||||
The [algorithm for identifying cycles](#Identifying-reference-cycles) will find all
|
||||
unreachable cycles in a list of objects, but will not find any cycles that are
|
||||
even partly outside of that list.
|
||||
Therefore, to be guaranteed that a full scavenge will find all unreachable cycles,
|
||||
each cycle must be fully contained within a single increment.
|
||||
|
||||
To make sure that no partial cycles are included in the increment we perform a
|
||||
[transitive closure](https://en.wikipedia.org/wiki/Transitive_closure)
|
||||
over reachable, unscanned objects from the initial increment.
|
||||
Since the transitive closure of objects reachable from an object must be a (non-strict)
|
||||
superset of any unreachable cycle including that object, we are guaranteed that a
|
||||
transitive closure cannot contain any partial cycles.
|
||||
We can exclude scanned objects, as they must have been reachable when scanned.
|
||||
If a scanned object becomes part of an unreachable cycle after being scanned, it will
|
||||
not be collected this at this time, but it will be collected in the next full scavenge.
|
||||
|
||||
> [!NOTE]
|
||||
> The GC implementation for the free-threaded build does not use incremental collection.
|
||||
> Every collection operates on the entire heap.
|
||||
|
||||
In order to decide when to run, the collector keeps track of the number of object
|
||||
allocations and deallocations since the last collection. When the number of
|
||||
allocations minus the number of deallocations exceeds `threshold_0`,
|
||||
collection starts. Initially only generation 0 is examined. If generation 0 has
|
||||
been examined more than `threshold_1` times since generation 1 has been
|
||||
examined, then generation 1 is examined as well. With generation 2,
|
||||
things are a bit more complicated; see
|
||||
[Collecting the oldest generation](#Collecting-the-oldest-generation) for
|
||||
more information. These thresholds can be examined using the
|
||||
allocations minus the number of deallocations exceeds `threshold0`,
|
||||
collection starts. `threshold1` determines the fraction of the old
|
||||
collection that is included in the increment.
|
||||
The fraction is inversely proportional to `threshold1`,
|
||||
as historically a larger `threshold1` meant that old generation
|
||||
collections were performed less frequently.
|
||||
`threshold2` is ignored.
|
||||
|
||||
These thresholds can be examined using the
|
||||
[`gc.get_threshold()`](https://docs.python.org/3/library/gc.html#gc.get_threshold)
|
||||
function:
|
||||
|
||||
|
@ -402,8 +454,8 @@ specifically in a generation by calling `gc.collect(generation=NUM)`.
|
|||
... pass
|
||||
...
|
||||
|
||||
# Move everything to the last generation so it's easier to inspect
|
||||
# the younger generations.
|
||||
# Move everything to the old generation so it's easier to inspect
|
||||
# the young generation.
|
||||
|
||||
>>> gc.collect()
|
||||
0
|
||||
|
@ -413,40 +465,24 @@ specifically in a generation by calling `gc.collect(generation=NUM)`.
|
|||
>>> x = MyObj()
|
||||
>>> x.self = x
|
||||
|
||||
# Initially the object is in the youngest generation.
|
||||
# Initially the object is in the young generation.
|
||||
|
||||
>>> gc.get_objects(generation=0)
|
||||
[..., <__main__.MyObj object at 0x7fbcc12a3400>, ...]
|
||||
|
||||
# After a collection of the youngest generation the object
|
||||
# moves to the next generation.
|
||||
# moves to the old generation.
|
||||
|
||||
>>> gc.collect(generation=0)
|
||||
0
|
||||
>>> gc.get_objects(generation=0)
|
||||
[]
|
||||
>>> gc.get_objects(generation=1)
|
||||
[]
|
||||
>>> gc.get_objects(generation=2)
|
||||
[..., <__main__.MyObj object at 0x7fbcc12a3400>, ...]
|
||||
```
|
||||
|
||||
Collecting the oldest generation
|
||||
--------------------------------
|
||||
|
||||
In addition to the various configurable thresholds, the GC only triggers a full
|
||||
collection of the oldest generation if the ratio `long_lived_pending / long_lived_total`
|
||||
is above a given value (hardwired to 25%). The reason is that, while "non-full"
|
||||
collections (that is, collections of the young and middle generations) will always
|
||||
examine roughly the same number of objects (determined by the aforementioned
|
||||
thresholds) the cost of a full collection is proportional to the total
|
||||
number of long-lived objects, which is virtually unbounded. Indeed, it has
|
||||
been remarked that doing a full collection every <constant number> of object
|
||||
creations entails a dramatic performance degradation in workloads which consist
|
||||
of creating and storing lots of long-lived objects (for example, building a large list
|
||||
of GC-tracked objects would show quadratic performance, instead of linear as
|
||||
expected). Using the above ratio, instead, yields amortized linear performance
|
||||
in the total number of objects (the effect of which can be summarized thusly:
|
||||
"each full garbage collection is more and more costly as the number of objects
|
||||
grows, but we do fewer and fewer of them").
|
||||
|
||||
Optimization: reusing fields to save memory
|
||||
===========================================
|
||||
|
|
Loading…
Reference in New Issue