ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Python 运行时机制精讲:内存、描述符、协程与性能调优

Python 运行时机制精讲:内存、描述符、协程与性能调优 这个系列写到第十二篇我发现一件挺有意思的事真正拉开水平差距的从来不是谁会背更多的语法糖而是谁对 Python 运行时的行为边界更清楚。你可能已经把装饰器、推导式、生成器用得滚瓜烂熟但一旦遇到内存不降、属性赋值失效、异步任务悄悄卡死这类问题还是得回到最底层的对象模型和协议约定上找答案。这篇不打算再罗列十个好用的小技巧那种清单。我把最近一年在真实项目里反复用得上的六个方向重新整理了一遍从对象内存布局、描述符协议、上下文管理器契约到生成器与协程的边界、类创建钩子、缓存与分派的失效场景。内容偏运行时机制适合已经写过至少几千行 Python、能独立维护一个模块或服务的同学。如果你还在纠结怎么装环境、怎么写第一个函数这篇可以收藏起来晚点再看。下面所有代码都在 CPython 3.11 上跑过个别版本差异我会单独标出来。1. 先算清内存这本账slots、弱引用与循环引用的真实代价__slots__大概是 Python 里最被滥用也最被误解的特性。很多人在面试里能一字不差地答出用来节省内存、限制实例属性转头在项目里加上去用sys.getsizeof一测发现数字几乎没动于是判定这东西没用。问题出在测量方法本身——sys.getsizeof只算对象自身那一块连续内存根本不会把你挂在对象上的实例字典算进去而__dict__恰恰是内存的大头。1.1 用 sys.getsizeof 单测实例等于什么都没测一个不带__slots__的普通实例它自己那点内存通常在几十字节量级真正的开销是背后那个独立分配的实例字典。字典即使一个键都没插也要占几十字节插入几个键之后还会按 2 的幂次扩容键和值的指针各自占位。所以正确的测量方式至少要写成两段相加import sys class Point: def __init__(self, x, y): self.x x self.y y p Point(1, 2) print(sys.getsizeof(p)) # 只有实例头 print(sys.getsizeof(p.__dict__)) # 真正的开销在这里但即使这样也还是不够准。字典的值指向的对象比如字符串、嵌套对象本身的内存不在里面容器类对象的容量也不等于实际元素大小。真要较真用pympler.asizeof.asizeof(p)递归统计或者用tracemalloc抓两份快照做差值——后者是我更推荐的方式因为它能告诉你内存是在哪个调用栈上被分配的而不是只给一个总数。1.2slots的三个失效条件我在代码评审里见过最多的写法是给子类加__slots__父类不管。这种改法基本等于白干因为__slots__的省内存效果依赖整条继承链都参与了。继承链上有一环没写__slots__。只要某个基类没定义__slots__它就会自动带上__dict__子类即使声明了__slots__实例上依然存在那个字典只是新字段改存到 slot 描述符里。省下的只是新增字段那部分收益大幅缩水。类里用了functools.cached_property或者代码里出现了self.__dict__、vars(self)。带__slots__的实例没有__dict__这些写法会直接抛AttributeError。同理那些喜欢往实例上动态挂临时属性的框架代码也会当场炸掉。实例需要被weakref引用但__slots__里没写__weakref__。默认情况下定义了__slots__的类实例是不可弱引用的除非显式把__weakref__加进去或者某个基类已经提供了这个槽位。这一点在写缓存、监听器列表时特别容易踩。还有一些细节值得一并记住__slots__里的名字不能和类变量重名否则类创建阶段就直接报ValueError旧版本 Python 里对带__slots__的类做 pickle 需要自己实现__getstate__和__setstate__如果项目里有序列化环节改之前一定要先跑一遍相关测试。1.3 weakref 和 gc什么时候该怀疑循环引用Python 靠引用计数所以循环引用必然泄漏这句话对一半。CPython 的确以引用计数为主但分代垃圾回收器专门负责处理循环垃圾绝大多数循环引用会被自动清掉。真正需要警惕的是两种情况一是对象被gc判定为不可回收比如旧版本里带__del__的循环PEP 442 之后已经好很多二是对象其实可达只是你以为它该被释放了——比如被某个全局缓存或者异常回traceback 悄悄持有。排查路径我一般这么走先用gc.get_objects()或者objgraph看某一类实例的数量是不是持续上涨再用gc.get_referrers(obj)反查是谁在引用它。objgraph.show_backrefs能直接画出一张引用链图比手动翻代码快得多。sys.getrefcount也要会用但记住它会把参数本身那一次引用也算进去看到的数字通常比真实值多 1别被吓到。至于弱引用本身weakref.WeakValueDictionary是我最常用的一个拿它做实例级缓存键是某个标识值是对象对象一旦没别的地方引用就自动从字典里消失完全不需要手动清理。WeakSet适合做监听器集合。需要注意的是int、str、tuple这些内置类型不支持弱引用用之前先确认目标类型有没有__weakref__。2. 描述符协议property 只是它的一个特例property用得太顺手导致很多人根本没意识到它背后是一套通用协议。你写一个类属性访问obj.x到底走哪条路Python 有一套非常明确的优先级规则理解它之后很多为什么我的 setter 没被调用为什么子类属性把父类的覆盖了之类的问题就不用猜了。2.1 数据描述符和非数据描述符的分水岭判断标准只有一条这个描述符类型有没有定义__set__或__delete__。只有__get__的叫非数据描述符同时具备__set__或__delete__的叫数据描述符也有资料叫覆盖型描述符。这个区别的直接后果是数据描述符会优先于实例字典非数据描述符会被实例字典覆盖。最典型的例子就是函数——函数是非数据描述符所以你可以在实例上写obj.method some_callable实例字典里的这个值会盖掉类上的方法。而property是数据描述符所以一旦你给属性定义了 setter就算实例字典里真有同名键也会被 setter 接管。2.2 属性查找的完整优先级顺序把顺序写清楚遇到问题直接对号入座在type(obj).__mro__里找同名、且是数据描述符的属性找到就调用它的__get__找obj.__dict__有就直接返回回到type(obj).__mro__里找非数据描述符或普通类属性找到就调用__get__或直接返回前三步都失败才轮到__getattr__。注意到__getattr__是最后兜底的而__getattribute__是包住整个流程的总入口——想拦截所有属性访问只能重写后者代价是性能下降且极易写出无限递归。有个小技巧如果你只想在常规查找失败后做点事用__getattr__它只在失败时触发开销可以忽略。还有一点容易混__getattr__和__getattribute__的调用不会互相触发对方的兜底逻辑重写一个不会自动影响另一个。2.3set_name让描述符知道自己叫什么写描述符时经常需要知道我被赋值给了哪个属性名以前只能靠传参或者事后反射扫描很别扭。Python 3.6 加了__set_name__在类对象创建阶段由type.__new__自动调用参数是拥有者和属性名class Field: def __set_name__(self, owner, name): self.name name def __get__(self, obj, objtypeNone): if obj is None: return self return obj.__dict__.get(self.name) def __set__(self, obj, value): obj.__dict__[self.name] value class User: name Field()这段代码有个坑值得记一下Field作为数据描述符__get__里却直接读obj.__dict__如果不小心写成getattr(obj, self.name)就会无限递归。所以我养成了一个习惯描述符内部一律走obj.__dict__而不是getattr把这一层隔离干净。另外__set_name__只在类创建时调用一次之后动态改类属性不会再触发别指望它做运行时校验。2.4 cached_property 为什么不能和slots搭配functools.cached_property的实现思路很巧妙它是一个非数据描述符第一次访问时计算值然后把这个值写进obj.__dict__。第二次访问时实例字典里有这个键而非数据描述符会被实例字典覆盖所以__get__根本不会再被调用——缓存就这样生效了零额外查询开销。这也解释了两个限制。一是它必须有实例字典可写带__slots__的类直接不能用二是如果类重写了__setattr__并做了拦截第一次写入可能失败。还有一个版本差异要注意3.12 起cached_property移除了内部锁多线程并发第一次访问时可能重复计算如果这个计算有副作用比如打日志、发请求得自己在外面加锁保护。3. with 语句背后的契约上下文管理器与 ExitStack 的正确用法上下文管理器看起来简单但它和异常处理、资源释放、嵌套顺序强绑定是生产事故的高发区。我在线上见过最典型的一类 bug是锁在线程异常退出时没释放根因几乎都是手写acquire/release而没用with或者在__exit__里悄悄吞掉了异常。3.1exit的返回值决定了异常要不要继续抛__exit__(exc_type, exc_val, exc_tb)的返回值是有语义的返回真值表示异常已被处理不再向外传播返回None或假值表示继续抛出。很多手写的上下文管理器忘了写return或者在不该返回True的地方返回了True结果把一个本该让调用方感知的错误吞掉了——这种 bug 最难查因为日志里什么都没有程序看起来正常运行。还有一个隐蔽点如果__exit__内部自己抛了异常它会替换掉 with 体里的原始异常原异常会被挂在__context__上。所以清理逻辑本身必须足够健壮别在finally里做可能失败的操作。像加锁、开事务这类场景正确姿势是把释放动作放在finally里而不是依赖返回值。class Transaction: def __enter__(self): self.conn.begin() return self.conn def __exit__(self, exc_type, exc_val, exc_tb): if exc_type is None: self.conn.commit() else: self.conn.rollback() return False # 明确不吞异常3.2 contextmanager 里最容易写漏的 try/finallycontextlib.contextmanager把带yield的生成器改造成上下文管理器代码短很多但它的执行模型必须理解透yield之前的代码相当于__enter__yield之后的代码相当于__exit__。如果 with 体里抛异常这个异常会被抛回到生成器的 yield 处。所以正确写法必须是包一层try/finally否则 yield 之后那句清理代码压根不会执行from contextlib import contextmanager contextmanager def timed(label): start time.perf_counter() try: yield finally: print(f{label}: {time.perf_counter() - start:.3f}s)另外几个约束记一下生成器必须yield恰好一次yield 之后不能再来第二个 yield否则退出时会报RuntimeError: generator didnt stop不要在finally里写return那会干扰异常的传递如果 with 体里发生了异常你又想在yield处捕获它用except包住 yield 即可但要判断好是放行还是消化。3.3 ExitStack资源数量不确定时的解法with的问题是它要求资源个数在写代码时就固定。一旦遇到根据配置动态打开 N 个文件按条件注册若干清理动作嵌套 with 就会写成一团糟。这时候用contextlib.ExitStackfrom contextlib import ExitStack with ExitStack() as stack: files [stack.enter_context(open(p)) for p in paths] stack.callback(logger.info, 资源已全部关闭) process(files)ExitStack内部维护一个回调栈退出时按后进先出的顺序依次执行这正好对应资源释放的安全顺序。enter_context用来接管上下文管理器callback用来注册普通清理函数push可以接管一个__exit__方法。它还有个挺实用的技巧临时把一个上下文管理器转成装饰器用的时候ExitStack能帮你省掉手写一层包装类。有一点必须强调ExitStack自身不是线程安全的多个线程共用一个栈迟早出问题。异步场景请用contextlib.AsyncExitStack它的enter_async_context和push_async_callback对应异步版本aclose时会按顺序 await。3.4 可重入与线程安全用计数器解决老大难一个常见需求是同一个锁在同一线程里可以重复进入标准库给了threading.RLock但如果你自己实现一个需要可重入的上下文管理器比如自定义连接池、采集器就得自己维护重入计数。思路很直接进入时计数加一只有从 0 变 1 时才真正获取底层资源退出时计数减一归零时才真正释放。class ReentrantGuard: def __init__(self): self._depth 0 self._lock threading.RLock() def __enter__(self): self._lock.acquire() self._depth 1 return self def __exit__(self, *exc): self._depth - 1 if self._depth 0: self._lock.release() self._lock.release() return False这段是示意结构真实实现里计数器本身的读写也要在锁保护下进行否则多线程下会出现计数错乱、锁永远不释放的情况。我个人经验是能复用标准库就别自己写自己写就必须补一组并发测试光靠单线程跑通不算数。4. 生成器不是协程yield from、send 与 asyncio 的三条边界线yield这个词在 Python 里背了两层职责。一层是生成器用来惰性产出一串值另一层是协程用来做双向通信。两套语义共用同一个关键字导致很多概念被混在一起讲。把边界划清楚再去看 asyncio 的调度行为就会顺很多。4.1 yield 是表达式不是语句y yield x这个写法说明yield有返回值这个返回值来自外部的send()。规则有两条必须记牢生成器刚创建还没启动时只能next()或者send(None)传非 None 会直接报TypeErrorsend的值会成为挂起的那个yield表达式的结果。def accumulator(): total 0 while True: x yield total if x is None: return total x g accumulator() next(g) # 启动返回 0 print(g.send(5)) # 5 print(g.send(3)) # 8另外close()会在挂起点抛入GeneratorExit生成器必须就此退出如果它试图在异常处理里再yield一个值会得到RuntimeError。throw()则可以往生成器里注入任意异常这是早期手写协程调度器的基础。理解这三件套之后你再看那些基于生成器的状态机实现就不会觉得玄乎了。4.2 yield from 帮你转发了什么yield from sub不只是把sub的值一个个吐出来它还会自动转发三样东西调用方的send会转发给子生成器throw会被注入子生成器close会关闭子生成器。更要紧的是子生成器return的值会成为yield from表达式的结果所以return (yield from sub)是一种很自然的写法——这就是把子生成器的结果继续往上传的标准套路。还有一点yield from会自动处理子生成器的StopIteration把它转换成表达式的值而不是让它冒泡出去。如果你在写一些定制的迭代器组合逻辑比如分块遍历、多路合并用yield from会比手写循环转发干净很多也不用担心异常语义出错。但请记住带 yield 的生成器并不是 asyncio 意义上的协程。它没有__await__、不能被事件循环调度也无法被await。真要桥接得用types.coroutine装饰或者__await__协议显式实现这只是历史兼容路径新代码不要往这个方向走直接写async def就对了。4.3 asyncio 里最常踩的三个坑第一个坑是把阻塞调用写进了async def。这是生产环境最高频的事故源在协程函数里调time.sleep、requests.get、又或者一段几百毫秒的 CPU 计算。事件循环是单线程的这些调用会把整个循环堵住所有并发任务一起停滞表现就是QPS 上不去、延迟莫名其妙飘高。正确做法是 IO 阻塞交给asyncio.to_thread或者直接用异步版客户端CPU 密集交给loop.run_in_executor配ProcessPoolExecutor。判断标准很简单这个函数会不会让出控制权不会让出的就不该直接放在协程里。第二个坑是asyncio.gather的异常语义。默认情况下只要有任意一个子任务抛异常gather会立刻把异常向上抛而其他任务既不会被取消也不会被等待它们还在后台跑抛出的异常可能以Task exception was never retrieved的形式出现在日志里。想拿到全部结果用return_exceptionsTrue想要结构化并发和自动取消用 3.11 引入的asyncio.TaskGroup它在退出时会等待所有任务并统一抛出ExceptionGroup。第三个坑是取消不是立即停止。取消操作实际上是在下一个await点向协程注入CancelledError所以同步代码段是拦不住的执行到一半的清理逻辑必须写在finally里。注意CancelledError从 3.8 起继承自BaseException而不是Exception那些宽泛的except Exception拦不住它但如果你写了except BaseException又默默 pass就会把取消信号吞掉任务变成永不结束的僵尸。超时控制建议直接用asyncio.timeout3.11比旧的wait_for语义清晰得多。5. 元类与init_subclass注册表模式到底该用哪个什么时候该用元类是 Python 社区被问得最多的问题之一而标准答案其实是绝大多数时候都不该用。真正需要元类的场景非常少Python 3.6 之后又多了两条替代路径把门槛进一步拉低了。下面把三种方案的适用边界摆清楚最后给一个完整的插件注册表实现。5.1 type() 的三参数形式和元类的真实职责type(name, bases, namespace)是动态创建类的底层接口你平时写的class语句本质就是编译器把它翻译成了一次type(...)调用。元类就是类的类——继承自type用它来控制类的创建过程。两个钩子要分清__new__负责构建并返回类对象__init__负责初始化已经建好的类。要修改命名空间内容增删属性必须在__new__阶段做__init__里改已经晚了。元类还有个绕不开的限制叫元类冲突如果多个基类的元类不是同一条继承链上的创建子类时会直接报TypeError: metaclass conflict。这在混用多个第三方库的时候很容易撞上而且报错信息通常不够直白。所以我给的建议是只在两个条件同时满足时才考虑元类——需要拦截类的创建本身不只是子类化以及这套逻辑要在多个互不相关的类之间共享。5.2init_subclass能吃掉大部分元类需求__init_subclass__是 3.6 加的类方法在子类创建完成后被父类隐式调用能拿到子类对象以及class语句里传来的关键字参数。它最擅长三件事自动注册子类、强制子类实现某些方法、给子类批量注入属性。跟元类相比它不需要你理解type.__new__的调用协议也不会引发元类冲突。有一个执行顺序的细节值得记住在类创建过程中描述符的__set_name__先被调用然后才轮到__init_subclass__。这意味着如果你在__init_subclass__里读取某些描述符的name属性它已经被设置好了这个顺序在写框架时很有用。5.3 一个插件注册表的完整实现和两个坑class PluginBase: registry {} def __init_subclass__(cls, *, keyNone, **kwargs): super().__init_subclass__(**kwargs) if key is not None: if key in cls.registry: raise ValueError(f插件键冲突: {key}) cls.registry[key] cls class JsonPlugin(PluginBase, keyjson): ... print(PluginBase.registry) # {json: JsonPlugin}这段代码能用但有两个坑必须提前知道。第一注册动作发生在模块导入时如果某个插件所在的模块从头到尾没被import它就不会出现在注册表里。解决方案是显式 import 对应的包或者用pkgutil.iter_modules扫描目录强制导入。这个问题很隐蔽因为本地测试时模块恰好被别的路径导入了一上生产就插件丢失。第二抽象中间类也会被触发注册。如果你写了一个半成品基类是为了被继承而不是被使用它同样会走__init_subclass__。解决办法是给它加一个标记位在注册前检查if getattr(cls, __abstract_plugin__, False): return用abc.ABC配合__abstractmethods__也行但要留意只有存在抽象方法时这个属性才非空空抽象类判断不出来。我自己的习惯是显式声明一个类属性作为标记逻辑最直白不容易出意外。6. 性能与调试从 dis 到 lru_cache 的失效场景前面五节讲的都是怎么把代码写对这一节聊聊怎么把它写快以及怎么确认它真的快了。性能优化最大的陷阱是凭感觉改代码——你觉得某处慢改完发现毫无变化反而引入了 bug。所以顺序永远是先测量、再定位、最后才优化并且优化之后必须再测一次。6.1 先用 dis 看清局部变量和全局变量的差距想知道一行代码在解释器里长什么样dis.dis(func)是最直接的工具。它会打印出字节码你能看到LOAD_FAST、LOAD_GLOBAL、LOAD_ATTR这些指令。区别在于局部变量存在一个固定的数组里按下标取值全局变量要走模块字典查找属性访问还要经过描述符协议的完整流程。import dis def slow(items): result [] for i in items: result.append(i * len(items)) return result def fast(items): result [] append result.append n len(items) for i in items: append(i * n) return resultfast里把result.append和len(items)提到循环外绑定成局部变量字节码里就少掉了每次迭代的全局查找和属性查找。要注意的是现代 CPython 对全局查找加了内联缓存差距比十年前小了很多而且这种改写会牺牲一点可读性。我的原则是只在 profiler 明确指出的热点循环里做其他地方保持原样不要为了省几个纳秒把代码写成天书。6.2 lru_cache 的四个失效场景functools.lru_cache几乎是所有人都用过的缓存工具但它有几个场景用了还不如不用场景表现处理方式参数含不可哈希对象直接抛TypeError先转换成元组或字符串键装饰实例方法self进入缓存键实例无法被回收改用cached_property或模块级函数使用无界cache键的组合爆炸内存持续上涨显式设置maxsize返回可变对象调用方修改后后续调用拿到被污染的对象返回副本或改用不可变结构其中第二个是最隐蔽的。缓存字典的键里包含了self等于给每个实例加了一条强引用实例永远不会被垃圾回收。如果这个类会频繁创建销毁比如每个请求一个实例内存会稳定地往上爬而且用gc查起来也很绕因为对象确实是可达的。第三个也值得展开。cache3.9等价于maxsizeNone也就是无界缓存写起来爽但参数组合一旦多起来就是内存黑洞。我一般会在压测阶段观察cache_info()的currsize、hits、misses——如果hits占比很低而currsize一直涨说明缓存根本没起到作用反而在吃内存。顺便说lru_cache从 3.8 起可以不带括号直接当装饰器用少打一对空括号读起来清爽一点。6.3 singledispatch 替掉一长串 isinstance不少老代码里有这种结构def render(obj): if isinstance(obj, str): return render_text(obj) elif isinstance(obj, dict): return render_dict(obj) elif isinstance(obj, list): return render_list(obj) ...这种写法有三个毛病加一种类型就得改这个函数违反开闭原则、isinstance是有顺序的、子类可能被前面的分支截胡。functools.singledispatch就是为了解决这个按第一个参数的实际类型分派到注册的实现查找时沿 MRO 找最近的匹配。from functools import singledispatch singledispatch def render(obj): raise TypeError(f不支持的类型: {type(obj)}) render.register def _(obj: str): return render_text(obj) render.register def _(obj: dict): return render_dict(obj)几个实操注意点singledispatch只看第一个参数的类型多个参数的话没辙用注解方式注册需要确保注解是实际类型而不是字符串每次新增实现都会清空内部分派缓存所以不要在热路径里动态注册。类方法版本用singledispatchmethod3.8但它和classmethod、staticmethod叠加时顺序有讲究装饰器必须写在最内层写反了会报错。6.4 Protocol让类型检查器看懂鸭子类型最后聊一个不直接影响运行速度、但显著影响协作效率的东西。Python 是鸭子类型只要方法齐全就能用可静态检查器不认识长成这样的对象于是你要么写一堆Any要么硬造继承关系。typing.Protocol提供了结构化子类型只描述需要哪些方法任何具备这些方法的类自动符合不需要显式继承。from typing import Protocol, runtime_checkable runtime_checkable class Readable(Protocol): def read(self, n: int -1) - bytes: ...和abc.ABC相比Protocol是非侵入式的不会强迫第三方库改自己的继承结构代价是它只做静态检查isinstance需要加runtime_checkable而且只校验方法名是否存在不校验签名参数个数对不上也照样通过。所以在需要严格运行时校验的场景抽象基类仍然更可靠。我通常的做法是跨模块的接口契约用Protocol描述框架内部需要强制实现的用abc。写到这里其实这六个方向的共同点已经很清楚了Python 的很多魔法都不是黑盒它们背后是几条明确的协议和查找规则。把这些规则摸清之后你看到报错信息就能猜到是哪一层出了问题——比如属性赋值没生效先想数据描述符还是非数据描述符异步任务莫名卡死先想是不是在协程里塞了阻塞调用。这种能定位的感觉比背多少技巧都管用。
返回列表