Watch at Ease看见世界,读懂生活。

科技 · 实用指南

Python 3.12 的 cached_property 会执行两次?仅在正确性需要时加单航班锁

Python 3.12移除了cached_property原来的描述符级锁。先证明重复计算会造成伤害,再用按实例的锁只发布成功结果。

依据并发访问和幂等性选择普通cached_property或按实例单航班的决策树。
原创解释性示意图:仅当重复首次计算影响正确性或造成不可接受成本时才同步。 Watch at Ease

缓存最终保持一致,并不等于getter只执行一次。Python文档中的线程安全主要指缓存结构不会被破坏;从Python 3.12开始,同一实例首次访问时,两条线程都可能在结果写入前进入getter。

不要条件反射地加锁。纯粹、幂等的派生计算重复一次通常只是性能成本;若getter会扣费、建文件、分配外部ID、修改共享状态或依赖对象身份,就应把只执行一次当成独立的正确性要求。

1. 修改设计前先证明竞争

用屏障让两条线程同时首次读取同一实例。getter开始时递增计数并短暂停顿;在Python 3.12及以上版本中,即使最终读到同一个缓存值,计数为2也符合文档。

记录Python版本、两条线程是否共享同一实例,以及框架是否替换了cached_property。若测试创建了两个实例,验证的是另一类问题。

2. 按后果而不是仅按耗时分类

只读取不可变字段的纯计算通常幂等,重复执行只会浪费时间。昂贵计算也可能需要合并,但那是性能选择。

数据库插入、付款、建文件、外部请求、计数器和一次性注册会影响正确性。能使用幂等键或事务时应优先使用;进程内锁无法保证跨进程或重试时的全局恰好一次。

3. 在按实例锁内写入值

使用普通property、私有哨兵、缓存字段和实例锁。快路径不加锁直接返回;慢路径加锁后再检查一次,计算并写入后才释放。

关键顺序是:检查、加锁、再次检查、计算、赋值、释放。只在cached_property getter内部加锁但让装饰器在锁外写缓存,会留下额外窗口。

4. 保留失败与失效语义

仅在计算成功返回后赋值。异常会保留哨兵,使后续访问重试。不要先发布半初始化的可变对象,再在锁外补完。

若支持清除缓存,应在同一实例锁内清除,并说明正在读取的线程能否继续使用旧对象。若None是合法结果,就不能拿None当哨兵。

5. 不要重建Python 3.11的争用

把锁放在描述符或类上会串行化不相关实例,正好重现旧行为被移除的原因。每次访问临时新建一把锁也没有保护作用。

若每实例锁开销不可接受,可以考虑提前构造、不可变预计算、支持请求合并的外部缓存,或重新设计副作用。不要假设装饰器自带单航班保证。

6. 用四项测试验收

测试同一实例两条并发读取只计算一次;测试两个实例可以重叠计算;让第一次计算抛错并确认下次重试;最后在锁内失效并确认只发布一个新值。

在所有支持的Python版本上运行。目标不是模仿未公开的历史实现,而是明确并验证应用自己的并发契约。

按实例单航班流程:检查哨兵、加锁、再次检查、计算并在释放前赋值。
原创解释性示意图:锁后复查与锁内赋值共同关闭重复初始化窗口。 Watch at Ease