Python 程序运行原理

将从总体上描述 Python 代码是怎么运行的,帮助我们在程序设计、编写代码、查找问题时,有一个程序运行大局意识。这里会涉及到 Python 对内存的使用、程序运行的过程等内容。Python 是一种动态类型化语言,Python 中的一切都是对象,我们得到的返回是对这些对象的引用。

程序的结构

Python 程序由代码块(code block)构成。代码块是作为一个单元执行的一段 Python 程序文本。代码块包括:

  • 交互模型下:执行的每个命令代码块
  • 脚本文件:
    • 整体是一个代码块(在解释器命令行上用 -c 选项指定的命令)
    • 以顶级脚本最高层级脚本(即 __main__ 模块)运行的模块(使用 -m 参数从命令行)
  • 传递给以下内置函数的字符串参数是一个代码块
    • eval()
    • exec()

代码块在执行帧(frame)中执行。一个帧包含一些管理信息(用于调试),并确定代码块执行完成后在何处以及如何继续执行。

命名空间

命名空间是从名称到对象的映射,Python 是用命名空间来记录变量的轨迹的。名称空间目前都实现为 Python 字典,键是变量名,值是变量值。各个命名空间是独立没有关系的,一个命名空间中不能有重名,但是不同的命名空间可以重名而没有任何影响。

命名空间分类

在一个 Python 程序中的任何一个地方,都存在几个可用的命名空间。

  • local,每个函数都有着自已的命名空间,叫做局部命名空间,它记录了函数的变量,包括函数的参数和局部定义的变量。
  • global,每个模块拥有它自已的命名空间,叫做全局命名空间,它记录了模块的变量,包括函数、类、其它导入的模块、模块级的变量和常量。
  • Built-in,还有就是内置命名空间,Python 自带的内建命名空间任何模块均可访问它,它存放着内置的函数和异常。

命名空间查找顺序

当一行代码要使用变量 x 的值时,Python 会到所有可用的名字空间去查找变量,按照如下顺序:

  • 1、局部命名空间 local:特指当前函数或类的方法。如果函数定义了一个局部变量 x,或一个参数 x,Python 将使用它,然后停止搜索。
  • 2、全局命名空间 global:特指当前的模块。如果模块定义了一个名为 x 的变量,函数或类,Python 将使用它然后停止搜索。
  • 3、内置命名空间 Built-in:对每个模块都是全局的。作为最后的尝试,Python 将假设 x 是内置函数或变量。
  • 4、如果 Python 在这些名字空间找不到 x,它将放弃查找并引发一个 NameError 异常,如,NameError: name 'aa' is not defined。

嵌套函数的情况:

  • 先在当前 (嵌套的或 lambda) 函数的命名空间中搜索
  • 然后是在父函数的命名空间中搜索
  • 接着是模块命名空间中搜索
  • 最后在内置命名空间中搜索

命名空间的生命周期

不同的命名空间在不同的时刻创建,有不同的生存期。

  • Built-in 内置命名空间在 Python 解释器启动时创建,会一直保留,不被删除。
  • global 模块的全局命名空间在模块定义被读入时创建,通常模块命名空间也会一直保存到解释器退出。
  • local 当函数被调用时创建一个局部命名空间,当函数返回结果 或 抛出异常时,被删除。每一个递归调用的函数都拥有自己的命名空间。

各命名空间创建顺序:

Python 解释器启动 -> 创建内建命名空间 -> 加载模块 -> 创建全局命名空间 -> 函数被调用 -> 创建局部命名空间

各命名空间销毁顺序:

函数调用结束 -> 销毁函数对应的局部命名空间 -> Python 虚拟机(解释器)退出 -> 销毁全局命名空间 -> 销毁内建命名空间

Python 解释器加载阶段会创建出内建命名空间、模块的全局命名空间,局部命名空间是在运行阶段函数被调用时动态创建出来的,函数调用结束动态的销毁的。

Python 的全局命名空间存储在一个叫 globals() 的 dict 对象中;局部命名空间存储在一个叫 locals() 的 dict 对象中。可以用 locals() 来查看该函数体内的所有变量名和变量值。

名称的绑定

名称(标识符)指向对象,名称是通过名称绑定操作引入的。以下构造绑定名称:

  • 函数的形参
  • 类的定义
  • 函数定义
  • 赋值表达式
  • 如果出现在赋值中,则作为标识符的目标:
    • for 循环头
    • 在 with 语句中的 as 之后,结构模式匹配中除子句或 as 模式外
    • 结构模式匹配中的捕获模式
  • import 语句

