lru_cache线程安全,为何仍重复计算

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

lru_cache的线程安全,保证的是并发更新时缓存内部结构保持一致,不保证同一个冷键只计算一次。两个请求都在第一个结果写入前到达,仍可能各自执行函数体。若要减少同键重复工作,需要额外协调正在进行的计算,并在取得协调权后重新检查缓存;这仍需配合明确的淘汰与失败政策。

文章目录
  1. 缓存状态一致,和工作不重复,是两件事
  2. 用同步屏障复现,而不是碰运气
  3. 取得键级锁以后,再查询缓存
  4. maxsize限制不了这张锁表
  5. 失败、超时与多副本,需要单独定义
  6. 用于AI服务前,先检查键代表什么

Read this article in English。这对模型配置加载、昂贵的特征计算或Embedding请求缓存都有实际意义:缓存命中率看起来正常,并不意味着冷启动时后端只收到了一次计算。本文用合成字符串结果复现时序,没有调用模型、网络接口或GPU,也不据此估算费用与性能。

缓存状态一致,和工作不重复,是两件事

缓存记录的是已经完成、可以复用的结果;同键请求合并则协调尚未完成的工作。前者回答“现在有没有值”,后者回答“谁在生产这个值,其他请求该怎么办”。没有完成结果时,一份普通缓存不一定有可供等待者订阅的计算状态。

Python的functools文档明确区分了这两点:缓存支持多线程使用,但在首次调用完成并缓存前,包装的函数可能被再次调用。不能仅从装饰器里存在锁,就推断整段业务计算也在锁内。

为看清机制,可以参考CPython v3.13.0的Python实现:有容量上限的分支在锁内查找和维护缓存,未命中后在锁外执行用户函数,再回到锁内检查与更新。该文件也会导入C加速实现;本文没有强制本机执行Python回退版本。源码用于解释设计,下面的实验通过公开API核验实际行为。

若把昂贵操作全放进一把全局锁,虽然能抑制重叠计算,也会让不相关键互相等待。需要保护的临界区和需要合并的业务工作应当分开识别,不能只用“加锁就好”概括设计。

A查询键K → 未命中 → 开始计算
A未返回时,B查询K → 仍未命中
A和B分别返回 → 缓存中只有一个键
缓存条目数1 ≠ 函数体执行1次
图1:同键冷请求可以产生两次计算和一个缓存条目。示意的是并发顺序,不是吞吐量测量。

用同步屏障复现,而不是碰运气

第一段实验让两个工作线程同时调用duplicate(“same”)。函数体一进入就增加受锁保护的计数,然后等待Barrier。只有两次调用都已进入函数体,屏障才放行;因此不能靠第一个调用过快完成、第二个恰好命中缓存来隐藏重复工作。

两个Future都返回相同字符串后,函数体计数为2,cache_info显示两次miss、一个当前条目。再顺序调用同一个键,命中数增加,函数体计数仍为2。这个结果没有矛盾:两个并发miss分别执行了工作,但后续请求仍然可以复用最终缓存。

屏障和Event用于控制先后顺序,有限等待只用于避免测试失控时永久挂住,不是用sleep猜测线程进度。实验不测执行耗时,也没有验证不同键的吞吐量。将“重复次数减少”改写成“速度提高一倍”,会超出这组证据。

取得键级锁以后,再查询缓存

第二段演示先在短暂的注册表锁内获取键对应的Lock,然后退出注册表锁,在键锁内调用带缓存的cached函数。A持有键锁并进入函数体时,B可以取得同一把锁的引用,但要等A释放后才能调用cached。等A返回并缓存结果,B再调用时才有机会命中。

关键是顺序:取得键级协调权,然后检查缓存。若两个请求先在锁外判断“没有值”,再依次拿锁直接执行事先决定好的计算,等待只会把两次计算串行化,不能消除第二次工作。缓存查询与“要不要生产”的决定必须在取得键级协调权之后重新确认。

演示用entered确认第一个函数体已经开始,用attempted确认第二个请求已获取键锁引用;在放开release前检查函数体仅执行一次,两个外层Future都未完成。随后放行,两个调用取得相同结果,统计为一次miss、一次hit、一个条目。这里没有其他键挤走结果,缓存也没有被清空。

注册表锁:只负责找到K对应的锁
A持有K锁 → 查缓存 → 计算并缓存
B等待K锁 → 取得后重新查缓存
成功路径:函数体1次,缓存命中1次
图2:教学版键锁与缓存的组合。全局注册表锁不包住计算;该图没有承诺锁回收、失败共享或跨进程协调。
from functools import lru_cache
from concurrent.futures import ThreadPoolExecutor
from threading import Barrier, Event, Lock
import sys

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

counter_lock = Lock()
calls = 0
both = Barrier(2, timeout=3)
@lru_cache(maxsize=8)
def duplicate(key):
    global calls
    with counter_lock:
        calls += 1
    both.wait()  # Both cold calls entered before either can return.
    return 'value:' + key

with ThreadPoolExecutor(max_workers=2) as pool:
    a = pool.submit(duplicate, 'same')
    b = pool.submit(duplicate, 'same')
    check(a.result(timeout=4) == b.result(timeout=4) == 'value:same')
check(calls == 2)
i = duplicate.cache_info()
check(i.misses == 2 and i.currsize == 1)
check(duplicate('same') == 'value:same')
check(calls == 2 and duplicate.cache_info().hits == 1)

