Why lru_cache Cannot Directly Cache an async Function

黎 浩然/ 25 10 月, 2026/ 教程/TUTORIAL, 计算机/COMPUTER/ 0 comments

Applying lru_cache directly to async def caches its immediate return object, not the value eventually produced by await. For an async function, that object is a coroutine. On a hit, it returns that same object. Once that native coroutine has run to completion, awaiting it again fails instead of retrieving a reusable result.

Contents
  1. A hit can occur before the function body runs
  2. Why the first await succeeds and the second fails
  3. What a completed-value cache actually fixes
  4. Reusing values does not merge concurrent misses
  5. A shared Task also retains a failure state
  6. The complete program and nineteen checks

This matters when wrapping asynchronous retrieval, configuration loading, or model requests. A small program with no network calls can separate three designs: keeping a coroutine, keeping a completed value, and letting callers await one Task. Each stores something with a different lifecycle. 中文版.

A hit can occur before the function body runs

A synchronous function can return a tuple for lru_cache to retain. Calling an async def function first produces a coroutine object; that call alone does not run the body. The outer decorator neither inserts an await nor extracts the eventual return value. The immediate return from the call and the later return from the coroutine are two different boundaries.

The distinction between calling and executing a coroutine is documented in the CPython 3.13.0 asyncio documentation. Its functools documentation also identifies async functions as unsuitable for this direct use of the cache. These fixed historical sources make the catch-up article traceable; this is not a claim that the behavior first appeared in 2026.

In the program, two calls to bad(‘demo’) happen before either is awaited. Both return the identical object. cache_info().hits is already one, while calls, incremented inside the body, remains zero. That combination explains why a cache-hit metric cannot establish that a remote lookup ran successfully. It only shows that the wrapper reused an object it had received.

1. Call bad(key)
Save coroutine C
Body entries: 0
2. First await of C
Receive tuple
Body entries: 1
3. Reuse the cached C
Await it again
RuntimeError
Original timeline: a cache hit can precede body execution. The cached object is not completed application data.

Why the first await succeeds and the second fails

Awaiting first runs the coroutine, returns (‘demo’, 42), and increments calls to one. Awaiting second then raises RuntimeError because second refers to the native coroutine that has just finished. The program checks the exception type rather than treating an exact English error message as a cross-version API contract.

A cache hit does not call the underlying function again, so it does not create a fresh, equivalent coroutine. Clearing the cache allows a later call to create another object, but clearing before every use gives up the intended result cache. It also cannot repair old references already handed to other callers.

For diagnosis, inspect the returned object type, whether references identify the same object, and how often the function body actually starts. Then interpret cache_info in that context. Avoid logging complete request arguments or retrieved documents merely to debug caching: those can contain private content. Fixed synthetic keys and tuples are enough to demonstrate the problem here.

What a completed-value cache actually fixes

The get_value example checks values, awaits load on a miss, and writes result only after that await succeeds. Callers receive the completed tuple, not a saved coroutine. Two sequential awaits succeed, load runs once, and both results have the same identity. An immutable tuple also avoids introducing a shared-list mutation problem into this example.

Because insertion follows successful completion, an exception from load is not stored as a successful value by this implementation. Another call will try loading again. That is an observed property of these lines, not a universal retry policy. A real external service may require different handling for rate limits, transient failures, permanent failures, and retry backoff.

This minimal dictionary has no capacity bound, TTL, invalidation mechanism, or in-flight request merging. Its purpose is to show where completed values enter a cache; it is not a production async LRU implementation. The key also needs deliberate design. Model version, tenant, and authorization conditions can affect a result. A cache can operate exactly as written while returning data that is inappropriate for the current request if those distinctions are lost.

Reusing values does not merge concurrent misses

Two callers can both miss before either stores a result. Even on a single event-loop thread, an await can let another task enter that unfinished interval. The race example uses two Events to establish this ordering explicitly. Both tasks must enter the loading stage before both_entered wakes the main coroutine. Main then checks that the result is still absent and sets release so both tasks can complete. The observed entered count is two, without relying on a lucky sleep duration.

Keeping successful results and allowing only one simultaneous execution per key are separate contracts. One manages completed data; the other must represent ongoing work. Renaming the dictionary or adding hit counters does not create that second contract. The earlier article on duplicate computations with thread-safe lru_cache concerns synchronous threads. The additional issue here is the lifecycle of coroutine objects and unfinished asynchronous operations.

Coroutine
One execution process
Cannot await again after completion
Task
State of one operation
Success and failure need policies
Completed value
Application data already obtained
Concurrent first requests can still duplicate work
Original comparison: sharing ongoing work and reusing completed data are separate designs. No automatic expiry or retry is implied.

A shared Task also retains a failure state