要注意的是:

  • import 语句来自 from ... import * 绑定导入模块中定义的所有名称,以下划线开头的名称除外。这种形式只能在模块级别使用
  • del 语句中出现的目标也被认为是为此绑定的(尽管实际的语义是解除名称绑定)
  • 每个赋值或导入语句都出现在由类或函数定义定义的块内,或出现在模块级(顶层代码块)
  • 如果名称绑定在一个代码块中,则为该代码块的局部变量,除非声明为 nonlocal 或 global。 如果名称绑定在模块层级,则为全局变量。 (模块代码块的变量既为局部变量又为全局变量。) 如果变量在一个代码块中被使用但不是在其中定义,则为 自由变量。
  • 程序文本中出现的每一个名称都指由后边的名称解析规则建立的该名称的绑定

name 和 object 是两个抽象的概念,在 Python 中,赋值语句的作用有两个:

  • 创建新对象
  • 在新对象和名称之间建立连接

执行过程

Python是面向对象的编程语言,一切皆为对象,不像 Java 里面还有所谓的基本数据类型(原始数据类型,int、char、boolean、double等),所以变量其实就是对象的引用,它们保存在名字叫”栈“(stack)的存储空间上,而对象理论上来讲都保存在称为”堆“(heap)的存储空间上。当然有些通过字面量语法创建的对象会共享内存空间。

Python 不会在执行之前编译我们的 Python 代码,而是 Python 本身是一个编译程序(称为 Python 解释器),它逐行执行 Python 代码,解析代码,转换为字节码并运行。

Python 必须为我们做一些内存管理,这样我们才能实现动态类型化,完全忽略代码的内存分配和释放。

如果您在较高的层次上理解了内存模型,您就会知道,在运行时,系统可以利用两种类型的内存段:栈段(Stack segment)和堆段(Heap segment)。理想情况下:

  • 栈(stack)用于函数和方法执行
  • 堆(Heap)是程序可以请求的唯一动态分配段

如,在 linux 系统上,动态分配是通过调用 brk()/sbrk() 系统调用来实现的,该调用返回(计算机的)页面,由应用程序维护并计算接收到的内存。这样做的好处是为了更好的利用高效率的内存,同时将程序员从各种内存管理的细节中解放,让他们更好的关注业务逻辑。

栈内存

说到栈内存,不能不说的是“栈”。只要学过一点数据结构的都知道,栈(stack)是一种后进先出(last in first out,缩写为LIFO)的数据结构。那“栈内存”里的“栈”和后进先出有什么关系呢?大家在写代码的时候,都会调用函数,而函数又可以调用其他函数。假定一个函数func1调用了函数func2,func2又调用了func3,那么这三个函数返回(return)的顺序是什么?没错,func3先返回,然后是func2,最后是func1。看见了?先调用的函数后返回,后调用的函数先返回,后进先出!这就是大家常说的“函数调用栈”(call stack)。

大家也都知道,函数可以定义一些参数(形参),函数体里也可以定义局部变量。对于每一个函数来说,当前函数一旦返回,这些形参和局部变量就出了作用域,也就没用了,于是它们占用的内存也就可以安全地释放了。从这里可以看见,形参和局部变量的生命周期和函数调用的生命周期一致,而函数的嵌套调用是后进先出的,那么这部分内存也可以用后进先出的方式去管理,这就是栈内存。每个函数被调用时,操作系统或语言运行时会创建一个对应于本次函数调用的“栈帧”(stack frame)并push到栈内存上,当这个函数返回的时候pop掉。

对于每一个栈帧,它所需要的内存大小在编译时就能知道了(如果不考虑编译时优化,每个栈帧的大小等于当前函数的所有形参和局部变量的大小的总和,外加一些元数据的大小),并且每个形参和局部变量在当前栈帧上的内存地址偏移量也都是固定的,所以只要有个变量名就可以直接获取到这个变量对应的内存地址(变量名在编译时直接变成偏移量。这就是为什么Java程序反编译之后局部变量和型参的名称全部丢失的原因),从而直接访问它。对于多线程的程序,每个线程都有自己的栈内存。

由于栈内存的生命周期非常明了,栈内存的管理也相当地直截了当,通常操作系统直接就替你做了。栈内存的好处我个人觉得有这么几点:

  • 无需通过指针或引用动态寻址,访问速度快
  • 生命周期非常明确,释放及时,内存空间使用效率高
  • 大小和偏移量固定,开内存的算法也简单,内存分配的延迟短
  • 回收内存不需要复杂的垃圾回收机制,算法的时间开销稳定

