ARTICLE DETAIL

资讯详情

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

一文搞懂Python底层原理:从对象模型到GIL的完整指南

一文搞懂Python底层原理:从对象模型到GIL的完整指南 你被“底层原理”这四个字吓住之前先听我说句实话——绝大多数问出这个问题的人并不是真的想去看C语言源码而是被Python某些“怪脾气”搞懵了为什么a [1,2,3]之后b a修改ba也跟着变为什么两个整数用is判断有时候是True有时候是False为什么多线程跑计算密集型任务反而更慢这些问题全都能用一套统一的底层心智模型来解释。搞懂了这套模型你再看Python基本就像看一张透明的地图哪里是路、哪里是坑、哪里在偷懒、哪里在加速心里全有数。本文不打算带你去读CPython上万行C代码那对99%的人来说是劝退而不是帮助。我要做的是把Python底层最核心的几条原理拆开、揉碎用最直白的话讲清楚对象模型、变量赋值、解释器执行流程、数据结构的真实内存布局、垃圾回收与GIL的来龙去脉。每一条原理我都会告诉你“它解决什么问题”“它带来什么坑”“学完能干什么”。适合刚学完基础语法想进阶的初学者也适合工作一两年但没系统梳理过Python机制的开发者。1. Python的“底层”到底指什么先建立全局框架在拆细节之前必须先把“底层原理”这个概念盘清楚。很多人一听到“底层原理”就以为是汇编、内存地址、寄存器那一套然后焦虑地觉得自己学不会。但Python本身是一种高度抽象的语言它的“底层原理”是分层次的不同层次的深度对应不同的学习者需求。1.1 从代码到运行中间经历了什么你写下一行print(hello)按下回车后到底发生了什么这是理解底层原理的第一个关键节点。整个链条是这样的源代码你写下的.py文件本质是一个普通文本文件里面就是一堆字符串。编译为字节码Python解释器会先扫描这段文本解析语法然后编译成一种中间表示叫“字节码”bytecode。这个步骤非常快所以你会看到Python启动时会有个__pycache__目录里面存放的就是这些编译好的字节码文件.pyc目的是下次运行时省去重新编译的时间。解释执行字节码Python虚拟机通常指CPython的虚拟机循环逐条读取字节码指令并执行。注意这里不是把字节码再编译成机器码而是一条一条“翻译”成底层操作然后执行。这和第3步是Python和C语言最本质的区别。C语言是直接把源代码编译成机器码CPU直接执行机器码Python则是把代码编译成字节码然后由一个虚拟机这个虚拟机本身是个C程序来解释执行字节码。生活化类比C语言相当于你直接请了一个外国厨师他听你的指令直接用锅铲做菜Python相当于你请了一个传话的人你告诉他“做红烧肉”他翻译成“切肉、起锅、放油……”再告诉厨师。多了一层传话灵活性强了但每次都慢那么一点点。1.2 学习底层原理到底要学什么如果你去搜“Python底层原理”会看到各种文章有的讲GIL、有的讲垃圾回收、有的讲哈希表、有的讲装饰器闭包看得人头晕。我的建议是分三条主线数据模型层对象、变量、引用、可变性。这一层解决“Python的变量到底是什么”的问题是所有底层知识的基石。执行机制层解释器、字节码、GIL、多线程/多进程。这一层解决“Python的代码是怎么跑起来的”的问题。内存与性能层垃圾回收、引用计数、数据结构的内存布局、哈希表、动态数组。这一层解决“为什么Python有时快有时慢”的问题。这三条线并不是孤立的而是层层递进。数据模型是最底层的世界观执行机制是运行时的行为规则内存与性能是前两者共同作用的结果。有一点我必须提前强调不同版本的Python底层实现细节并不完全相同。本文主要讲的是CPython——也就是你在python.org下载的那个官方版本。其他实现如PyPy用Python写的Python解释器、Jython跑在JVM上的Python、IronPython跑在.NET上的Python各有各的底层机制但核心对象模型和语言语义是一致的。对绝大多数学习者来说理解CPython就够了。2. 对象模型与变量赋值最容易被误解的核心机制Python的“一切皆对象”这句话几乎所有教程都会讲但真正理解它的人不多。这一节我用最直白的方式拆透它。2.1 变量不是盒子是指针在C语言里变量像一个盒子int a 10意味着你往一个叫a的盒子里放了整数10盒子的内存地址是固定的。但在Python里没有“盒子”这个东西。Python的变量是什么是一个名字标签贴在一个对象上的标签。当你写a [1, 2, 3]时实际发生的事情是在内存中创建一个列表对象[1, 2, 3]。给这个对象分配一个唯一的内存地址你在代码里用id()函数能看到它。把名字a绑定到这个地址上。更准确地说Python的变量名字只是指向对象的一个“引用”。这意味着当你执行b a时并不是复制了一份[1, 2, 3]而是再贴了一个叫b的标签到同一个对象上。所以修改b中的元素a也会跟着变因为它们指向的本来就是同一个对象。这就是初学者最容易踩的第一个大坑把变量当成盒子。a [1, 2, 3] b a b.append(4) print(a) # [1, 2, 3, 4]理解了“变量是标签”之后这个问题就不需要死记硬背了——因为a和b贴的是同一个对象改哪一个都是改那个对象。2.2 is和的区别以及整数缓存的猫腻理解了引用之后is和的区别就顺理成章了比较的是两个对象的值是否相等。is比较的是两个引用是否指向同一个对象本质上就是比较id()是否相同。所以a b为True不代表a is b也为Truea [1, 2, 3] b [1, 2, 3] print(a b) # True值相等 print(a is b) # False两个不同的对象但奇怪的事情来了x 256 y 256 print(x is y) # True m 257 n 257 print(m is n) # False这是很多人百思不得其解的地方。原因在于CPython里有一个小整数缓存机制-5到256之间的整数对象在解释器启动时就被预先创建好了并常驻内存。所以不管你在哪里写256拿到的都是同一个预先创建的对象。而257超出了缓存范围每次执行都会创建新的整数对象。这个知识点在刷题写代码时几乎用不上但在面试或者排查一些诡异的bug时能帮你避免被“玄学”搞晕。我在实际工作中确实见过有同事用is来判断整数相等然后被is和不一致的结果坑了半天的案例。2.3 可变与不可变字典的钥匙为什么必须是不可变类型Python把对象分成两类可变对象mutable和不可变对象immutable。不可变对象int、float、str、tuple、frozenset等。创建之后内容不能修改。可变对象list、dict、set、自定义类的实例等。创建之后可以增删改。这个区分的底层原因很有意思。以字符串为例CPython对字符串做了很多优化比如字符串驻留intern机制同样的短字符串可能会复用同一个对象。如果字符串内容可以修改那么修改一个对象会导致所有指向它的变量都变这会引发灾难。而对于哈希表来说不可变性更是硬性要求。哈希值是根据对象的内容计算出来的一个固定整数如果对象内容变了哈希值就变了。而字典是靠哈希值来定位存储位置的如果键变了哈希值就再也找不到原来的那个条目了。这也是为什么Python规定只有不可变对象才能作为字典的键。你没办法用list做字典的key但可以用tuple。2.4 赋值、浅拷贝、深拷贝底层分别做了什么理解了“变量是标签”拷贝问题就很好讲清楚了。b a只是多贴了一个标签没有创建任何新对象。b a.copy()创建了一个新列表但里面的元素还是原来的对象浅拷贝。b copy.deepcopy(a)连里面的元素也全部创建一份新的深拷贝。这三个操作的内存有趣程度不一样。浅拷贝修外层列表不会影响原列表但如果修改内层列表比如b[0].append(...)原列表也会变因为内层列表还是同一个对象。深拷贝则是彻底复制一份独立的。3. 解释器与字节码代码到底是怎么跑起来的这一节回答很多人的疑惑Python到底是不是解释型语言那为什么会有编译这个概念字节码又是什么鬼3.1 解释型还是编译型这是个伪问题教科书上会说Python是解释型语言、C是编译型语言。但严格来说现代Python已经很难用这个二分法去定义了。CPython的做法是先把源代码编译成字节码。再用虚拟机逐条执行字节码。所以它既有编译的环节又有解释的环节。真正的区别在于C的编译产物是机器码CPU直接执行Python的编译产物是字节码由虚拟机执行。机器码是CPU的母语执行速度极快字节码是Python虚拟机的指令需要虚拟机再翻译成机器操作所以天然少了一层、慢一步。这解释了为什么纯Python代码一般比纯C代码慢几十倍。3.2 用dis模块拆开字节码如果你想知道一段Python代码在被解释器执行之前长什么样可以用标准库dis模块来“拆开看”import dis def add(a, b): total a b return total dis.dis(add)输出会是类似这样的字节码指令序列2 0 LOAD_FAST 0 (a) 2 LOAD_FAST 1 (b) 4 BINARY_OP 0 () 6 STORE_FAST 2 (total) 3 8 LOAD_FAST 2 (total) 10 RETURN_VALUE每条指令的含义大致是LOAD_FAST从局部变量表中取出变量放到栈顶。BINARY_OP从栈上弹出两个值做加法再把结果压入栈。STORE_FAST把栈顶值存回局部变量表。RETURN_VALUE返回栈顶值。你看它就是一个基于栈的虚拟机模型所有的操作都围绕一个“栈”进行压入、弹出、运算、再压入。这就是Python执行的底层画面。我建议每个学过Python的人都把自己写过的函数丢给dis.dis()看一遍。虽然不指望靠它写出更快的代码但你会彻底丢掉“Python是魔法”的滤镜——看到机器里真的在逐条执行指令你对性能的理解会提升一个维度。3.3 为什么Python比较慢但开发效率高明白了虚拟机执行机制和栈模型就能解释一个常被讨论的问题为什么Python慢动态类型Python在运行时才知道a b里的a和b是int还是str还是别的什么。如果是int就调int的加法如果是str就拼接字符串。每一次操作都需要“查一下类型”比C语言编译时已经知道类型、直接生成对应机器码要慢得多。解释执行开销每条字节码指令都需要虚拟机循环分派处理无法像机器码那样直接由CPU执行。对象封装开销Python里的整数不是CPU的int而是一个包含引用计数、类型指针、数值等多个字段的完整对象内存占用也远高于C的int。但这换来了巨大的开发效率自由多变、无需管理内存、代码简洁易读。底层的“慢”其实是一种权衡——用性能换开发体验。这也是Python在数据科学、爬虫、快速原型领域无可替代的原因——在这些场景里程序员的“思考时间”远超“运行时间”瓶颈压根不在语言速度上。3.4 字节码缓存__pycache__到底是什么你可能会注意到运行Python脚本后会出现一个__pycache__文件夹里面有一些.pyc文件。这就是上一次编译产生的字节码缓存。每次运行程序时Python解释器会先检查源文件和.pyc的时间戳如果源码没变就跳过编译直接用缓存的字节码如果源码变了就重新编译覆盖。所以下次运行会快那么一丢丢。这算是Python在执行机制层面给你的一颗糖编译这一步骤被缓存了省去了重复劳动。虽然它不会让解释执行本身变快但至少能减少启动阶段的开销。4. 数据结构底层list、dict、set的真实内存布局很多人对“底层原理”最直观的想象就是“数据在内存里到底怎么存的”。这一节讲清楚Python最常用的三个内建容器——list、dict、set——的底层机制。搞懂这些你写出的代码在性能上的差距会变得肉眼可见。4.1 list不是链表是动态数组一个高频误区list在英文里也有“链表”的含义所以很多人误以为Python的list是链表实现的。不是。Python的list底层是一个动态数组更准确地说是一个“对象指针数组”。它在内存里是一块连续的空间每个元素不是直接存那个对象本身而是存一个指向该对象的指针8字节64位系统上。也就是说列表对象本体连续内存块 | 指针0 | 指针1 | 指针2 | ... | ↓ ↓ ↓ 对象A 对象B 对象C这种结构的优点和缺点都极其鲜明按下标随机访问极快因为是连续数组lst[5]直接通过首地址加偏移量就算出元素位置时间复杂度是O(1)。尾部追加平均也快数组会预留一些空闲容量当容量不够时一次性扩容。CPython的扩容策略是大约扩充到原来的1.125倍具体版本有差异因为平均下来平摊到每次append的开销很小时间复杂度摊还是O(1)。在中间插入或删除很慢因为需要把后面的所有元素都往后挪一位或往前挪一位时间复杂度是O(n)。这个底层原理直接影响写代码的习惯。比如你要反复在列表头部插入元素list.insert(0, x)是很糟糕的做法每次都要移动整个数组。这种场景应该用collections.deque它的双端队列结构在两端操作都是O(1)。我在处理大量日志数据时踩过这个坑——几万条数据逐条insert到头部程序卡得怀疑人生换成deque后秒开。4.2 dict哈希表的美与坑Python的字典底层是哈希表hash table。哈希表的核心思路是通过一个哈希函数把键转换成一个固定范围的整数哈希值然后通过这个整数映射到存储桶bucket的位置。当你执行d[key] value时底层做的事情是对键key调用哈希函数得到一个哈希值。根据哈希值和表的大小计算出存储位置通常是对表长取模。把键值对存到该位置。查找时也是同样的过程所以理想情况下字典的增删查时间复杂度都是O(1)。但哈希表有两个绕不开的问题第一个是哈希冲突。两个不同键算出来的哈希值可能落到同一个位置。CPython采用开放寻址法来解决冲突如果位置被占了就按某种规律继续探测下一个空位。这个探测过程会降低性能冲突多了甚至可能退化成O(n)级别的查找。第二个是负载因子与扩容。哈希表不能装太满太满时冲突概率急剧上升。CPython会监控表的填充程度当装载因子超过约2/3时会触发扩容重新分配更大内存并重新计算所有键的位置。这也是为什么无序字典在扩容后遍历顺序可能发生变化的原因之一。这里面有个知识点能直接解释一个常见面试题Python 3.7的字典为什么能保持插入顺序早期Python字典确实是彻底无序的——因为哈希表的存储位置和插入顺序没有必然关系扩容后还会重排。但从Python 3.6开始CPython引入了一种新的实现字典的底层被拆成两个数组一个是紧凑的条目数组按插入顺序排列一个是稀疏的索引数组用于哈希查找。索引数组负责定位条目数组负责按顺序存储。这样既保留了哈希表的高速查找又能按插入顺序遍历。Python 3.7起这个特性被语言规范正式确定下来。另外还有一个面试经典题为什么dict的key一定要是不可变对象因为哈希表要求键的哈希值在整个生命周期中保持稳定。如果键可变哈希值就会变哈希表就无法再定位到原来的存储位置。这和第2章讲的可变不可变是一脉相承的。4.3 set只存键的字典set的底层其实就是一个简化版的哈希表——它只存键不存值。所有关于哈希冲突、扩容、查找的机制set全部继承。所以set也是无序的、去重的、查找O(1)的。理解了这一点就能理解set的几个典型行为set中只能放不可变对象原因和dict的key完全一样。判断x in set比x in list快得多——前者是哈希查找后者是线性遍历。数据量一大差距是数量级的。set的无序性来自哈希表的随机分布即便Python 3.7之后dict有序了set依然无序因为它没有存“插入顺序”的信息。4.4 字符串不可变与驻留机制字符串在Python中极其常用它的底层也有几个值得了解的机制。字符串驻留对于短的、看起来像合法标识符的字符串比如只含字母、数字、下划线CPython会做一个缓存内容相同的字符串直接复用同一个对象。所以会出现这种现象a hello_world b hello_world print(a is b) # True字符串驻留机制如果字符串里带了空格或特殊字符驻留就不一定会发生is结果就可能是False。这个机制主要是为了优化内存使用——程序里大量使用相同的短字符串如果没有驻留每个都是独立对象内存浪费巨大。字符串不可变为什么字符串必须是不可变对象除了驻留机制依赖内容稳定之外还因为哈希表的key经常用字符串。如果字符串可变字典的key就全乱套了。这也是语言设计者深思熟虑的结果。5. 内存管理、垃圾回收与GILPython的定时炸弹和护城河这一章可能是大家最好奇的Python怎么回收没用的对象为什么它不自带“真正的并发”且听我慢慢拆。5.1 引用计数最朴素的垃圾回收CPython的内存回收机制第一层是引用计数reference counting。原理极其简单每个对象在内存里都记录着一个“被多少个变量/容器引用”的计数。当计数降为0时说明没有任何东西指向它了对象立刻被销毁内存在同一时刻就被释放。import sys a [] print(sys.getrefcount(a)) # 2一个来自a一个来自getrefcount的临时参数 b a print(sys.getrefcount(a)) # 3多了b del b print(sys.getrefcount(a)) # 2b没了 del a # 此刻引用计数归零列表被释放注意sys.getrefcount的结果总是比你以为的多1因为它自己把对象传进去时也临时加了一个引用。引用计数的优点是简单、及时没有“垃圾回收器扫描所有内存”那种暂停。缺点也明显如果两个对象互相引用循环引用它们的引用计数永远不会归零就永远不会被释放——这就是内存泄漏的一个源头。比如两个对象互相持有对方形成了一个环外部已经没有指针指向这个环了但它内部每个对象的引用计数都至少是1。5.2 标记-清除与分代回收处理循环引用为了解决循环引用问题CPython引入了一套标记-清除mark-sweep和分代回收generational garbage collection机制。标记-清除的思路从根对象比如全局变量、调用栈中的引用出发遍历所有能到达的对象能到达的标记为“存活”没被标记的就是“垃圾”释放掉。这套机制专门用来处理容器对象list、dict、set、自定义类实例等能找出一整个循环引用环并连锅端。分代回收是配合标记-清除的一种优化策略。CPython把对象分成三代新对象进第0代每次触发回收后存活下来的对象升入下一代。每一代触发回收的频率不同第0代最频繁第1代次之第2代最少。理由是统计学上的实证规律越年轻的对象越容易死比如函数里临时创建的列表函数结束就没了。老对象存活越久越稳不用频繁去扫描。这些回收触发阈值可以用gc.get_threshold()查看一般是(700, 10, 10)意思是新建对象数减去释放对象数达到700时执行一次第0代回收第0代执行10次后执行一次第1代回收第1代执行10次后执行一次第2代回收。5.3 GIL一把让多线程又爱又恨的锁GILGlobal Interpreter Lock全局解释器锁是CPython里最有争议的设计。它的大意在同一时刻CPython解释器只允许一个线程执行Python字节码。也就是说多线程在Python里并不能利用多核CPU并行执行Python代码——它们轮流抢同一把锁来回切换。为什么要有GIL历史原因很现实CPython的内存管理引用计数本身不是线程安全的。如果两个线程同时对一个对象做增删引用计数不加上锁就会导致计数混乱——对象被错误释放程序崩溃。而给每个对象都加细粒度锁又会让操作慢得无法接受。当时的CPython设计者选择了一把全局的大锁简单、安全、实现容易代价是放弃了多线程并行。GIL对不同类型的任务影响不同CPU密集型任务多线程几乎没有任何帮助甚至因为锁切换反而更慢。想用多核只能靠多进程multiprocessing。IO密集型任务多线程很有效。因为线程在等待网络响应、文件读写时会主动释放GIL让其他线程继续执行。比如爬虫爬100个网页单线程等网络的时间远大于执行时间多线程可以让等待和其他线程的请求同时进行。我在实际写爬虫和做数据处理时对GIL感受极深。爬虫用多线程轻松跑满网络带宽后来做股票数据计算几千只股票的指标计算在纯Python多线程下毫无加速换成multiprocessing之后CPU直接吃满。5.4 为什么说理解GIL是在学“原理”而不是“屠龙之技”很多新手觉得GIL离自己很远但它在高性能场景下的影响是实打实的。比如你在写一个Web服务如果某个请求做了非常重的CPU计算GIL会把这个请求的所有计算任务长时间占着锁其他请求全部卡住等待。这种情况下你需要考虑把重的计算放到其他进程多进程。用C扩展如numpy、pandas绕过GIL——它们的大量计算是在C层完成的不占用Python字节码的执行。用异步IOasyncio处理IO密集型任务。理解GIL能帮你正确选择并发方案而不是盲目开线程以为万事大吉。它是Python底层机制和实际工程决策之间最直接的桥梁。6. Python底层原理的学习路径与实用技巧讲了这么多底层机制你可能会问那我到底应该怎么系统地去学这些有没有什么具体的方法和工具帮我把这套心智模型内化6.1 三个性价比极高的标准库工具学习底层原理不需要一上来就看C源码。先把Python自带的“仪器”用起来你就能从Black Box变成透视眼id()查看对象的内存地址在CPython里就是实际地址。用它验证“a和b是否指向同一个对象”。sys.getrefcount(obj)查看对象的引用计数立刻感受“变量是标签”这句话的分量。dis.dis(func)反汇编函数看到真正的字节码指令序列彻底祛魅。这三个工具就够你做一大堆“思想实验”了。比如你可以试一下a [1, 2, 3] b a print(id(a), id(b)) # 地址一样 b [1, 2, 3] print(id(a), id(b)) # 地址不一样重新绑定了一个新对象你看同样是b ...一行是给旧对象贴标签另一行是重新贴到一个新对象上底层行为天差地别。6.2 可视化与实验把抽象变成直觉理论容易忘亲手做的实验不会忘。给你两个我常用的实验方法用画图辅助理解引用关系。我给团队新人讲引用和可变性的时候常让他们画出“对象”和“变量标签”的关系图标出每个对象的引用计数。画一遍比读十篇文章管用。测一下执行时间差异。比如对比在list头部插入和尾部追加的时间import timeit def insert_head(): lst [] for i in range(10000): lst.insert(0, i) def append_tail(): lst [] for i in range(10000): lst.append(i) print(timeit.timeit(insert_head, number100)) # 慢得多 print(timeit.timeit(append_tail, number100)) # 快得多差距通常是数量级的。亲手测完你就再也不会写出大量insert(0, x)的代码了。6.3 什么阶段需要去看CPython源码我前面一直劝你别急着看C源码但有一个阶段确实值得看当你对Object模型和内存管理已经有完整认知并且遇到了一些超出现有解释能力的问题时。比如你想知道a 1到底做了什么——去看int对象的重分配逻辑。你想知道为什么list扩容到1.125倍而不是2倍——去看list_resize函数。你想知道dict重新哈希的细节——去看dictresize。但在那之前我建议你先用自己的Python代码反复做实验把“变量是引用”“对象有引用计数”“容器底层是数组或哈希表”这些心智模型彻底内化。心智模型错了看源码也是看天书。6.4 一条实用的学习路线如果你问我学Python底层原理的正确顺序是什么我会建议先巩固基础语法和常用数据结构至少能独立写脚本解决实际问题。重点理解对象模型变量、引用、可变性、不可变性、is和的区别。这是所有后续认知的地基。学会用dis、id、sys.getrefcount做实验亲眼验证概念。理解解释器的运行机制编译-字节码-虚拟机执行。理解哈希表、动态数组等核心数据结构的内存布局和复杂度。理解内存管理引用计数、标记-清除、分代回收。能解释循环引用和内存泄漏。理解GIL及并发方案为什么多线程、多进程、异步各有适用场景。如果还有兴趣和精力再深入CPython源码。这套路径大概对应“学会Python”和“理解Python”之间的一段路走完之后你会发现那些网上流传的“Python诡异行为”大赏基本都能一眼看穿。6.5 关于学底层原理的一个心态提醒最后我想说点掏心窝的话。学习Python底层原理最大的阻碍不是智力而是心态。很多人一开始就想把每个细节都搞透结果被C源码和操作系统概念劝退反而连基本的编程能力都没打磨好。正确的姿势是“够用”优先遇到问题时再往深挖。比如你刚学完基本语法不用急着深挖GIL等哪天你写并发代码遇到性能瓶颈了再回来研究GIL那时你会豁然开朗。原理的果实时机成熟才有味道提前摘下来反而嚼不动。我个人学这些东西的体会就是不要试图一次学完而是像拼图一样在实战中一块一块捡起来最后它们自己会拼成一张完整的画面。
返回列表