async函数为何不能直接用lru_cache

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

把lru_cache直接加在async def上,缓存的不是await之后的结果。普通装饰器看到的是函数调用立即返回的协程对象,而不是协程将来返回的值。相同参数再次命中时,拿到的仍是原来那个协程;它一旦被等待到结束,就不能再次执行。

文章目录
  1. 缓存命中发生在函数体运行之前
  2. 第一次await成功,第二次为何失败
  3. 只缓存成功完成的值,能解决什么
  4. 结果缓存不等于并发请求合并
  5. 共享Task,失败以后还会重试吗
  6. 完整程序与十九项检查

异步检索、配置读取或模型请求的包装函数,都可能遇到这个错误。下面用不访问网络的完整示例,把三件容易混淆的事拆开:复用协程、复用已完成值,以及多个调用者等待同一个Task。Read in English。

缓存命中发生在函数体运行之前

普通同步函数返回一个元组,lru_cache可以保存这个元组。async def的调用则先创建协程对象,调用这一步并不等于运行函数体。外层lru_cache没有自动插入await,也不会把协程的return值提取出来保存。这是两个返回时刻,不能根据源代码里写着return就把它们视为同一个时刻。

可以从CPython 3.13.0的协程文档核对调用与执行的区别。同版本functools文档也明确将async函数列为不适合直接使用这种缓存的对象。这里引用固定历史版本,是为了让补发文章的资料时间可追溯,不是在介绍一个2026年才出现的新行为。

示例里连续调用bad(‘demo’)两次,尚未await。两次返回对象的身份相同,cache_info().hits已经是1,函数体里的calls却仍为0。这个组合很有诊断价值:命中率增加只能证明装饰器复用了一个返回对象,不能证明一次远程查询已经执行或成功。

① 调用bad(key)
缓存协程C
函数体次数:0
② 首次await C
得到元组
函数体次数:1
③ 再次使用已命中的C
再次await
RuntimeError
原创时间线:缓存命中可以早于函数体执行,命中的对象并非已完成数据。

第一次await成功,第二次为何失败

第一次await first把协程执行完,得到(‘demo’,42),calls变为1。接着await second会触发RuntimeError,因为second指向的就是刚刚用完的那个原生协程对象。代码只检查异常类型,不把具体英文报错字符串写成跨版本接口约定。

这与“相同参数会重新创建一个等价协程”不同。缓存命中绕过了原函数调用,也就没有创建新的协程。清空缓存后下次调用可以产生新对象,但“每次使用前清缓存”会放弃跨调用复用,也不能修复已经交给其他调用者的旧引用。

排查时先记录对象类型、是否为同一对象、函数体实际进入次数,再看cache_info。不要打印完整请求参数或返回文档;在检索和模型应用中,它们可能含私人内容。本文使用固定虚构键与元组,足以检验机制,不需要真实请求日志。

只缓存成功完成的值,能解决什么

get_value先查values,未命中则await load,再把result写入字典。返回给调用者的是完成后的元组,不是协程。两次顺序await都成功,底层load只进入一次,而且结果身份相同。使用不可变元组也避免了这个示例里一个调用者修改共享列表、影响另一个调用者的问题。

写入发生在await成功之后,所以这段实现不会把load抛出的异常当作成功结果存入values。若加载失败,下次调用仍会尝试加载。这只是代码的行为,不代表所有业务都应无条件重试;外部服务的限流、错误类型和退避策略需要另外决定。

这个最小实现没有容量限制、TTL、失效管理,也没有并发合并。它的用途是解释缓存位置,不是生产环境的异步LRU库。若结果还受模型版本、租户或权限条件影响,应把影响结果的条件纳入设计,不能仅用问题文本作通用共享键。否则缓存正确运行,也可能返回不适合当前请求的数据。

结果缓存不等于并发请求合并

两个调用者可以在首个结果写入前同时发现未命中。即使事件循环在同一线程运行,await仍为另一个任务提供了进入未完成区间的机会。示例中的race用两个Event固定这个顺序:两个任务都进入加载阶段后,both_entered才放行主任务;主任务确认字典仍没有结果,再设置release允许两者完成。这样能稳定看到entered为2,无需靠sleep碰运气。

所以“完成后只存成功值”和“同一键同时只执行一次”是两项不同契约。前者处理已经完成的数据,后者还需要管理正在进行的工作。给字典换一个名字或增加命中统计,不会凭空产生后者。之前lru_cache线程安全仍可能重复计算讨论的是同步多线程;这里的新增问题是协程对象生命周期与异步未完成状态。

协程对象
一次执行过程
完成后不能重新await
Task
同一次工作的完成状态
成功或失败均需策略
完成值
已得到的业务数据
并发首次请求仍可重复加载
原创对照:共享工作状态与复用完成数据是不同设计;图中没有承诺自动失效或重试。

共享Task,失败以后还会重试吗

Task负责执行协程并保存完成状态。在同一个运行中的事件循环内,示例创建一个task,两次await都拿到相同内容,load只多执行一次。它不是让原生协程重新运行,而是在等待同一次任务的完成。本文不创建跨事件循环的全局Task缓存。

失败的Task也有完成状态。failed只执行一次fail,随后两次await都得到ValueError。把Task保存在字典里而从不移除失败项,会让后续调用继续遇到同一次失败,不会自动变成重试。这正是“换成Task就好了”遗漏的决策:成功项留多久,失败项何时清理,由谁发起下一次工作。淘汰正在运行的项时,还应决定只是移除缓存引用,还是终止工作;这两个动作不能混为一谈。

取消还涉及所有权:一个等待者离开时,是否允许它取消共享工作?其他等待者是否仍需要结果?这里不提供未经验证的通用取消封装,也没有演示屏蔽取消。真正实现请求合并前,应把调用者取消、任务失败和服务关闭分别设计;持有Task引用并消费最终结果或异常,不能只把它放入容器就不管。

完整程序与十九项检查

下面只用标准库,实测环境为Python 3.15.0。十九项检查覆盖提前命中但未执行、顺序重用协程报错、完成值复用、并发双加载、Task重复等待和失败状态保留。程序没有访问检索服务或模型,没有测吞吐与成本。所有创建的Task都会被等待,事件也由明确条件释放。

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())

输出为19 checks passed。要把它改为项目里的回归检查,可以保留相同参数连续等待、并发冷启动和失败后第二次调用这三种场景,再按业务契约补上容量、失效和取消测试。不要只测试第一次调用成功:第一次成功恰恰掩盖了缓存着一次性协程的问题。

判断缓存设计时,先问容器里到底放了什么,再问它能复用多久。协程对象、Task和已完成数据分别代表执行过程、可等待的任务状态与业务结果,不能因为都能出现在异步函数附近就互相替换。

资料依据为CPython 3.13.0的functools文档与asyncio文档;图形、事件控制的对照和检查程序为原创教学示例。文章日期保留北京时间2026年10月25日23:30,实际于2026年10月26日补发。

支持

如果文中的解释或示例帮你解决了问题、节省了时间,欢迎自愿打赏支持本站。

微信支持二维码,点击查看大图
微信
支付宝支持二维码,点击查看大图
支付宝
Buy Me a Coffee,点击支持本站
Buy Me a Coffee

二维码可点击放大。更多支持方式见支持页面。

Share this Post

Leave a Comment

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

*
*