Memory and garbage collection
References, cyclic garbage, weak caches, and memory diagnosis
Reachability
Names, containers, attributes, closures, caches, and stack frames can hold strong references. An object remains reachable while those paths lead to it. del name removes one binding; other owners may retain the object.
CPython combines reference counting with a cyclic collector. Objects can remain in an unreachable cycle after outside owners disappear. The collector can reclaim such cycles, but exact destruction timing is not a language guarantee:
import gc
import weakref
class Document:
pass
item = Document()
item.link = item
observer = weakref.ref(item)
retained = [item]
del item
print(observer() is retained[0]) # True
retained.clear()
gc.collect()
print(observer() is None) # TrueThe list first retains the document. Clearing it leaves only the self-cycle; the explicit collection request handles that controlled unreachable cycle in CPython. The weak observer does not keep it alive.
Weak references
A weak reference observes an object without owning it. Retrieve the referent once and hold that local strong reference during use. Checking once and dereferencing again can race with loss of the last owner.
Ordinary user-defined instances generally support weak references; ordinary lists and dictionaries do not directly. A weak-value index makes objects discoverable while another owner retains them:
import gc
import weakref
class Artifact:
pass
artifact = Artifact()
cache = weakref.WeakValueDictionary({"latest": artifact})
owners = [artifact]
del artifact
print("latest" in cache) # True
owners.clear()
gc.collect()
print("latest" in cache) # FalseUse weak indexing when misses can reload or recreate values. Use a bounded strong cache with eviction when retention is required. Check for other strong owners in callbacks, keys, and auxiliary containers.
Diagnosing growth
- A cycle is not automatically a leak. An unbounded reachable cache can retain memory without any cycle;
gc.collect()cannot remove reachable data. - Reclaimed objects need not cause an immediate drop in process memory. Python's allocator can retain memory for reuse.
- Look for retained paths through caches, callbacks, closures, suspended generators, and stored tracebacks. Entry count alone does not measure payload size.
- Use context managers or explicit
close()for resources; finalizers are not a dependable lifetime boundary.
Where supported, start tracemalloc before the workload, take snapshots, and compare with after.compare_to(before, "lineno"). Warm up imports and caches before measuring steady state. Traces cover Python allocations, not all native or OS memory; sys.getsizeof does not recursively total an object graph. Exact sizes vary by runtime, and some browser builds omit allocation tracing.
Put it into practice
Implement the exercise in a full-screen workspace, then run its tests.