# Teaching registry: no eviction, recursion, cancellation or failure policy.
registry = {}
registry_lock = Lock()
attempts = 0
attempted = Event()
entered = Event()
release = Event()
serial_calls = 0
@lru_cache(maxsize=8)
def cached(key):
    global serial_calls
    with counter_lock:
        serial_calls += 1
    entered.set()
    if not release.wait(3):
        raise RuntimeError('cleanup gate timed out')
    return 'value:' + key

def merged(key):
    global attempts
    with registry_lock:
        key_lock = registry.setdefault(key, Lock())
        attempts += 1
        if attempts == 2:
            attempted.set()
    with key_lock:
        return cached(key)  # Recheck cache after acquiring the key lock.

with ThreadPoolExecutor(max_workers=2) as pool:
    a = pool.submit(merged, 'same')
    b = pool.submit(merged, 'same')
    try:
        check(entered.wait(1) and attempted.wait(1))
        with counter_lock:
            check(serial_calls == 1)
        check(not a.done() and not b.done())
    finally:
        release.set()
    check(a.result(timeout=2) == b.result(timeout=2) == 'value:same')
check(serial_calls == 1)
i = cached.cache_info()
check(i.misses == 1 and i.hits == 1 and i.currsize == 1)
print(checks, 'checks passed; Python', sys.version.split()[0])
print('same-key cold body calls: lru_cache=2; key-lock plus cache=1')

代码实际在Python 3.15.0运行,11项检查通过。它使用不可变字符串作为返回值,不代表缓存可变数组、模型对象或连接对象也天然安全。缓存复用了什么对象,以及调用方是否会修改它,是另一个需要按返回值语义审查的问题。

maxsize限制不了这张锁表

这份代码刻意保留了一个教学边界:registry永不删除键。lru_cache(maxsize=8)限制的是结果缓存条目,不会限制外部字典中的锁数量。若服务不断接收新键,锁表会继续增长,因此不能把本例原样当成有界内存的生产实现。

直接在一次调用结束后pop(key)也有竞态。假设B已经拿到了旧锁L1的引用并在等待,A结束时把注册表中的K删掉;随后C为K创建新锁L2。B和C持有不同的锁,同键互斥规则已经失效。正确回收需要考虑所有持有者与等待者的生命周期,而不只是“当前这次计算完成了”。

可选择带引用计数或显式进行中状态的管理方式,但要核验条目的建立、加入等待、完成通知和移除是如何协调的。本文没有实现这样的完整single-flight组件。键锁加缓存的演示用于说明顺序;若等待期间结果被其他键淘汰,或者有人清空缓存,后来的调用仍可能再次计算,不能宣称整个等待队列必然共享同一个结果。

示例使用普通非重入Lock,不适用于同键递归调用或存在循环依赖的键调用链。这些路径可能互相等待;不能因为成功路径断言通过,就把它部署到任意递归计算里。

失败、超时与多副本,需要单独定义

实际组件还要回答:生产者失败后,等待者收到同一个异常,还是允许重试?某个等待者超时,是否影响仍在进行的工作?若组件选择保留失败状态,应保留多久,下一次请求何时能重新竞争?这些是接口政策,不能由一把锁替你作出决定。本文没有执行失败传播或取消测试。

“调用方不再等待”也不等于后台计算已经停止。若计算是线程中的模型或远程调用,生命周期应与请求超时分开观察;可以结合asyncio超时与后台线程理解这一边界。对等待者数量,还应有独立于结果缓存的容量控制,否则减少重复工作也可能积累大量排队请求。

此处registry与缓存都在同一进程内。多个服务进程或副本各有自己的状态,不能从本例推导出跨副本请求合并。若业务工作包含远端写入,缓存和键锁也不能提供端到端恰好一次;接收端的去重与重试规则仍需明确,相关问题可见重试与幂等。

用于AI服务前,先检查键代表什么

把这个机制用于Embedding等计算时,键必须包含会改变结果含义的条件,例如模型版本、预处理规则与输出配置。只按文本字符串复用,而忽略已经更换的模型版本,可能得到命中率很高却语义过期的结果。若结果取决于访问范围或租户数据,还必须让不同权限上下文得到正确隔离。

本例只有一个字符串参数,因此锁表和缓存使用同一个key。推广到多参数、关键字参数或自定义对象时,必须先统一两套键规则:缓存视为同一请求的调用,不能各自拿到互不相关的锁。

键归一化也应谨慎:两个字符串看起来相似,不代表你的模型或预处理一定将它们视为同一个输入。应按业务语义定义等价关系,而不是为了增加命中率随意删除空白或标点。相反,等价请求如果采用不同键,也会绕过同键协调。这些建议来自缓存正确性的要求,不是某个模型SDK的默认行为说明。

上线验收宜分别记录外层请求数、函数体实际执行数、缓存命中与未命中、等待者数量和失败结果。先用固定的同键冷请求复现时序,再验证不同键、淘汰、失败和取消场景;只有都满足定义,才有依据判断组件适合哪条路径。不要用最终缓存大小代替实际计算次数。

本文为机制解读,引用的CPython v3.13.0源码早于原计划文章日期;文档版本不代表本地执行版本。本文于2026年10月25日补发,保留原计划的2026年10月23日09:00文章日期。

支持

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

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

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

Share this Post

Leave a Comment

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

*
*