lru_cache线程安全,为何仍重复计算
lru_cache的线程安全,保证的是并发更新时缓存内部结构保持一致,不保证同一个冷键只计算一次。两个请求都在第一个结果写入前到达,仍可能各自执行函数体。若要减少同键重复工作,需要额外协调正在进行的计算,并在取得协调权后重新检查缓存;这仍需配合明确的淘汰与失败政策。
文章目录
Read this article in English。这对模型配置加载、昂贵的特征计算或Embedding请求缓存都有实际意义:缓存命中率看起来正常,并不意味着冷启动时后端只收到了一次计算。本文用合成字符串结果复现时序,没有调用模型、网络接口或GPU,也不据此估算费用与性能。
缓存状态一致,和工作不重复,是两件事
缓存记录的是已经完成、可以复用的结果;同键请求合并则协调尚未完成的工作。前者回答“现在有没有值”,后者回答“谁在生产这个值,其他请求该怎么办”。没有完成结果时,一份普通缓存不一定有可供等待者订阅的计算状态。
Python的functools文档明确区分了这两点:缓存支持多线程使用,但在首次调用完成并缓存前,包装的函数可能被再次调用。不能仅从装饰器里存在锁,就推断整段业务计算也在锁内。
为看清机制,可以参考CPython v3.13.0的Python实现:有容量上限的分支在锁内查找和维护缓存,未命中后在锁外执行用户函数,再回到锁内检查与更新。该文件也会导入C加速实现;本文没有强制本机执行Python回退版本。源码用于解释设计,下面的实验通过公开API核验实际行为。
若把昂贵操作全放进一把全局锁,虽然能抑制重叠计算,也会让不相关键互相等待。需要保护的临界区和需要合并的业务工作应当分开识别,不能只用“加锁就好”概括设计。
用同步屏障复现,而不是碰运气
第一段实验让两个工作线程同时调用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、一个条目。这里没有其他键挤走结果,缓存也没有被清空。
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文章日期。
支持
如果文中的解释或示例帮你解决了问题、节省了时间,欢迎自愿打赏支持本站。
二维码可点击放大。更多支持方式见支持页面。