A Task executes a coroutine and retains its completion state. Within one running event loop, the program creates task and awaits it twice successfully. Only one additional call to load occurs. The coroutine is not being restarted: both awaits observe the completion of the same task. The example does not create a global Task cache spanning event loops.

A failed Task retains a completion state too. The failed task executes fail once, but both awaits raise ValueError. Saving that Task in a dictionary indefinitely would keep serving the same failure rather than automatically retrying. This is a policy choice hidden by advice to “just cache a Task”: how long should successful entries live, when should failed entries be removed, and who starts the next attempt? Evicting an in-flight entry also requires deciding whether to remove only the cache reference or terminate the operation; those are different actions.

Cancellation raises an ownership question as well. If one waiting caller leaves, should it be allowed to cancel work that another caller still needs? No generic cancellation wrapper or shielding behavior is demonstrated here. Before implementing request merging, define caller cancellation, task failure, and service shutdown separately. Retain Task references and consume their eventual results or exceptions; placing them in a container is not lifecycle management by itself.

The complete program and nineteen checks

The program uses only the standard library and was executed with Python 3.15.0. Its nineteen checks cover a hit before body execution, sequential coroutine-reuse failure, completed-value reuse, two concurrent loads, repeated Task awaits, and retained failure. It makes no retrieval or model-service calls and measures neither throughput nor cost. Every Task it creates is awaited, and explicit events release the controlled operations.

import asyncio
from functools import lru_cache

checks = 0
def check(condition):
    global checks
    assert condition
    checks += 1

async def main():
    calls = 0
    @lru_cache(maxsize=8)
    async def bad(key):
        nonlocal calls
        calls += 1
        return (key, 42)

    first = bad('demo')
    second = bad('demo')
    check(first is second)
    check(calls == 0)
    check(bad.cache_info().hits == 1)
    check(await first == ('demo', 42))
    check(calls == 1)
    try:
        await second
    except RuntimeError:
        check(True)
    else:
        raise AssertionError('expected coroutine reuse failure')
    bad.cache_clear()

    values = {}
    value_calls = 0
    async def load(key):
        nonlocal value_calls
        value_calls += 1
        return (key, 42)
    async def get_value(key):
        if key in values:
            return values[key]
        result = await load(key)
        values[key] = result
        return result

    a = await get_value('demo')
    b = await get_value('demo')
    check(a is b)
    check(value_calls == 1)
    check(values['demo'] == ('demo', 42))

    entered = 0
    both_entered = asyncio.Event()
    release = asyncio.Event()
    concurrent_values = {}
    async def race(key):
        nonlocal entered
        if key in concurrent_values:
            return concurrent_values[key]
        entered += 1
        if entered == 2:
            both_entered.set()
        await release.wait()
        result = (key, 42)
        concurrent_values[key] = result
        return result
    tasks = [asyncio.create_task(race('same')) for _ in range(2)]
    await both_entered.wait()
    check(entered == 2)
    check('same' not in concurrent_values)
    release.set()
    check(await asyncio.gather(*tasks) == [('same', 42)] * 2)

    task = asyncio.create_task(load('shared'))
    check(await task == ('shared', 42))
    check(await task == ('shared', 42))
    check(value_calls == 2)

    failures = 0
    async def fail():
        nonlocal failures
        failures += 1
        raise ValueError('example')
    failed = asyncio.create_task(fail())
    for _ in range(2):
        try:
            await failed
        except ValueError:
            check(True)
        else:
            raise AssertionError('expected retained failure')
    check(failures == 1)
    check(failed.done())
    print(f'{checks} checks passed')

asyncio.run(main())

The output is 19 checks passed. For a project regression suite, retain three scenarios: identical arguments awaited consecutively, simultaneous cold misses, and a second call after failure. Then add checks for the chosen capacity, invalidation, and cancellation contracts. Testing only the first successful call misses precisely the lifecycle error: the first call can work while leaving a consumed coroutine in the cache.

Start a cache review by asking what is stored, then ask how long that object remains reusable. A coroutine represents execution, a Task represents awaitable task state, and a completed value represents application data. Their proximity to async code does not make them interchangeable.

Sources are the CPython 3.13.0 functools documentation and asyncio documentation. Both diagrams, the event-controlled comparison, and the verification program are original teaching material. The article retains October 25, 2026 at 23:30 Beijing time and was actually published as a catch-up article on October 26, 2026.

Support

If the explanations or examples helped you solve a problem or save time, you’re welcome to support this site with a voluntary tip.

WeChat support QR code; click to enlarge
WeChat
Alipay support QR code; click to enlarge
Alipay
Buy Me a Coffee; support this site
Buy Me a Coffee

Click a QR code to enlarge. More options: support page。

Share this Post

Leave a Comment

您的邮箱地址不会被公开。 必填项已用 * 标注

*
*