Card 07/ 09
Lapsed Listeners That Keep Objects Alive
A closed dialog is still subscribed, so it is never freed and still reacts to every event.
A subject holds a reference to every observer on its list, so an observer that is never detached cannot be freed while the subject lives. This is the lapsed listener problem: memory grows with every object that subscribed and was then thrown away, and each of them still runs its code on every notification. This card shows the leak, why the garbage collector cannot help, and the fixes.
An application has one event bus for its whole life. Each dialog subscribes to it in its constructor, so it can react when the theme changes. Three dialogs are opened and closed, and the code that opened them drops every reference to them.
class Dialog:
def __init__(self, name, bus):
self.name = name
bus.subscribe(self.on_message)After the three dialogs are closed and dropped, how many subscribers does the bus hold? Decide, then look.
import gc
import weakref
class EventBus:
def __init__(self) -> None:
self.handlers = []
def subscribe(self, handler) -> None:
self.handlers.append(handler)
def publish(self, message: str) -> None:
for handler in self.handlers:
handler(message)
class Dialog:
def __init__(self, name: str, bus: EventBus) -> None:
self.name = name
bus.subscribe(self.on_message)
def on_message(self, message: str) -> None:
print(f" {self.name} handled {message!r}")
def close(self) -> None:
print(f"{self.name} closed")
bus = EventBus()
first = Dialog("dialog 1", bus)
probe = weakref.ref(first)
first.close()
del first
for n in (2, 3):
dialog = Dialog(f"dialog {n}", bus)
dialog.close()
del dialog
gc.collect()
print("subscribers:", len(bus.handlers))
print("dialog 1 still in memory:", probe() is not None)
bus.publish("theme changed")dialog 1 closed
dialog 2 closed
dialog 3 closed
subscribers: 3
dialog 1 still in memory: True
dialog 1 handled 'theme changed'
dialog 2 handled 'theme changed'
dialog 3 handled 'theme changed'The bus holds three subscribers, the first dialog is still in memory after a full garbage collection, and all three closed dialogs handle the theme change. weakref.ref is used here only as a probe: it watches the first dialog without keeping it alive.
The garbage collector frees only objects nothing can reach. self.on_message is a bound method, a function tied to its object, and it holds a reference to the dialog. The bus is reachable for the whole run, so its list is, so every dialog is. In a desktop or browser application the cost is memory that grows with every dialog opened, and handlers acting on windows the user has closed.
The fix is to make every attach have a matching detach, in the place that ends the observer's use.
import gc
import weakref
class EventBus:
def __init__(self) -> None:
self.handlers = []
def subscribe(self, handler) -> None:
self.handlers.append(handler)
def unsubscribe(self, handler) -> None:
self.handlers.remove(handler)
def publish(self, message: str) -> None:
for handler in self.handlers:
handler(message)
class Dialog:
def __init__(self, name: str, bus: EventBus) -> None:
self.name = name
self._bus = bus
bus.subscribe(self.on_message)
def on_message(self, message: str) -> None:
print(f" {self.name} handled {message!r}")
def close(self) -> None:
self._bus.unsubscribe(self.on_message)
print(f"{self.name} closed")
bus = EventBus()
first = Dialog("dialog 1", bus)
probe = weakref.ref(first)
first.close()
del first
for n in (2, 3):
dialog = Dialog(f"dialog {n}", bus)
dialog.close()
del dialog
gc.collect()
print("subscribers:", len(bus.handlers))
print("dialog 1 still in memory:", probe() is not None)
bus.publish("theme changed")dialog 1 closed
dialog 2 closed
dialog 3 closed
subscribers: 0
dialog 1 still in memory: FalseA subject can instead hold weak references, which do not keep an object alive, using weakref.WeakMethod for bound methods. They hide the missing detach rather than fix it, so prefer the explicit detach where the end of an observer's use is known.
← → move through sections and cards