科技 · 实用指南
Python 3.12 的 cached_property 会执行两次?仅在正确性需要时加单航班锁
Python 3.12移除了cached_property原来的描述符级锁。先证明重复计算会造成伤害,再用按实例的锁只发布成功结果。

缓存最终保持一致,并不等于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版本上运行。目标不是模仿未公开的历史实现,而是明确并验证应用自己的并发契约。
