Managing Resources in Complex Programs

When a program grows beyond a single script, resource management gets trickier. Multiple components open files, sockets and connections; long-lived services pool expensive objects; worker threads need locks; shutdown has to release everything in the right order. The answer is to concentrate resource handling in a few well-designed primitives and to make the rest of the code use them through with.

contextlib.ExitStack is the workhorse for “I need N resources and have to clean them up in reverse order”. You enter the stack once, push each resource with stack.enter_context(cm), and they all unwind automatically when the stack exits. The number of resources can depend on runtime data — config files, user-selected plug-ins, a list of downloads.

For resources shared across threads or async tasks, locks and semaphores enforce safe access. with lock: inside a function guarantees only one thread runs the critical section. threading.RLock allows the same thread to reacquire the lock safely. Pools (concurrent.futures.ThreadPoolExecutor, database connection pools) cap how many live resources can exist at once.

For program-wide singletons (logger, config, connection pool), build them once in an @lru_cache or module-level initializer, expose them through a clear function, and tear them down in a registered cleanup. Use atexit.register for last-chance cleanups when a graceful shutdown isn't otherwise wired up.

ExitStack and callbacks

with ExitStack() as stack: resources = [stack.enter_context(open(p)) for p in paths]. If construction fails mid-way, already-entered contexts are popped in reverse order. stack.callback(fn) registers an arbitrary cleanup function too.

stack.pop_all() transfers ownership of all current contexts to a fresh ExitStack. Use it to defer cleanup to a caller after a successful setup.

Locks, pools, and atexit

with threading.Lock(): for a critical section. queue.Queue is a thread-safe FIFO. For I/O-bound parallelism, use ThreadPoolExecutor; for CPU-bound, ProcessPoolExecutor.

atexit.register(cleanup_fn) runs on normal interpreter shutdown. For signal-driven shutdown, install a handler with the signal module and raise or set a flag; avoid doing substantial work inside the handler itself.

Complex-program resource tools.

ToolPurpose
ExitStack
class
Dynamic composition of context managers.
AsyncExitStack
class
Same, for async.
threading.Lock
class
Mutual exclusion primitive.
RLock
class
Reentrant lock for recursion-safe critical sections.
queue.Queue
class
Thread-safe FIFO.
ThreadPoolExecutor
class
Managed pool of worker threads.
atexit.register
function
Run cleanup on interpreter shutdown.
weakref
module
References that don't prevent garbage collection.

Managing Resources in Complex Programs code example

The script opens many resources with ExitStack, uses a lock for a shared counter, and registers an atexit cleanup.

# Lesson: Managing Resources in Complex Programs
import atexit
import threading
from concurrent.futures import ThreadPoolExecutor
from contextlib import ExitStack
from pathlib import Path
from tempfile import TemporaryDirectory


with TemporaryDirectory() as tmp:
    root = Path(tmp)
    paths = []
    for name in ("a.log", "b.log", "c.log"):
        p = root / name
        p.write_text(f"from {name}", encoding="utf-8")
        paths.append(p)

    # Open N files with guaranteed cleanup even if one open() fails
    with ExitStack() as stack:
        files = [stack.enter_context(open(p, "r", encoding="utf-8")) for p in paths]
        for f in files:
            print(Path(f.name).name, ":", f.read())
    print("all files closed after the ExitStack block")


# Lock-protected shared counter
counter = 0
lock = threading.Lock()


def bump(n: int) -> None:
    global counter
    for _ in range(n):
        with lock:
            counter += 1


with ThreadPoolExecutor(max_workers=4) as pool:
    for _ in range(4):
        pool.submit(bump, 2500)

print("counter:", counter)
assert counter == 4 * 2500


# atexit: runs on interpreter shutdown
def on_exit():
    print("cleanup: releasing global resources")


atexit.register(on_exit)
print("main work done")

Patterns to internalize:

1) `ExitStack.enter_context` composes any number of `with`-able resources.
2) Lock-guarded critical section keeps the shared counter consistent.
3) A thread pool is a resource too: `with` block ensures a clean shutdown.
4) `atexit.register` is the last-chance hook for program-wide cleanup.

Build a tiny connection pool.

from contextlib import contextmanager
import threading

class FakePool:
    def __init__(self, size: int):
        self._free = [f"conn-{i}" for i in range(size)]
        self._lock = threading.Lock()
    @contextmanager
    def borrow(self):
        with self._lock:
            c = self._free.pop()
        try:
            yield c
        finally:
            with self._lock:
                self._free.append(c)

pool = FakePool(2)
with pool.borrow() as conn:
    print("using", conn)
print("free after:", pool._free)

LIFO unwind on ExitStack.

from contextlib import ExitStack, contextmanager
order = []
@contextmanager
def tag(n):
    order.append(("enter", n))
    try: yield
    finally: order.append(("exit", n))
with ExitStack() as s:
    s.enter_context(tag(1))
    s.enter_context(tag(2))
assert order == [("enter", 1), ("enter", 2), ("exit", 2), ("exit", 1)]

Running prints (order of lines varies slightly):

a.log : from a.log
b.log : from b.log
c.log : from c.log
all files closed after the ExitStack block
counter: 10000
main work done
cleanup: releasing global resources