Skip to Content
Course content

361: Reference Counting and Garbage Collection

Click on the "Edit" button in the top corner of the screen to edit your slide content.

I want to show you something that usually doesn't bite you until your application has been running for three days and your server starts swapping to disk because it's out of RAM. In Python, we're told that memory management is "automatic." While that's mostly true, there's a specific edge case involving circular references that can lead to subtle memory leaks.

Take a look at this simple implementation of a Project and Task relationship. It looks perfectly logical: a project has many tasks, and each task knows which project it belongs to.

import gc

class Project:
    def __init__(self, name):
        self.name = name
        self.tasks = []
        print(f"Creating project: {self.name}")

    def __del__(self):
        print(f"Destroying project: {self.name}")

class Task:
    def __init__(self, name, project):
        self.name = name
        self.project = project
        project.tasks.append(self)
        print(f"Creating task: {self.name}")

    def __del__(self):
        print(f"Destroying task: {self.name}")

def create_work():
    p = Project("Website Redesign")
    t1 = Task("Fix CSS", p)
    t2 = Task("Update Logos", p)
    # The function ends here, and p, t1, and t2 go out of scope.

create_work()
print("Function finished. Objects should be gone...")

If you run this, you'll notice something weird: the "Destroying..." messages never print. Even though the function finished and the local variables are gone, the objects are still hanging around in memory. Why?

The Circular Reference Trap

Python primarily uses Reference Counting. Every object keeps track of how many other things are pointing to it. When that count hits zero, Python immediately reclaims the memory. It's fast and deterministic.

But look at our code. The Project object has a list that points to the Task objects. Simultaneously, each Task object has a self.project attribute pointing back to the Project. This is a circular reference. Even after create_work() finishes, the Project's reference count is still 1 (because the Tasks point to it), and the Tasks' reference counts are still 1 (because the Project points to them). They are keeping each other alive in a "suicide pact," even though the rest of your program can no longer reach them.

Now, Python does have a secondary system called the Generational Garbage Collector (GC) specifically to find these cycles. But the GC doesn't run every second; it runs periodically based on thresholds. In a high-throughput app, relying solely on the GC can lead to memory spikes that are a nightmare to debug.

Breaking the Cycle with Weak References

The fix is to use a weakref. A weak reference is essentially a pointer that doesn't increase the reference count of the object it points to. If the only remaining references to an object are weak references, Python's reference counter will hit zero and kill the object anyway.

In our case, the "strong" ownership should go from Project $\rightarrow$ Task. The Task's reference back to the Project should be "weak."

import weakref
import gc

class Project:
    def __init__(self, name):
        self.name = name
        self.tasks = []
        print(f"Creating project: {self.name}")

    def __del__(self):
        print(f"Destroying project: {self.name}")

class Task:
    def __init__(self, name, project):
        self.name = name
        # We store a weak reference to the project instead of a direct one
        self.project_ref = weakref.ref(project)
        project.tasks.append(self)
        print(f"Creating task: {self.name}")

    @property
    def project(self):
        # Calling the weakref returns the original object (or None if it's gone)
        return self.project_ref()

    def __del__(self):
        print(f"Destroying task: {self.name}")

def create_work():
    p = Project("Website Redesign")
    t1 = Task("Fix CSS", p)
    t2 = Task("Update Logos", p)

create_work()
print("Function finished. Objects should be gone...")

Now, when create_work() ends, the only strong reference to the Project is gone. Its reference count hits zero. It gets destroyed, which in turn drops the reference counts of the Tasks to zero, and they get destroyed immediately. Everything is cleaned up the moment it's no longer needed. I highly recommend using this pattern whenever you have "parent-child" relationships where the child needs to talk back to the parent.




📋 Practical Task

Exercise: Fixing the Memory Leak in a Bi-Directional Cache

You are building a caching system where CacheEntry objects are stored in a CacheManager. To make lookups faster, the CacheEntry needs to know which CacheManager it belongs to. However, the current implementation is causing a memory leak because of circular references.

Your Task: Modify the provided code to use weakref so that when a CacheManager instance is deleted, its entries are also cleaned up immediately, rather than waiting for the generational garbage collector.

import weakref

class CacheManager:
    def __init__(self, name):
        self.name = name
        self.entries = []
        print(f"Manager {self.name} initialized")

    def add_entry(self, key, value):
        entry = CacheEntry(key, value, self)
        self.entries.append(entry)

    def __del__(self):
        print(f"Manager {self.name} destroyed")

class CacheEntry:
    def __init__(self, key, value, manager):
        self.key = key
        self.value = value
        self.manager = manager # This is causing the circular reference!
        print(f"Entry {self.key} created")

    def __del__(self):
        print(f"Entry {self.key} destroyed")

# Test case
def run_test():
    mgr = CacheManager("MainCache")
    mgr.add_entry("user_1", {"name": "Alice"})
    mgr.add_entry("user_2", {"name": "Bob"})
    print("End of run_test scope")

run_test()
print("Check: Did you see the 'destroyed' messages?")

Requirements:

  • Implement weakref.ref within the CacheEntry class.
  • Add a property or method to CacheEntry to safely retrieve the manager.
  • Verify that the __del__ methods for both the manager and the entries print to the console immediately after run_test() finishes.
Rating
0 0

There are no comments for now.

to be the first to leave a comment.