Python 用 @cached_property 缓存实例属性:惰性计算、什么时候失效,以及它不是线程安全的
Python 用 cached_property 缓存实例属性:惰性计算、什么时候失效,以及它不是线程安全的你写了个类,里面有个属性算起来很贵——要读文件、查数据库、跑一段复杂计算。结果每次访问obj.data都重新算一遍,一个请求里访问五次就白算四次。很多人第一反应是在__init__里算好存起来,但那样对象一创建就付出代价,哪怕这次根本用不到。functools.cached_property就是为这个场景生的:第一次访问才算,算完存进实例,之后直接返回缓存。这篇讲清楚它怎么用、什么时候失效、以及一个容易忽略的线程安全坑。朴素写法的两个问题先看不加缓存的property:classReport:def__init__(self,path):self.pathpathpropertydefdata(self):print(正在解析...)# 每次访问都会打印withopen(self.path)asf:returnheavy_parse(f.read())# 每次都重新解析rReport(big.csv)print(r.data)# 正在解析...print(r.data)# 正在解析... —— 又算了一遍!property每次访问都执行方法体,重复计算。那放__init__里呢?classReport:def__init__(self,path):self.dataheavy_parse(open(path).read())# 创建对象就解析这又走向另一个极端:哪怕你只是想Report(big.csv).path,压根不用data,也被迫解析了一遍。我们想要的是「用到才算、算一次」。cached_property:惰性 缓存fromfunctoolsimportcached_propertyclassReport:def__init__(self,path):self.pathpathcached_propertydefdata(self):print(正在解析...)# 只会打印一次withopen(self.path)asf:returnheavy_parse(f.read())rReport(big.csv)print(r.data)# 正在解析... —— 第一次访问才计算print(r.data)# (无输出) —— 直接返回缓存它的原理很简单:第一次访问时执行方法,把结果写进实例的__dict__,属性名就叫data。之后再访问r.data,因为实例字典里已经有data了,描述符根本不会被触发,直接命中字典。你可以验证:print(datainr.__dict__)# 第一次访问后为 True,值就存这里怎么让缓存失效:直接 del正因为缓存存在实例__dict__里,想重新计算,del掉它就行:delr.data# 删掉缓存print(r.data)# 正在解析... —— 重新计算并再次缓存这比很多手写缓存方案干净:没有额外的_cache字段,失效就是删字典键。适合「数据源变了要刷新」的场景,比如文件被重写、配置被更新。和 property 的关键区别:cached_property 可以被赋值普通property不带 setter 时是只读的,赋值会报错。但cached_property因为本质是往实例字典写值,可以直接赋值覆盖:r.datamanual override# 合法!直接写进 __dict__print(r.data)# manual override这既是特性也是坑:如果你依赖data永远由计算得来,别人一行赋值就能悄悄改掉它。需要真正只读的语义时,用带缓存逻辑的普通 property 更稳。硬性限制:类必须有dictcached_property靠写实例__dict__工作,所以如果类定义了__slots__且没包含__dict__,它会直接报错:classReport:__slots__(path,)# 没有 __dict__cached_propertydefdata(self):return42Report(x).data# TypeError: No __dict__ attribute on Report ... cached_property用__slots__省内存的类想缓存属性,得手动实现,或者在 slots 里显式加上__dict__(但那样就抵消了 slots 的省内存效果)。最容易忽略的坑:不是线程安全的这是文档里写了但很多人没注意的点。多个线程同时第一次访问同一个实例的cached_property,计算函数可能被执行多次:importthreading,timefromfunctoolsimportcached_propertyclassConfig:cached_propertydefvalue(self):print(computing...)# 高并发下可能打印不止一次time.sleep(0.1)# 模拟慢计算,放大竞态return42cfgConfig()threads[threading.Thread(targetlambda:cfg.value)for_inrange(5)]fortinthreads:t.start()fortinthreads:t.join()# 可能看到多行 computing... —— 缓存写入前多个线程都在算Python 3.12 前的旧版本甚至用一把类级别的锁,导致不同实例的访问互相阻塞(性能问题);新版本移除了那把锁,于是变成上面这种「可能重复计算」。如果你的计算有副作用(比如建数据库连接)或者非常昂贵,别在多线程首次访问下裸用cached_property,自己加锁:importthreadingfromfunctoolsimportcached_propertyclassConfig:_lockthreading.Lock()cached_propertydefvalue(self):withself._lock:# 自己兜线程安全ifvalueinself.__dict__:returnself.__dict__[value]# 双重检查,别人已算好returnexpensive()好在大多数 Web 场景每个请求各用各的对象,单线程访问,裸用完全没问题。知道这个边界,才不会在高并发共享单例时被坑。小结cached_property 惰性计算 结果缓存进实例__dict__,第一次访问才算、之后直接命中。失效很简单:del obj.attr删掉缓存键即可重新计算。它可以被直接赋值覆盖(不像只读 property),既灵活也可能被误改。依赖实例__dict__,和不含__dict__的__slots__冲突。不是线程安全的:多线程首次并发访问可能重复执行计算,有副作用/昂贵计算时自己加锁。一句话记忆点:cached_property是「懒汉版属性」,单线程里用它省心又干净;一旦跨线程共享,记得它不帮你上锁。