但是栈内存也有它的“缺陷”(严格地说是它设计的时候就不想让你那么用它):

  • 一次函数调用一旦返回,它对应的栈帧上的数据就不能用了(我在小白的年代经常犯这种错误,一个函数返回一个指向栈内存里的结构体的指针)。换句话说就是栈内存上的数据活不过一次函数调用的生命周期。
  • 栈内存无法在多个线程间共享
  • 栈内存的大小通常比较小(几个MB),如果往里塞的栈帧太多(=函数调用层次太深)或栈帧里要开的结构体太大,就会导致栈溢出(stack overflow)。这就是为什么递归函数的退出条件没写好就经常会导致stack overflow的道理,因为总是在push却没有pop。

为了克服上述任意一种“缺陷”,我们需要另一种内存管理机制:

堆内存

很抱歉,翻阅了很多资料也没找到“堆内存”和数据结构里的“堆”之间的关系。有人说是当年的Lisp用堆实现了这种内存管理方式(但没有证据)。总之记住数据结构的堆和堆内存完全不沾边就对了。堆内存就像是一堆杂乱无章的东西堆在那儿,也许这才是“堆内存”这个名称的由来吧。

堆内存是一种按需分配的动态内存管理机制(这里的动态是指什么时候申请就什么时候给你,申请多少给你多少,而不是像栈内存那样只要调了函数就直接把所有本次调用可能用到的内存都给你,不管你具体什么时候用),这种机制能让数据在内存里存活时间超过定义它的函数的生命周期(不去销毁就一直在那儿),也能让多个线程共享同一个内存里的结构体/对象(任何线程只要拿到这片内存的地址和大小就能访问)。但是由于它的动态,你不能仅靠一个变量名就知道每一份数据在内存里的确切位置,导致要访问它里面的数据必须通过指针或引用。

堆内存给我的感觉很像某些商场里寄放包裹的储物柜。你要用储物柜,就得先找前台申请,她会给你一把钥匙。有了这把钥匙,你就能往柜子里放东西了。等你购物完准备走人的时候,把柜子里的东西取出来,再把钥匙还掉。只不过商场里的柜子只有一种大小,而堆内存空间你可以任意指定需要的大小。另外,申请堆内存时拿到的钥匙(指针或引用)是可以复制的。

堆内存里的数据的生命周期只有程序员才知道(如果他真的知道),所以任何操作系统和语言运行时都不能准确地知道什么时候才应该释放一片堆内存空间。堆内存的管理现在比较常见的有三种手段:

  • 程序员手动管理(C/C++)。
  • 引入垃圾回收机制,在程序闲下来的时候或内存不够的时候清一下(这类语言最多,我都不想去列举了)。
  • 要求程序员明确指出每个变量的生命周期,并引入结构体的转让/借用机制,编译器据此推算出堆内存上的结构体什么时候释放(貌似只有Rust是这么做的)。

补充

大家可以发现我这里只字未提内存地址增长方向,因为我觉得这是实现细节,每个语言都可以有自己的特色(比如Erlang的栈内存压根儿就不是正常的栈,而是类似于注册表
的形式,所以也就不存在内存地址增长方向这一说了,当然它也不会有栈溢出),所以不能一概而论啦。

执行示例

通过一个示例代码,让我们试着理解它:

def bar(a):
    a = a - 1
    return a

def foo(a):
    a = a * a
    b = bar(a)
    return b

def main():
    x = 2
    y = foo(x)

if __name__ == "__main__":
    main()

上述代码有三个函数,程序运行时调用 main()main() 中调用 foo()、 foo() 调用 bar(),然后依次收到所返回的值进行计算,最终返回结果。

这个执行过程为:

  • 创建程序的栈(stack)或者调用栈(call stack)
  • 引用变量作为局部变量和参数进入堆栈,但对象是从堆中分配的,堆中的每个新对象以及 Python 中的所有对象都是一个对象
  • 堆栈事件、函数被调用(推送到堆栈上)和返回(从堆栈弹出),它们在堆中分配对象

Python 程序运行原理

在下图中,当 foo() 和 bar() 超出范围(从堆栈中弹出)时,它们留下了一些带有暂停对象的堆内存,没有释放它们。

Python 程序运行原理

Python 有一个内置的资源管理器,名为垃圾收集器,它在垃圾收集之前保持在用内存和使用后内存引用的计数。

  • 24
    点赞
  • 20
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

高亚奇

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值