谈谈 Python 程序的运行原理
本文内容纲要:
-1.简单的例子
-2.背后的魔法
-2.1模块
-2.2编译
-2.3pyc文件
-2.4字节码指令
-2.5Python虚拟机
-2.6import指令
-2.7绝对引入和相对引入
-2.8赋值语句
-2.9def指令
-2.10动态类型
-2.11命名空间(namespace)
-2.12内置属性__name__
-2.13函数调用
-3.回顾
-3.1pyc文件
-3.2小整数对象池
-3.3字符串对象缓冲池
-3.4import指令
-3.5多线程
-3.6垃圾回收
-4.参考文献
因为我的个人网站restran.net已经启用,博客园的内容已经不再更新。请访问我的个人网站获取这篇文章的最新内容,谈谈Python程序的运行原理
这篇文章准确说是『Python源码剖析』的读书笔记,整理完之后才发现很长,那就将就看吧。
1.简单的例子
先从一个简单的例子说起,包含了两个文件foo.py和demo.py
[foo.py]
defadd(a,b):
returna+b
[demo.py]
importfoo
a=[1,'python']
a='astring'
deffunc():
a=1
b=257
print(a+b)
print(a)
if__name__=='__main__':
func()
foo.add(1,2)
执行这个程序
pythondemo.py
输出结果
astring
258
同时,该文件目录多出一个foo.pyc文件
2.背后的魔法
看完程序的执行结果,接下来开始一行行解释代码。
2.1模块
Python将.py文件视为一个module,这些module中,有一个主module,也就是程序运行的入口。在这个例子中,主module是demo.py。
2.2编译
执行pythondemo.py
后,将会启动Python的解释器,然后将demo.py编译成一个字节码对象PyCodeObject。
有的人可能会很好奇,编译的结果不应是pyc文件吗,就像Java的class文件,那为什么是一个对象呢,这里稍微解释一下。
在Python的世界中,一切都是对象,函数也是对象,类型也是对象,类也是对象(类属于自定义的类型,在Python2.2之前,int,dict这些内置类型与类是存在不同的,在之后才统一起来,全部继承自object),甚至连编译出来的字节码也是对象,.pyc文件是字节码对象(PyCodeObject)在硬盘上的表现形式。
在运行期间,编译结果也就是PyCodeObject对象,只会存在于内存中,而当这个模块的Python代码执行完
后,就会将编译结果保存到了pyc文件中,这样下次就不用编译,直接加载到内存中。pyc文件只是PyCodeObject对象在硬盘上的表现形式。
这个PyCodeObject对象包含了Python源代码中的字符串,常量值,以及通过语法解析后编译生成的字节码指令。PyCodeObject对象还会存储这些字节码指令与原始代码行号的对应关系,这样当出现异常时,就能指明位于哪一行的代码。
2.3pyc文件
一个pyc文件包含了三部分信息:Python的magicnumber、pyc文件创建的时间信息,以及PyCodeObject对象。
magicnumber是Python定义的一个整数值。一般来说,不同版本的Python实现都会定义不同的magicnumber,这个值是用来保证Python兼容性的。比如要限制由低版本编译的pyc文件不能让高版本的Python程序来执行,只需要检查magicnumber不同就可以了。由于不同版本的Python定义的字节码指令可能会不同,如果不做检查,执行的时候就可能出错。
下面所示的代码可以来创建pyc文件,使用方法
pythongenerate_pyc.pymodule_name
例如
pythongenerate_pyc.pydemo
[generate_pyc.pyc]
importimp
importsys
defgenerate_pyc(name):
fp,pathname,description=imp.find_module(name)
try:
imp.load_module(name,fp,pathname,description)
finally:
iffp:
fp.close()
if__name__=='__main__':
generate_pyc(sys.argv[1])
2.4字节码指令
为什么pyc文件也称作字节码文件?因为这些文件存储的都是一些二进制的字节数据,而不是能让人直观查看的文本数据。
Python标准库提供了用来生成代码对应字节码的工具dis
。dis提供一个名为dis的方法,这个方法接收一个code对象,然后会输出code对象里的字节码指令信息。
s=open('demo.py').read()
co=compile(s,'demo.py','exec')
importdis
dis.dis(co)
执行上面这段代码可以输出demo.py编译后的字节码指令
10LOAD_CONST0(-1)
3LOAD_CONST1(None)
6IMPORT_NAME0(foo)
9STORE_NAME0(foo)
312LOAD_CONST2(1)
15LOAD_CONST3(u'python')
18BUILD_LIST2
21STORE_NAME1(a)
424LOAD_CONST4(u'astring')
27STORE_NAME1(a)
630LOAD_CONST5(<codeobjectfuncat00D97650,file"demo.py",line6>)
33MAKE_FUNCTION0
36STORE_NAME2(func)
1139LOAD_NAME1(a)
42PRINT_ITEM
43PRINT_NEWLINE
1344LOAD_NAME3(__name__)
47LOAD_CONST6(u'__main__')
50COMPARE_OP2(==)
53POP_JUMP_IF_FALSE82
1456LOAD_NAME2(func)
59CALL_FUNCTION0
62POP_TOP
1563LOAD_NAME0(foo)
66LOAD_ATTR4(add)
69LOAD_CONST2(1)
72LOAD_CONST7(2)
75CALL_FUNCTION2
78POP_TOP
79JUMP_FORWARD0(to82)
>>82LOAD_CONST1(None)
85RETURN_VALUE
2.5Python虚拟机
demo.py被编译后,接下来的工作就交由Python虚拟机来执行字节码指令了。Python虚拟机会从编译得到的PyCodeObject对象中依次读入每一条字节码指令,并在当前的上下文环境
中执行这条字节码指令。我们的程序就是通过这样循环往复的过程才得以执行。
2.6import指令
demo.py的第一行代码是importfoo
。import指令用来载入一个模块,另外一个载入模块的方法是fromxximportyy
。用from语句的好处是,可以只复制需要的符号变量到当前的命名空间中(关于命名空间将在后面介绍)。
前文提到,当已经存在pyc文件时,就可以直接载入而省去编译过程。但是代码文件的内容会更新,如何保证更新后能重新编译而不入旧的pyc文件呢。答案就在pyc文件中存储的创建时间信息
。当执行import指令的时候,如果已存在pyc文件,Python会检查创建时间是否晚于代码文件的修改时间,这样就能判断是否需要重新编译,还是直接载入了。如果不存在pyc文件,就会先将py文件编译。
2.7绝对引入和相对引入
前文已经介绍了importfoo
这行代码。这里隐含了一个问题,就是foo
是什么,如何找到foo
。这就属于Python的模块引入规则,这里不展开介绍,可以参考pep-0328。
2.8赋值语句
接下来,执行到a=[1,'python']
,这是一条赋值语句,定义了一个变量a,它对应的值是[1,'python']。这里要解释一下,变量是什么呢?
按照[维基百科]("https://en.wikipedia.org/wiki/Variable_(computer_science")的解释
变量是一个存储位置和一个关联的符号名字,这个存储位置包含了一些已知或未知的量或者信息。
变量实际上是一个字符串的符号,用来关联一个存储在内存中的对象。在Python中,会使用dict(就是Python的dict对象)来存储变量符号(字符串)与一个对象的映射。
那么赋值语句实际上就是用来建立这种关联,在这个例子中是将符号a
与一个列表对象[1,'python']
建立映射。
紧接着的代码执行了a='astring'
,这条指令则将符号a
与另外一个字符串对象astring
建立了映射。今后对变量a
的操作,将反应到字符串对象astring
上。
2.9def指令
我们的Python代码继续往下运行,这里执行到一条deffunc()
,从字节码指令中也可以看出端倪MAKE_FUNCTION
。没错这条指令是用来创建函数的。Python是动态语言,def实际上是执行一条指令,用来创建函数(class则是创建类的指令),而不仅仅是个语法关键字。函数并不是事先创建好的,而是执行到的时候才创建的。
deffunc()
将会创建一个名称为func
的函数对象。实际上是先创建一个函数对象,然后将func这个名称符号绑定到这个函数上。
Python中是无法实现C和Java中的重载的,因为重载要求函数名要相同,而参数的类型或数量不同,但是Python是通过变量符号(如这里的
func
)来关联一个函数,当我们用def语句再次创建一个同名的函数时,这个变量名就绑定到新的函数对象上了。
2.10动态类型
继续看函数func
里面的代码,这时又有一条赋值语句a=1
。变量a
现在已经变成了第三种类型,它现在是一个整数了。那么Python是怎么实现动态类型的呢?答案就藏在具体存储的对象上。变量a
仅仅只是一个符号(实际上是一个字符串对象),类型信息是存储在对象上的。在Python中,对象机制的核心是类型信息和引用计数(引用计数属于垃圾回收的部分)。
用type(a),可以输出a的类型,这里是int
b=257
跳过,我们直接来看看print(a+b)
,print是输出函数,这里略过。这里想要探究的是a+b
。
因为a
和b
并不存储类型信息,因此当执行a+b
的时候就必须先检查类型,比如1+2和"1"+"2"的结果是不一样的。
看到这里,我们就可以想象一下执行一句简单的a+b
,Python虚拟机需要做多少繁琐的事情了。首先需要分别检查a
和b
所对应对象的类型,还要匹配类型是否一致(1+"2"将会出现异常),然后根据对象的类型调用正确的+
函数(例如数值的+或字符串的+),而CPU对于上面这条语句只需要执行ADD指令(还需要先将变量MOV到寄存器)。
2.11命名空间(namespace)
在介绍上面的这些代码时,还漏掉了一个关键的信息就是命名空间。在Python中,类、函数、module都对应着一个独立的命名空间。而一个独立的命名空间会对应一个PyCodeObject对象,所以上面的demo.py文件编译后会生成两个PyCodeObject,只是在demo.py这个module层的PyCodeObject中通过一个变量符号func
嵌套了一个函数的PyCodeObject。
命名空间的意义,就是用来确定一个变量符号到底对应什么对象。命名空间可以一个套一个地形成一条命名空间链,Python虚拟机在执行的过程中,会有很大一部分时间消耗在从这条命名空间链中确定一个符号所对应的对象是什么。
在Python中,命名空间是由一个dict对象实现的,它维护了(name,obj)这样的关联关系。
说到这里,再补充一下importfoo
这行代码会在demo.py这个模块的命名空间中,创建一个新的变量名foo
,foo
将绑定到一个PyCodeObject对象,也就是foo.py的编译结果。
2.11.1dir函数
Python的内置函数dir可以用来查看一个命名空间下的所有名字符号。一个用处是查看一个命名空间的所有属性和方法(这里的命名空间就是指类、函数、module)。
比如,查看当前的命名空间,可以使用dir(),查看sys模块,可以使用dir(sys)。
2.11.2LEGB规则
Python使用LEGB的顺序来查找一个符号对应的对象
locals->enclosingfunction->globals->builtins
locals,当前所在命名空间(如函数、模块),函数的参数也属于命名空间内的变量
enclosing,外部嵌套函数的命名空间(闭包中常见)
deffun1(a):
deffun2():
#a位于外部嵌套函数的命名空间
print(a)
globals,全局变量,函数定义所在模块的命名空间
a=1
deffun():
#需要通过global指令来声明全局变量
globala
#修改全局变量,而不是创建一个新的local变量
a=2
builtins,内置模块的命名空间。Python在启动的时候会自动为我们载入很多内置的函数、类,比如dict,list,type,print,这些都位于__builtins__
模块中,可以使用dir(__builtins__)
来查看。这也是为什么我们在没有import任何模块的情况下,就能使用这么多丰富的函数和功能了。
介绍完命名空间,就能理解print(a)
这行代码输出的结果为什么是astring
了。
2.12内置属性__name__
现在到了解释if__name__=='__main__'
这行代码的时候了。当Python程序启动后,Python会自动为每个模块设置一个属性__name__
通常使用的是模块的名字,也就是文件名,但唯一的例外是主模块,主模块将会被设置为__main__
。利用这一特性,就可以做一些特别的事。比如当该模块以主模块来运行的时候,可以运行测试用例。而当被其他模块import时,则只是乖乖的,提供函数和功能就好。
2.13函数调用
最后两行是函数调用,这里略去不讲。
3.回顾
讲到最后,还有些内容需要再回顾和补充一下。
3.1pyc文件
Python只会对那些以后可能继续被使用和载入的模块才会生成pyc文件,Python认为使用了import指令的模块,属于这种类型,因此会生成pyc文件。而对于只是临时用一次的模块,并不会生成pyc文件,Python将主模块当成了这种类型的文件。这就解释了为什么pythondemo.py
执行完后,只会生成一个foo.pyc
文件。
如果要问pyc文件什么时候生成,答案就是在执行了import指令之后,fromxximportyy同样属于import指令。
3.2小整数对象池
在demo.py这里例子中,所用的整数特意用了一个257,这是为了介绍小整数对象池的。整数在程序中的使用非常广泛,Python为了优化速度,使用了小整数对象池,避免为整数频繁申请和销毁内存空间。
Python对小整数的定义是[-5,257),这些整数对象是提前建立好的,不会被垃圾回收。在一个Python的程序中,所有位于这个范围内的整数使用的都是同一个对象,从下面这个例子就可以看出。
>>>a=1
>>>id(a)
40059744
>>>b=1
>>>id(b)
40059744
>>>c=257
>>>id(c)
41069072
>>>d=257
>>>id(257)
41069096
id函数可以用来查看一个对象的唯一标志,可以认为是内存地址
对于大整数,Python使用的是一个大整数对象池
。这句话的意思是:
每当创建一个大整数的时候,都会新建一个对象,但是这个对象不再使用的时候,并不会销毁,后面再建立的对象会复用之前已经不再使用的对象的内存空间。(这里的不再使用指的是引用计数为0,可以被销毁)
3.3字符串对象缓冲池
如果仔细思考一下,一定会猜到字符串也采用了这种类似的技术,我们来看一下
>>>a='a'
>>>b='a'
>>>id(a)
14660456
>>>id(b)
14660456
没错,Python的设计者为一个字节
的字符对应的字符串对象(PyStringObject)也设计了这样一个对象池。同时还有一个intern
机制,可以将内容相同的字符串变量转换成指向同一个字符串对象。
intern机制的关键,就是在系统中有一个(key,value)映射关系的集合,集合的名称叫做interned。在这个集合中,记录着被intern机制处理过的PyStringObject对象。不过Python始终会为字符串创建PyStringObject对象,即便在interned中已经有一个与之对应的PyStringObject对象了,而intern机制是在字符串被创建后才起作用。
>>>a='astring'
>>>b='astring'
>>>aisb
False
>>>a=intern('astring')#手动调用intern方法
>>>b=intern('astring')
>>>aisb
True
关于intern函数可以参考官方文档,更多扩展阅读:
http://stackoverflow.com/questions/15541404/python-string-interning
值得说明的是,数值类型和字符串类型在Python中都是不可变的,这意味着你无法修改这个对象的值,每次对变量的修改,实际上是创建一个新的对象。得益于这样的设计,才能使用对象缓冲池这种优化。
Python的实现上大量采用了这种内存对象池的技术,不仅仅对于这些特定的对象,还有专门的内存池用于小对象,使用这种技术可以避免频繁地申请和释放内存空间,目的就是让Python能稍微更快一点。更多内容可以参考这里。
如果想了解更快的Python,可以看看PyPy
3.4import指令
前文提到import指令是用来载入module的,如果需要,也会顺道做编译的事。但import指令,还会做一件重要的事情就是把import的那个module的代码执行一遍,这件事情很重要
。Python是解释执行的,连函数都是执行的时候才创建的。如果不把那个module的代码执行一遍,那么module里面的函数都没法创建,更别提去调用这些函数了。
执行代码的另外一个重要作用,就是在这个module的命名空间中,创建模块内定义的函数和各种对象的符号名称(也就是变量名),并将其绑定到对象上,这样其他module才能通过变量名来引用这些对象。
Python虚拟机还会将已经import过的module缓存起来,放到一个全局module集合sys.modules中。这样做有一个好处,即如果程序的在另一个地方再次import这个模块,Python虚拟机只需要将全局module集合中缓存的那个module对象返回即可。
你现在一定想到了sys.modules是一个dict对象,可以通过type(sys.modules)来验证
3.5多线程
demo.py这个例子并没有用到多线程,但还是有必要提一下。
在提到多线程的时候,往往要关注线程如何同步,如何访问共享资源。Python是通过一个全局解释器锁GIL(GlobalInterpreterLock)来实现线程同步的。当Python程序只有单线程时,并不会启用GIL,而当用户创建了一个thread时,表示要使用多线程,Python解释器就会自动激活GIL,并创建所需要的上下文环境和数据结构。
Python字节码解释器的工作原理是按照指令的顺序一条一条地顺序执行,Python内部维护着一个数值,这个数值就是Python内部的时钟,如果这个数值为N,则意味着Python在执行了N条指令以后应该立即启动线程调度机制,可以通过下面的代码获取这个数值。
importsys
sys.getcheckinterval()#100
线程调度机制将会为线程分配GIL,获取到GIL的线程就能开始执行,而其他线程则必须等待。由于GIL的存在,Python的多线程性能十分低下,无法发挥多核CPU的优势,性能甚至不如单线程。因此如果你想用到多核CPU,一个建议是使用多进程
。
3.6垃圾回收
在讲到垃圾回收的时候,通常会使用引用计数的模型,这是一种最直观,最简单的垃圾收集技术。Python同样也使用了引用计数,但是引用计数存在这些缺点:
- 频繁更新引用计数会降低运行效率
- 引用计数无法解决循环引用问题
Python在引用计数机制
的基础上,使用了主流垃圾收集技术中的标记——清除
和分代收集
两种技术。
关于垃圾回收,可以参考
http://hbprotoss.github.io/posts/pythonla-ji-hui-shou-ji-zhi.html
4.参考文献
- Python源码剖析
- Python官方文档
本文内容总结:1.简单的例子,2.背后的魔法,2.1模块,2.2编译,2.3pyc文件,2.4字节码指令,2.5Python虚拟机,2.6import指令,2.7绝对引入和相对引入,2.8赋值语句,2.9def指令,2.10动态类型,2.11命名空间(namespace),2.12内置属性name,2.13函数调用,3.回顾,3.1pyc文件,3.2小整数对象池,3.3字符串对象缓冲池,3.4import指令,3.5多线程,3.6垃圾回收,4.参考文献,
原文链接:https://www.cnblogs.com/restran/p/4903056.html