Inside lru_cache: keys, locking and concurrency

functools · Expert · 11 min

High-traffic services rely on caches behaving predictably under concurrency.

CPython's lru_cache is implemented in C (_functools._lru_cache_wrapper), with a pure-Python fallback that documents the algorithm:

  • Keys: _make_key flattens positional args, a marker, then keyword items into a flat tuple (with types appended if typed=True). A single argument of type int or str is used directly as the key, a fast path.
  • Storage: a dict maps keys to links in a circular doubly linked list. A hit moves the link to the front; when the cache is full, a miss reuses the oldest link. Both are O(1). The unbounded variant skips the linked list entirely.
  • Hashing happens once per call; arguments with expensive __hash__ make every call expensive.

Thread safety: the cache's internal structures stay consistent when used from several threads. But the wrapped function is called without holding a lock, so two threads that miss on the same key concurrently both compute the value, and the last one to finish is stored. For expensive or side-effecting computations that must run once, add a per-key lock or use a single-flight pattern.

On the free-threaded 3.13 build, the C implementation uses critical sections to protect its state; the "may compute twice" behaviour remains.

cached_property similarly has no lock since 3.12, for the same reason: the old class-wide lock serialised all instances.

Example

import threading
import time
from functools import lru_cache

calls = []
@lru_cache(maxsize=None)
def slow_square(n):
    calls.append(n)
    time.sleep(0.1)                  # simulate an expensive call
    return n * n
def run_threads(fn, arg, n=3):
    ts = [threading.Thread(target=fn, args=(arg,)) for _ in range(n)]
    for t in ts: t.start()
    for t in ts: t.join()
run_threads(slow_square, 7)
print(len(calls) > 1)                # True: concurrent misses computed it again
locks, guard = {}, threading.Lock()
def once(n):                         # single-flight wrapper
    with guard:
        lock = locks.setdefault(n, threading.Lock())
    with lock:
        return slow_square(n)
calls.clear(); slow_square.cache_clear()
run_threads(once, 8)
print(calls)                         # [8]

Remember

  • lru_cache is a C dict plus a linked list: O(1) hits, misses and evictions
  • Its state is thread-safe, but concurrent misses can call the function more than once
  • Add per-key locking when a computation must happen exactly once

Watch out for

  • Using lru_cache to guarantee an expensive initialisation runs only once across threads.

Check yourself

  1. Three threads call an @lru_cache function with the same new argument at the same time. What can happen?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: docs.python.org/3/library/functools.html#functools.lru_cache