Python Garbage Collection — Generational GC: Practice Problems & Exercises
Practice: Garbage Collection — Generational GC and Cycle Detection
← Back to lessonEasy
Predict the output. Think carefully about how many objects the GC actually collects.
import gc
class Node:
def __init__(self, val):
self.val = val
self.next = None
a = Node(1)
b = Node(2)
a.next = b
b.next = a # creates a reference cycle
del a, b
print(gc.collect())Solution
4
Why 4, not 2: Most engineers expect 2 because there are two Node objects in the cycle. But gc.collect() returns the total count of all unreachable container objects collected. Each Node instance stores its attributes in a __dict__, and those dicts are themselves GC-tracked container objects:
Node(1) → __dict__ → {"val": 1, "next": Node(2)}
Node(2) → __dict__ → {"val": 2, "next": Node(1)}
The cycle involves 4 container objects: 2 Node instances + 2 __dict__ objects. The GC collects all four, so it returns 4.
Key rule: Any Python object that can hold references to other objects is tracked by the GC. Instances, dicts, lists, sets, and tuples are all tracked. Ints, floats, and strings are not (they cannot reference other objects).
Expected Output
4Hints
Hint 1: gc.collect() returns the total number of unreachable objects collected, not just the objects you explicitly created.
Hint 2: Each class instance stores its attributes in a __dict__. That dict is itself a GC-tracked container object.
Predict which assertions pass. This tests your understanding of how CPython tracks allocations across the three generations.
import gc # Reset by collecting everything gc.collect() count_before = gc.get_count() # Allocate some new objects (lists can hold refs, so they are tracked) fresh = [[i] for i in range(10)] count_after = gc.get_count() # gen 0 count increases with new tracked allocations print(count_after[0] > count_before[0]) # gen 1 and gen 2 counts did NOT change just by creating new objects print(count_after[1] == count_before[1]) print(count_after[2] == count_before[2])
Solution
True
True
True
How generational accounting works:
gc.get_count() returns (gen0, gen1, gen2) — the number of tracked objects in each generation since the last collection of that generation.
- Creating new objects increments the gen 0 counter.
- When gen 0 reaches its threshold (default 700), the GC collects gen 0. Survivors are promoted to gen 1.
- Gen 1 has its own threshold (default 10 collections). When reached, gen 1 is collected too, and survivors go to gen 2.
- Gen 2 (the oldest) is collected only when it also reaches its threshold (default 10).
So simply allocating 10 new lists raises gen0 but leaves gen1 and gen2 unchanged.
Practical insight: In a long-running service, most objects die young (gen 0 is collected frequently). Only long-lived objects like caches, connection pools, and module-level state survive to gen 2. The generational hypothesis — most objects die young — is why this three-tier design is efficient.
Expected Output
True\nTrue\nTrueHints
Hint 1: gc.get_count() returns a 3-tuple: (gen0_count, gen1_count, gen2_count).
Hint 2: Gen 0 is the youngest generation. New objects are allocated into gen 0.
Read and change the GC thresholds. Demonstrate that you can tune the collection frequency.
import gc # Default thresholds defaults = gc.get_threshold() print(defaults) # Check it's a 3-tuple print(len(defaults) == 3) # Increase thresholds (fewer, less frequent collections — good for throughput) gc.set_threshold(2000, 15, 15) print(gc.get_threshold())
Solution
(700, 10, 10)
True
(2000, 15, 15)
What the thresholds mean:
gen0=700: collect gen 0 after 700 new object allocations since the last gen 0 collection.gen1=10: collect gen 1 after gen 0 has been collected 10 times since the last gen 1 collection.gen2=10: collect gen 2 after gen 1 has been collected 10 times since the last gen 2 collection.
When to tune: In throughput-sensitive servers (Flask/FastAPI under heavy load), reducing collection frequency by raising thresholds can reduce latency spikes. Instagram famously sets gc.set_threshold(0) and calls gc.freeze() before forking their uWSGI workers, then disables the GC entirely for workers that do not create cycles.
Warning: Raising thresholds means cycles accumulate longer before being collected. If your code creates many cycles per request, this trades latency consistency for higher peak memory usage.
Expected Output
(700, 10, 10)\nTrue\n(2000, 15, 15)Hints
Hint 1: gc.get_threshold() returns the default (700, 10, 10) tuple unless you have changed it.
Hint 2: gc.set_threshold(gen0, gen1, gen2) accepts three positional arguments.
Toggle the GC on and off and verify the state.
import gc # GC is enabled by default print(gc.isenabled()) gc.disable() print(gc.isenabled()) gc.enable() print(gc.isenabled())
Solution
True
False
True
When disabling the GC makes sense:
Disabling the cyclic garbage collector does NOT disable reference counting. CPython still frees objects immediately when their reference count drops to zero — gc.disable() only stops the periodic sweep that catches cycles.
This is safe when:
- Your code does not create reference cycles (e.g., pure functional code, stateless request handlers).
- You are about to fork a process and want to prevent the parent's GC from running inside the forked child.
- You need deterministic, low-latency response times and are willing to manage cycles manually with
gc.collect()calls at controlled points.
The pattern used by high-throughput Python servers:
gc.disable() # disable periodic collections
# ... handle requests, accumulate cycles ...
gc.collect() # manual sweep at a safe checkpoint
gc.enable() # re-enable for the rest
Expected Output
True\nFalse\nTrueHints
Hint 1: gc.isenabled() tells you the current state.
Hint 2: After gc.disable(), cycles will accumulate until gc.enable() or gc.collect() is called.
Medium
Use gc.get_referrers() to find which objects hold a reference to a given node in a cycle. This is the technique used to diagnose memory leaks in production.
import gc
class Node:
def __init__(self, name):
self.name = name
self.ref = None
a = Node("x")
b = Node("y")
a.ref = b
b.ref = a
# Who holds a reference to b?
referrers = gc.get_referrers(b)
# Filter and print
Solution
import gc
class Node:
def __init__(self, name):
self.name = name
self.ref = None
def make_cycle():
x = Node("x")
y = Node("y")
x.ref = y
y.ref = x
return x, y
def find_cycle_partners(node):
"""Return names of Node objects that reference this node."""
referrers = gc.get_referrers(node)
names = []
for ref in referrers:
if isinstance(ref, Node):
names.append(ref.name)
return names
a, b = make_cycle()
partners = find_cycle_partners(b)
print(f"Objects referencing b: {partners}")
print(f"Cycle detected: {a.name in partners}")
Output:
Objects referencing b: ['x']
Cycle detected: True
Why gc.get_referrers() is a powerful diagnostic tool:
In a memory leak investigation, you typically have an object that should have been collected but wasn't. gc.get_referrers(obj) answers the question "what is keeping this object alive?"
The result includes every object in the interpreter that holds a reference — local variables, global dictionaries, list entries, dict values, and frame objects. The typical diagnostic workflow:
- Take a snapshot of live objects with
gc.get_objects(). - Filter to the type you expect to be leaking.
- Call
gc.get_referrers()on one leaked instance. - Inspect the chain of referrers to find the unexpected root.
Caution: gc.get_referrers() is slow — it scans every tracked object in the interpreter. Use it only during debugging, never in production hot paths.
import gc
class Node:
def __init__(self, name):
self.name = name
self.ref = None
def make_cycle():
"""Create two nodes that reference each other."""
x = Node("x")
y = Node("y")
x.ref = y
y.ref = x
return x, y
def find_cycle_partners(node):
"""Use gc.get_referrers to find what objects refer to 'node'.
Return a list of names of Node objects that reference this node.
"""
pass
a, b = make_cycle()
partners = find_cycle_partners(b)
print(f"Objects referencing b: {partners}")
print(f"Cycle detected: {a.name in partners}")Expected Output
Objects referencing b: ['x']\nCycle detected: TrueHints
Hint 1: gc.get_referrers(obj) returns a list of all objects that hold a reference to obj.
Hint 2: Filter the result to only include Node instances, then extract their .name attribute.
Hint 3: The list will include local variables in the calling frame too — filter those out.
Demonstrate gc.freeze() and gc.unfreeze(). This is the pattern used by Instagram before forking uWSGI workers to prevent the GC from running inside children.
import gc
# Collect everything first so we start clean
gc.collect()
print(f"Before freeze: {gc.get_freeze_count()} frozen")
# Freeze all currently live tracked objects
gc.freeze()
print(f"After freeze: {gc.get_freeze_count() > 0}")
# New objects created after freeze() are NOT frozen
new_list = [1, 2, 3]
before_new = gc.get_freeze_count()
gc.freeze() # freeze again — now new_list is also frozen
print(f"New objects not in frozen count: {gc.get_freeze_count() >= before_new}")
# Restore: move frozen objects back to gen 2
gc.unfreeze()
print(f"After unfreeze: {gc.get_freeze_count()} frozen")Solution
Before freeze: 0 frozen
After freeze: True
New objects not in frozen count: True
After unfreeze: 0 frozen
Why gc.freeze() matters for forking servers:
When a Python process forks (e.g., Gunicorn master forking workers), the child inherits the parent's entire memory space via copy-on-write. If the parent's GC then runs a collection cycle inside the child, it modifies GC bookkeeping data structures, causing copy-on-write page faults. Every modified page is copied from the parent's physical memory into the child's own pages — this defeats copy-on-write and wastes RAM.
gc.freeze() moves all long-lived objects (the parent's startup state, imported modules, pre-loaded data) into a permanent generation that the GC never scans. After fork, the child's GC only sees the small set of objects created after the fork — the frozen objects are never touched, so copy-on-write pages stay shared.
Instagram's actual pattern (Python 3.7+):
# In the master process, before forking workers:
gc.collect() # collect any cycles first
gc.freeze() # freeze all surviving objects
# os.fork() happens here
# In each worker:
# The frozen parent objects are never scanned — no COW faults
This optimization saved Instagram tens of gigabytes of RAM across their worker fleet.
Expected Output
Before freeze: 0 frozen\nAfter freeze: True\nNew objects not in frozen count: True\nAfter unfreeze: 0 frozenHints
Hint 1: gc.freeze() moves all currently tracked objects into a permanent generation that is never collected.
Hint 2: gc.get_freeze_count() returns the number of frozen objects.
Hint 3: gc.unfreeze() moves them back out of the permanent generation.
Predict the order in which __del__ is called for three objects that go out of scope together. No cycles are involved — this is pure reference counting behavior.
import gc
log = []
class Resource:
def __init__(self, name):
self.name = name
def __del__(self):
log.append(self.name)
def create_and_delete():
a = Resource("first")
b = Resource("second")
c = Resource("third")
# all three go out of scope as the function returns
create_and_delete()
print(log)Solution
['third', 'second', 'first']
Why LIFO order:
CPython's stack frame tracks local variables in order of first assignment. When the function returns, the frame is torn down and local variables are decremented in reverse order — last created, first destroyed. This is stack-like (LIFO) behavior.
c(Resource "third") was assigned last → its refcount hits zero first →__del__called → "third" appended.b(Resource "second") next → "second".a(Resource "first") last → "first".
This LIFO ordering is deterministic for acyclic objects in CPython. It is an implementation detail, not a Python language guarantee. PyPy, Jython, and other implementations may finalize in a different order.
The cycle caveat: When objects form a cycle, the GC handles them. The GC does NOT guarantee __del__ ordering across objects in the same cycle. PEP 442 (Python 3.4+) ensures __del__ is always called even for cycles, but the order among cycle members is undefined.
import gc
log = []
class Resource:
def __init__(self, name):
self.name = name
def __del__(self):
log.append(self.name)
def create_and_delete():
"""Create three Resource objects with no cycles.
Delete them in reverse order of creation by letting them go out of scope.
Return the order they were finalized.
"""
a = Resource("first")
b = Resource("second")
c = Resource("third")
# All three go out of scope here
create_and_delete()
print(log)Expected Output
['third', 'second', 'first']Hints
Hint 1: Without cycles, CPython uses reference counting. Objects are finalized when refcount drops to zero.
Hint 2: Local variables go out of scope in LIFO order — the last variable declared is destroyed first.
Hint 3: This is deterministic for acyclic objects — __del__ is called immediately when refcount hits zero.
Use gc.get_objects() to build a live instance counter. This technique is used in production leak detectors to track whether object counts are growing unexpectedly over time.
import gc
class Widget:
pass
def count_live_widgets():
# Use gc.get_objects() to count tracked Widget instances
pass
widgets = [Widget() for _ in range(5)]
print(f"After creating 5: {count_live_widgets()}")
Solution
import gc
class Widget:
pass
def count_live_widgets():
"""Return the number of Widget instances currently tracked by the GC."""
gc.collect() # ensure unreachable objects are collected first
return sum(1 for obj in gc.get_objects() if isinstance(obj, Widget))
widgets = [Widget() for _ in range(5)]
print(f"After creating 5: {count_live_widgets()}")
del widgets[0]
del widgets[1]
print(f"After deleting 2: {count_live_widgets()}")
del widgets
gc.collect()
print(f"After deleting all: {count_live_widgets()}")
Output:
After creating 5: 5
After deleting 2: 3
After deleting all: 0
Production use case — leak detector:
import gc
from collections import Counter
def object_type_counts():
gc.collect()
types = [type(obj).__name__ for obj in gc.get_objects()]
return Counter(types)
# Call this before and after handling a batch of requests:
before = object_type_counts()
handle_batch()
after = object_type_counts()
leaked = {t: after[t] - before[t] for t in after if after[t] > before[t]}
if leaked:
print(f"Potential leak: {leaked}")
If Widget (or any other type) grows by N on every batch, you have a confirmed leak.
Performance note: gc.get_objects() builds a list of every tracked object — potentially millions in a large process. Use it sparingly and only in diagnostic code, not in request hot paths.
import gc
class Widget:
pass
def count_live_widgets():
"""Return the number of Widget instances currently tracked by the GC."""
pass
# Create some widgets
widgets = [Widget() for _ in range(5)]
print(f"After creating 5: {count_live_widgets()}")
# Delete two
del widgets[0]
del widgets[1]
print(f"After deleting 2: {count_live_widgets()}")
# Delete all
del widgets
gc.collect()
print(f"After deleting all: {count_live_widgets()}")Expected Output
After creating 5: 5\nAfter deleting 2: 3\nAfter deleting all: 0Hints
Hint 1: gc.get_objects() returns a list of every object currently tracked by the cyclic GC.
Hint 2: Filter that list for isinstance(obj, Widget) to count only Widget instances.
Hint 3: Call gc.collect() before counting to ensure any unreachable objects have been cleaned up.
Hard
Implement a cycle detector using gc.get_referents() and depth-first search. This mirrors (in simplified form) the algorithm CPython's GC uses to identify unreachable cycles.
import gc
def has_cycle(obj):
"""Return True if obj is part of a reference cycle."""
# Implement DFS using gc.get_referents()
pass
Solution
import gc
def has_cycle(obj):
"""Return True if obj is part of a reference cycle."""
visited = set()
stack = [obj]
while stack:
current = stack.pop()
# Skip non-container types (they cannot hold references)
if not isinstance(current, (list, dict, set, tuple, object)):
continue
# Skip built-in singletons and non-container scalars
if isinstance(current, (int, float, str, bytes, bool, type(None))):
continue
obj_id = id(current)
if obj_id in visited:
return True # we reached an already-seen object: cycle found
visited.add(obj_id)
try:
referents = gc.get_referents(current)
except Exception:
continue
for ref in referents:
if id(ref) in visited:
return True
if isinstance(ref, (int, float, str, bytes, bool, type(None))):
continue
stack.append(ref)
return False
How the real GC cycle detection works (simplified):
CPython's Modules/gcmodule.c implements a mark-and-sweep variant:
- For every tracked object in a generation, copy its
ob_refcntinto a scratch field (gc_refs). - For every tracked object, traverse its referents and decrement each referent's
gc_refsby 1. - After traversal, any object whose
gc_refsis 0 has no external references — all its references come from within the same tracked set. It is "unreachable" and eligible for collection. - Objects whose
gc_refsis still above 0 are reachable from outside (stack frames, global variables) and are kept.
The simplification in our has_cycle(): instead of computing reference counts, we use visited-set DFS, which detects cycles but cannot distinguish "unreachable cycle" from "reachable cycle". The GC needs to identify specifically the unreachable ones.
Self-referential list: my_list.append(my_list) creates the simplest possible cycle — an object that is its own referent. gc.get_referents(my_list) includes my_list itself, so our DFS finds the cycle immediately.
import gc
def has_cycle(obj):
"""Return True if obj is part of a reference cycle.
Use gc.get_referents() to traverse the object graph.
Implement DFS with a visited set.
Only traverse container objects (skip ints, strings, etc.).
"""
pass
# Test 1: acyclic
class Box:
def __init__(self, val):
self.val = val
a = Box(1)
b = Box(2)
a.child = b # a -> b, no cycle back
print(f"Acyclic: {has_cycle(a)}")
# Test 2: simple cycle
c = Box(3)
d = Box(4)
c.other = d
d.other = c # cycle: c -> d -> c
print(f"Cycle: {has_cycle(c)}")
# Test 3: list cycle
my_list = []
my_list.append(my_list) # list contains itself
print(f"Self-referential list: {has_cycle(my_list)}")Expected Output
Acyclic: False\nCycle: True\nSelf-referential list: TrueHints
Hint 1: gc.get_referents(obj) returns the objects directly referenced by obj (one level deep).
Hint 2: Use a set of object ids (not the objects themselves) to track visited nodes — avoids creating new references.
Hint 3: Only recurse into container types (list, dict, set, tuple, and custom class instances). Skip non-containers.
Hint 4: If during DFS you encounter an object whose id is already in the visited set, you have found a cycle.
Identify a common class-level memory leak pattern and explain why it prevents garbage collection. Then verify the fix works correctly.
import gc
class EventBus:
_listeners = {} # class-level dict — shared across ALL instances
def on(self, event, callback):
if event not in self._listeners:
self._listeners[event] = []
self._listeners[event].append(callback)
class Widget:
def __init__(self, name):
self.name = name
self.bus = EventBus()
self.bus.on("click", self.handle_click)
def handle_click(self):
pass
# Simulate creating and "destroying" 5 widgets
for i in range(5):
w = Widget(f"widget_{i}")
gc.collect()
print(f"Leaked callbacks: {len(EventBus._listeners.get('click', []))}")
print(f"Is this a leak? {len(EventBus._listeners.get('click', [])) > 0}")Solution
Leaked callbacks: 5
Is this a leak? True
Fixed buses hold correct count: True
Root cause analysis:
The bug is _listeners = {} defined at class scope. This is a single dictionary shared across every EventBus instance and across every Widget that uses it.
When Widget.__init__ calls self.bus.on("click", self.handle_click), it appends a bound method to EventBus._listeners["click"]. A bound method holds a strong reference to the instance it is bound to (self — the Widget). So:
EventBus._listeners (class-level dict, lives forever)
└── "click" → [Widget_0.handle_click, Widget_1.handle_click, ..., Widget_4.handle_click]
↑ ↑
holds strong ref to holds strong ref to
Widget_0 Widget_1
Even after del w at the end of each loop iteration, the Widget cannot be GC'd because the class-level _listeners dict still holds a reference chain to it. This is a "retention path leak" — an object is kept alive by an unexpected root.
The fix: Move self._listeners = {} into __init__. Each bus instance then has its own private listener dict. When the bus is garbage collected (along with its Widget owner), the callbacks inside it lose their last reference and the Widget can be collected too.
This pattern appears in many real codebases: Django signal receivers, Flask blueprint callbacks, and any observer/event pattern that stores callbacks at class scope. Always prefer instance-level storage for per-object state.
import gc
class EventBus:
"""A simple event bus that registers callbacks.
WARNING: this implementation has a memory leak — find and fix it.
"""
_listeners = {} # class-level: shared across ALL instances
def on(self, event, callback):
if event not in self._listeners:
self._listeners[event] = []
self._listeners[event].append(callback)
def emit(self, event):
for cb in self._listeners.get(event, []):
cb()
class Widget:
def __init__(self, name):
self.name = name
self.bus = EventBus()
self.bus.on("click", self.handle_click)
def handle_click(self):
pass
def simulate_leak():
for i in range(5):
w = Widget(f"widget_{i}")
# Widget goes out of scope at end of iteration
gc.collect()
return len(EventBus._listeners.get("click", []))
# This should return 0 after all widgets go out of scope
# But it returns 5 — why?
count = simulate_leak()
print(f"Leaked callbacks: {count}")
print(f"Is this a leak? {count > 0}")
# Fix the EventBus so that it uses INSTANCE-level listeners
class FixedEventBus:
def __init__(self):
self._listeners = {} # instance-level: each bus has its own dict
def on(self, event, callback):
if event not in self._listeners:
self._listeners[event] = []
self._listeners[event].append(callback)
def emit(self, event):
for cb in self._listeners.get(event, []):
cb()
class FixedWidget:
def __init__(self, name):
self.name = name
self.bus = FixedEventBus()
self.bus.on("click", self.handle_click)
def handle_click(self):
pass
def test_fixed():
buses = []
for i in range(5):
w = FixedWidget(f"widget_{i}")
buses.append(w.bus)
# Each widget has its own bus; deleting w doesn't clear buses
# But the CLASS-level leak is gone
total = sum(len(b._listeners.get("click", [])) for b in buses)
return total
print(f"Fixed buses hold correct count: {test_fixed() == 5}")Expected Output
Leaked callbacks: 5\nIs this a leak? True\nFixed buses hold correct count: TrueHints
Hint 1: Class-level attributes are shared across ALL instances. EventBus._listeners is one dict for the whole class, not one per instance.
Hint 2: When a Widget goes out of scope, its EventBus instance is deleted, but the _listeners class dict still holds the callback (a bound method).
Hint 3: A bound method (self.handle_click) holds a strong reference to self, preventing the Widget from being GC collected.
Hint 4: The fix: move _listeners = {} into __init__ so each EventBus instance has its own dict.
Build a GCTuner context manager that implements the production pattern of deferring GC collections during a latency-sensitive block, then performing a single controlled sweep on exit. This consolidates many small pauses into one predictable pause at a safe point.
import gc
import time
class GCTuner:
"""Defer GC during a latency-sensitive block; collect on exit."""
def __init__(self, freeze_before=False):
self.freeze_before = freeze_before
self.pause_ms = 0.0
self._was_enabled = False
def __enter__(self):
pass
def __exit__(self, exc_type, exc_val, exc_tb):
pass
def report(self):
print(f"GC pause after block: {self.pause_ms:.2f} ms")
Solution
import gc
import time
class GCTuner:
"""Defer GC during a latency-sensitive block; collect on exit."""
def __init__(self, freeze_before=False):
self.freeze_before = freeze_before
self.pause_ms = 0.0
self._was_enabled = False
def __enter__(self):
self._was_enabled = gc.isenabled()
gc.disable()
if self.freeze_before:
gc.freeze()
return self
def __exit__(self, exc_type, exc_val, exc_tb):
# Time the full collection (gen 2 = collect all generations)
t0 = time.perf_counter()
gc.collect(2)
self.pause_ms = (time.perf_counter() - t0) * 1000
# Unfreeze if we froze on entry
if self.freeze_before:
gc.unfreeze()
# Restore original GC state
if self._was_enabled:
gc.enable()
return False # do not suppress exceptions
def report(self):
print(f"GC pause after block: {self.pause_ms:.2f} ms")
with GCTuner(freeze_before=True) as tuner:
data = []
for i in range(10_000):
node = {"id": i, "val": i * 2}
data.append(node)
for i in range(0, len(data) - 1, 2):
data[i]["next"] = data[i + 1]
data[i + 1]["prev"] = data[i]
del data
tuner.report()
print(f"GC re-enabled: {gc.isenabled()}")
print(f"Frozen objects cleared: {gc.get_freeze_count() == 0}")
Production design rationale:
Problem with default GC: The cyclic GC runs automatically when gen 0 exceeds 700 allocations. Inside a request handler that processes data intensively, this can trigger multiple stop-the-world pauses at unpredictable points — causing tail latency spikes.
GCTuner pattern:
- Disable GC on entry so no automatic pauses occur during the block.
- Freeze pre-existing long-lived objects so the post-block sweep is fast (it only scans objects created inside the block).
- Time a controlled
gc.collect(2)on exit — one predictable pause at a point you choose. - Unfreeze and restore GC state.
When to use this: In request handlers where you create many temporary objects with some cycles, and p99 latency matters. The tradeoff: peak memory during the block is higher (cycles not collected until exit), but latency is more predictable.
When NOT to use this: If the block runs for a long time and allocates enormous amounts of memory, deferring collection could cause OOM. Match the scope of the tuner to short, bounded work units.
import gc
import time
class GCTuner:
"""A context manager that optimizes GC behavior for a latency-sensitive
code block. During the block:
- GC collections are deferred (disabled).
- After the block, a single full collection is performed.
- The total GC pause time is recorded.
Also supports the fork-safe freeze pattern.
"""
def __init__(self, freeze_before=False):
self.freeze_before = freeze_before
self.pause_ms = 0.0
self._was_enabled = False
def __enter__(self):
"""Disable GC, optionally freeze existing objects."""
pass
def __exit__(self, exc_type, exc_val, exc_tb):
"""Re-enable GC, run a full collection, record pause time."""
pass
def report(self):
"""Print a summary of GC pause time."""
print(f"GC pause after block: {self.pause_ms:.2f} ms")
# Usage demo
with GCTuner(freeze_before=True) as tuner:
# Simulate a request handler that creates many short-lived objects
data = []
for i in range(10_000):
node = {"id": i, "val": i * 2}
data.append(node)
# Create some cycles
for i in range(0, len(data) - 1, 2):
data[i]["next"] = data[i + 1]
data[i + 1]["prev"] = data[i]
del data
tuner.report()
print(f"GC re-enabled: {gc.isenabled()}")
print(f"Frozen objects cleared: {gc.get_freeze_count() == 0}")Expected Output
GC pause after block: True\nGC re-enabled: True\nFrozen objects cleared: TrueHints
Hint 1: __enter__ should save gc.isenabled(), call gc.disable(), and optionally gc.freeze().
Hint 2: __exit__ should time a gc.collect(2) call (full collection across all generations), then restore GC state.
Hint 3: gc.unfreeze() moves frozen objects back to gen 2 so they can be collected normally again.
Hint 4: The expectedOutput for the first line should be a number — the test checks it is a string representation of a bool (True means the pause_ms is a float, not that it is True).
