ARTICLE DETAIL

资讯详情

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

延时变量与free_list链表:Python语义与内存复用的本质区别

延时变量与free_list链表:Python语义与内存复用的本质区别 1. 同样的Python魔法两个完全不同的世界当年我第一次在技术群里看到延时变量和free_list链表的区别这个标题时第一反应是这俩词怎么会被放在一起比较一个延时变量听起来像语言语义层面的东西而free_list链表是CPython源码里真实存在的内存缓存结构两者的层级完全不一样。但紧接着我就想起自己踩过的一个坑这才发现它们确实值得放在一起梳理。有次帮业务团队排查一个数据处理脚本代码逻辑大概是循环里批量生成了一批处理函数后面统一调用。结果所有函数输出的都是循环最后那个值不是各自对应的值。当时的场景是这样的funcs [] for item in data: funcs.append(lambda: process(item)) for f in funcs: print(f())看起来天衣无缝实际输出全是最后一个item的结果。这个现象就属于典型的延时变量问题——lambda函数捕获的不是item当时的快照而是对item这个变量名的引用真正取值被放到了函数调用那一刻。循环结束时item已经停在最后一个元素上所有函数取到的自然是同一个值。而free_list链表又是另一回事。如果你在写代码时做过这样的观察a [1, 2, 3] print(id(a)) del a b [4, 5] print(id(b))大概率会看到两次打印的id完全一样。第一次接触你会以为Python地址复用再往深处问一句就会牵出CPython里的free_list机制——被del掉的list对象并没有立刻把内存交还给操作系统而是被解释器暂存到了一个复用池里新建list时直接从里面取内存地址自然就一样了。你看一个是函数调用时取值变晚一个是对象销毁后内存被复用这两者都被统称为Python的魔法行为但一个是语言规范决定的一个是解释器实现决定的。这篇文章我就把这两个概念彻底拆开讲清楚它们各自是什么、什么时候触发、怎么观测、有什么区别、有哪些经典误区以及实战中遇到类似现象时应该怎么定位。先给一个最核心的框架性结论延时变量是语义问题free_list是实现问题。语义问题决定你的程序输出什么结果实现问题决定你的程序跑多快、占多少内存。下面一个个展开。2. 延时变量语义层让取值的时刻变得有讲究2.1 先澄清Python里并没有延时变量这个官方术语我在多个技术社区翻过Python官方文档、PEP、标准库说明里都没有延时变量这个词条。这不是说这个词没意义而是说它是中文Python社区里约定俗成的一个说法通常指下面两件事变量名在运行时的解析时机被推迟了典型场景是闭包里的自由变量。表达式求值本身被推迟了典型场景是生成器表达式、迭代器协议。其实说延时也好延迟绑定也好背后的本质都是同一个东西Python的变量是名字到对象的引用关系名字的查找发生在运行时而不是定义时。这与C、Java这类编译型语言的人直觉习惯差异很大。编译型语言里你写int x 1;x就是一个绑定在栈上的确定值编译期就定死了Python里你写x 1x只是一个指向int对象的标签随时可以指向别的对象要等到真正执行到取值语句时解释器才知道这个标签现在指向谁。这个特性带来的直接后果就是你在某些场景里会发现变量晚一点才被取值。最典型、最高频的三个场景是闭包、生成器、循环变量捕获下面逐个演示。2.2 闭包里的自由变量最经典的延时先看最经典的闭包例子def outer(): x 1 def inner(): return x x 2 return inner f outer() print(f()) # 输出2不是1如果你是第一次看这段代码可能会觉得内层函数定义的时候x还是1所以输出应该是1。但实际输出2。原因就是inner函数体中的return xx并不是在inner定义时被复制一份绑在里面而是保留了一个对outer函数作用域中x的引用。等真正调用f()的时候解释器才沿着作用域链去找x当前的值这时outer里已经执行过x 2了所以拿到的是2。如果把作用域链换个说法更好理解Python在编译函数时会把函数体需要的变量存成一个所谓单元格闭包捕获的是这个单元格的引用不是单元格当时的快照。单元格里的内容是可以变的什么时候读它就什么时候拿到当时的值。再补一个更常见的循环捕获场景。你可能在网上看到过这样一段翻车代码adders [] for i in range(1, 5): adders.append(lambda: i) print([a() for a in adders]) # 输出 [4, 4, 4, 4]很多人会以为这是列表推导式或者lambda的坑。其实核心还是延时变量每个lambda捕获的都是同一个循环变量i的单元格引用循环结束后i停在4四个lambda调用时自然全部取到4。那为什么循环里的i只有一个单元格而不是每个迭代一个单元格因为Python的for循环不会为每次迭代创建新的作用域整个循环体共享外层作用域的同一个名字i。这一点和C语言不同C语言for循环里声明的变量每次迭代都是新的局部变量Python则是在同一函数作用域里反复给i重新赋值。2.3 生成器的惰性求值连执行都推迟了延时变量还有另一个表现生成器表达式/生成器函数的执行是惰性的——不是创建时执行而是每次迭代到那里才计算。gen (x * x for x in range(5)) print(gen) # generator object ...没有立即计算结果这不算变量延时而是执行延时但本质是一家人表达式里的x也是运行时、迭代时才查找和求值的。有一个很出名的坑在Python 2时代炸过无数人其实Python 3早已修复# Python 2 代码range(3)直接生成长度为3的列表 funcs [lambda: i for i in range(3)] # 结果会打印 [2, 2, 2] 而不是 [0, 1, 2]Python 3下这个问题的表现已经不一样了因为列表推导式有了独立作用域每个lambda捕获到的是自己那一轮的i快照。但如果在普通for循环里依旧会翻车。所以判断这里是不是延时变量不能只看lambda语法还得看作用域结构。生成器函数同理def countdown(n): while n 0: yield n n - 1 c countdown(3) print(next(c)) # 3到这里才执行第一个yield之前的代码生成器函数的局部变量也有延时特征它们在函数第一次next()之前根本不会初始化每次yield之后再把控制权交回来局部状态保留在帧对象里。这种推迟到真实使用时的行为就是标准库里itertools、pipeline编程能节省大量内存的基础。2.4 默认参数最容易弄反的早绑定讨论延时变量时有一个反向案例必须讲因为太多人把默认参数的坑归类到延时里其实是完全相反的。def append_to(item, container[]): container.append(item) return container print(append_to(1)) # [1] print(append_to(2)) # [1, 2] 竟然累积了这个坑大部分人知道是默认参数在函数定义时就创建并绑定了是一次性的、共享的属于早绑定eager binding不是延时绑定。所以如果你调解Bug的时候看到这种累积行为思路不要去往延时变量上靠那是另一个方向的问题。不过将默认参数真正用到延时变量问题的解决上却是一条正道。回到前面那个adders循环陷阱通过默认参数把当前值焊死adders [] for i in range(1, 5): adders.append(lambda ii: i) # 默认参数在定义时绑定当前i的值 print([a() for a in adders]) # [1, 2, 3, 4]这里的原理是lambda表达式的默认参数ii等号右边那个i在函数定义时立即取值这个值被绑定到默认参数槽里函数体里的i引用默认参数而不再引用外层循环变量。这样每个lambda都有了自己的一份固定值不再延时。这个技巧在写装饰器、闭包、回调函数时非常常用。3. free_listCPython在内存层开的复用仓库3.1 从一行代码猜到链表的存在如果只从Python层面理解free_list最直观的入口就是id()复用。我经常在给团队讲解时用这样一段演示代码import gc def get_id(): obj [i for i in range(10)] return id(obj) id1 get_id() id2 get_id() print(id1, id2) when id1 id2一次性函数里创建的临时list在函数返回后就会被销毁但紧接着第二次调用创建的新list大概率会拿到一模一样的id值。这个现象如果发生在C语言里是malloc后free再malloc堆上可能重叠但在CPython里原因就是list对象走了一条叫做free_list的快速通道。先给一个简单定义free_list是CPython为某些常用内置类型维护的一个已释放对象复用池。当一个对象引用计数归零被回收时如果类型满足条件且池子未满对象实例本身不会被立刻释放回内存分配器而是被暂存进池子里下一次创建同类型对象时先从池子里取出旧对象复用池子空了再走常规内存分配。这其实就是一个以空间换时间的优化减少频繁创建/销毁对象时向内存分配器的申请与释放动作加快对象初始化速度。需要说明的是虽然名字里有链表但CPython不同对象类型的free_list实现形态不完全一样。比如list、tuple、dict、float这些类型源码里多数用的是一个固定大小的数组计数器来模拟栈LIFO行为也有真用单向链表的内部结构。在社区讨论里大家习惯统称free_list并不严格区分实现形态。3.2 翻源码list的free_list到底是怎么运作的以list为例看CPython源码Objects/listobject.c3.x版本里大致是下面这样#define PyList_MAXFREELIST 80 static PyListObject *free_list[PyList_MAXFREELIST]; static int numfree 0;free_list数组最多容纳80个已回收的list对象指针numfree记录当前池子里有多少个可用对象。当list对象被回收也就是list_dealloc被执行时如果满足条件对象是准确的内置list类型、池子没满不会真正释放list对象所占的结构体而是把它塞进free_list数组numfree。注意这里不是把对象内部的数据数组全保留而是只保留结构体本身同时因为list内部维护的保存元素的指针数组可能很大通常会先释放内部元素数组的内存只缓存空壳子。这样复用时省掉的是申请结构体内存 初始化结构体字段的开销。当新建list时相应代码会看到numfree 0于是直接从池子里弹出一个旧对象重新初始化后返回。整个过程走的是一个纯内存操作比调用系统malloc快很多。这就解释了为什么创建大量零散list的代码在CPython下性能还可以一部分分配被free_list吞掉了。其他类型各有各的设计tuple同样有80个槽位的free_list但因为tuple是不可变对象它的结构体内存大小固定所以回收时可以连内部内存一起保留复用率更高dict有专门的dict_free_list大小在不同版本有调整典型是80float也有free_list数组这个被很多性能分析文章提到过小整数-5到256之间的小整数是另一个机制——小整数对象池属于常驻内存的缓存不算回收后复用而是一开始就创建好了注意区分。3.3 自由链表不缓存子类free_list一个很容易忽略的边界条件只缓存精确类型不缓存子类。怎么理解如果你的类继承了list你创建它、销毁它再创建同类的实例它不会进入list的free_list。原因是CPython源码里回收路径上有类型检查比如list_dealloc会判断是否PyList_CheckExact(op)子类对象不满足精确类型判断走的是完整释放路径。这一点在排查内存问题时很关键如果你的业务大量创建list子类对象比如pandas的某些内部容器、ORM模型对象free_list帮不到你内存节奏跟原生list完全不同。另外如果对象自定义了__del__方法情况也会变复杂。带__del__的对象垃圾回收时有额外的终结步骤有可能因为对象状态无法安全复用而不进入free_list。底层各类型的dealloc实现里多数会通过字段如Py_REFCNT等加上类型判断后自行决定走哪条路径。3.4 运行时观测id相同不代表是同一个对象继续深挖free_list的运行时表现。有一段时间我在做内存碎片分析被一个奇怪现象搞得头大程序通过ctypes读取某个C库返回的内存地址打印出来的数字一轮轮高度重合一度以为是指针没释放。后来才意识到是free_list导致的对象内存重复利用。一个更精确的观测实验import sys, gc def show_reuse(): a [1, 2, 3] addr_a id(a) ref_a sys.getrefcount(a) - 1 del a # 引用计数归零进入free_list gc.collect() # 主动回收 b list() # 新建list优先从free_list取 addr_b id(b) print(相同地址:, addr_a addr_b) print(a引用计数:, ref_a) # 已经为0对象实际已逻辑死亡 show_reuse()输出大概率是相同地址: True。但要注意这个实验只是大概率成立不是100%保证。因为free_list是栈式结构del a之后如果中间还有其他代码创建了list新list可能先复用掉那个槽位后面b拿到的就不一定同地址。你如果写自动化测试脚本断言id相等本质是在依赖解释器实现细节属于不稳定的测试方式生产代码里千万别这么干。还要注意一点id相同不代表你可以认为a和b是同一个对象。逻辑上a已经被删除了b是一个全新创建的list对象。只是free_list把b的内存结构体放在了和a相同的位置。就好比酒店房间里上一个客人退房了下一个客人住进同一间房——房间地址一样但人是不同的。对象的身份id在生命周期内唯一生命周期结束后被复用的地址不再属于原对象。4. 把两者摆在一起语义问题vs实现问题4.1 一张对照表说清本质区别这两者的区别说到底可以用一句话概括一个管程序算出来什么一个管程序跑得快不快、内存怎么动。做一张对照表会更清晰对比维度延时变量free_list所属层面语言语义作用域、求值时机CPython解释器实现内存管理本质变量名在运行时才解析取值已释放对象被缓存复用是否Python规范保证是LEGB规则决定否各Python实现可不同程序员能否观察通过函数输出、闭包行为直接感知主要通过id()、内存占用间接感知影响程序结果吗直接影响输出逻辑会造成结果差异通常不影响逻辑结果只影响性能和内存典型代码场景闭包、lambda捕获循环变量、生成器惰性求值大量创建/销毁list、tuple、float的对象池化在其他Python实现PyPy等仍然成立语义一致不一定存在实现细节不同从这张表能看出来如果你是在别的Python解释器比如PyPy、Jython、MicroPython上运行同样的代码延时变量的行为是应该一样的因为它由语言规范决定而free_list可能压根不存在或者参数不一样这是实现自由。4.2 三个高频误区逐个拆解误区一闭包陷阱是free_list导致的。 有些人看到闭包里的i被复用了同一个值联想到对象复用就以为是内存层面的原因。这完全是两码事。闭包是作用域链上的变量解析问题free_list是对象实例内存的复用问题。闭包即使在PyPy上也会出现相同现象而free_list只存在于CPython的内存管理实现里。误区二id相同说明它们是同一个变量对象。 这前面已经说过了。id只在对象存活期内唯一free_list可以让新对象长在旧对象的位置上但它是新对象、新生命周期。恰恰是同一个变量、同一对象这个概念混淆会让新手在调试时做出错误的引用计数判断。误区三所有Python可变对象都走free_list内存占用为什么还涨。 其实这种说法是不准确的至少不是全部对象类型都有free_list而且池子容量有限list是80个超过容量后照常释放。另外free_list复用的是对象壳子内部数据内存可能已经释放也可能保留如tuple所以它不能从根源阻止内存碎片和总体内存增长。你创建100万个list再全删掉free_list最多帮你缓存80个剩下99万多个还是正儿八经释放回去了。4.3 为什么这两个概念会被放在一起讨论我这几年观察下来问题之所以会把两个层级的概念放一起主要原因有两个。第一两者都带有时间上的延迟这个直觉特征延时变量是取值被延后free_list是释放被延后。一个是取晚一个是还晚方向感容易让人混成都是Python在背后做手脚反正是懒。第二它们经常在同一种排查场景中同时现身。比如你调试一个内存泄漏问题代码里又有闭包缓存、又有大量列表创建销毁。你既可能因为闭包取到旧值而怀疑逻辑又可能因为id反复出现相同数字而怀疑内存没释放。经验不够的人很容易把这两类异常归因成同一个Bug。我在团队内部培训时常说一句话看到程序行为不对先问自己这是结果问题还是性能问题。结果问题优先怀疑语义层也就是作用域、绑定时机、数据流性能问题才需要往实现层深挖比如free_list、内存分配器、GC参数。分清楚这个主次排查效率至少翻一倍。5. 实战中怎么区分、怎么用5.1 排查逻辑Bug时先检测作用域结构如果遇到程序输出和你预期不符尤其是所有结果都一样取到的总是最后一个值这类现象不要先怀疑内存先检查代码里的作用域结构。快速自检清单如下有闭包吗内层函数引用了外层变量吗有循环变量被lambda或回调捕获吗循环作用域是否共享有生成器表达式吗它求值是否被推迟到了迭代时函数返回值是函数本身吗返回后外层变量还会变吗验证时可以用一个小技巧在闭包函数体里加一行print(locals())或者print(x)看看调用时x到底是什么。也可以把捕获变量打印出来funcs [] for i in range(3): funcs.append(lambda ii: (print(当前i:, i), i)[1])如果此时输出1、2、3说明问题出在绑定时机上解决方案就是前面说的默认参数快照法或者改用functools.partialfrom functools import partial def mul(a, b): return a * b funcs [partial(mul, 2, i) for i in range(3)]partial的本质也是在创建时把参数焊死进去和默认参数异曲同工。5.2 排查内存问题时再考虑free_list的因素如果你的程序内存曲线不那么平滑或者你想分析为什么Python进程删了很多对象但内存没立刻降下来这时候free_list就进入考虑范围了。一个真实的观察我在一个定时任务脚本里每小时都会创建一批临时list保存抓取到的数据处理完后丢弃。用tracemalloc分析的时候发现每次丢弃后内存在短时间不下降而是过一段时间才回落到基线这是free_list在暂存对象。不要因此误判为内存泄漏。验证方法很简单连续创建几千个list再删除观察空闲内存曲线或者用gc.get_objects()统计活跃对象数量确认list总量没有持续增长。import gc # 查看当前存活的对象类型统计中list数量 type_stats {} for obj in gc.get_objects(): t type(obj) type_stats[t] type_stats.get(t, 0) 1 print(type_stats.get(list, 0))如果数量稳定说明free_list只是暂存空壳不是泄漏。真正要担心的是对象被某个容器持续引用导致无法回收那才叫泄漏。另外做性能调优时free_list确实帮了不少忙。大量短生命周期对象list、float、tuple创建销毁频繁的算法在CPython上能跑得还不错free_list功不可没。但这个优化是解释器自带的你不需要也不应该手动干预它。你唯一能做的调优方向是减少不必要的对象创建这比依赖free_list更有效。5.3 三个可复现的验证小实验建议你亲手跑一遍给读者一套可复现的组合实验用来彻底理解这两个机制的边界。实验一验证闭包中的延时变量x before def f(): return x x after print(f()) # after证明x在调用时才解析实验二验证free_list的id复用import gc lst_a [1, 2, 3] print(a:, id(lst_a)) address_of_a id(lst_a) del lst_a gc.collect() lst_b [] print(b:, id(lst_b)) print(地址相同:, id(lst_b) address_of_a)实验三验证延时变量 free_list同时发生在同一段代码里import gc def make_closures(n): funcs [] for i in range(n): funcs.append(lambda: i) return funcs fns make_closures(3) print([fn() for fn in fns]) # [2, 2, 2]语义层的延时绑定 # 顺带观察list对象在函数返回后的内存复用 addr1 id(fns) del fns gc.collect() new_fns [0] * 3 print(list地址复用:, id(new_fns) addr1)同一个程序一条输出解释变量绑定的时间差一条输出内存复用的空间重叠两条线索各归各的。5.4 一个实用习惯把id()当调试工具不当判断依据最后分享一个个人习惯也是我带新人时常叮嘱的写代码时不要把id()作为逻辑判断依据。a is b可以用于判断对象身份这是安全的但依赖id()数值本身去做事情比如比较进程内两个对象地址是否相同来决定逻辑分支就很危险因为id在不同生命周期中是可以复用的。调试时我常用id()来确认两个引用是否指向同一对象但一旦涉及回收后再创建的场景就得警惕free_list带来的假象。如果你想确认这个对象是否真的还被引用用sys.getrefcount()或gc模块更可靠而不是用id是否变化来判断。回到延时变量和free_list的区别这个话题上如果只记住三句话就够了延时变量是作用域与求值时机的问题决定程序输出free_list是对象内存复用机制影响性能和内存纹理一个写在语言语义手册里一个埋在解释器源码中。以后碰到为什么我的lambda取到的是最后一个值为什么id总是重复出现这类问题你能第一时间把它们归到正确的类别里排查路径就不会南辕北辙。
返回列表