如果Python虚拟机执行字节码,那非导入模块的字节码在哪里?

0 投票
3 回答
501 浏览
提问于 2025-04-17 18:13

根据以下链接,我理解了一些事情(我可能错了!):

http://docs.python.org/2/glossary.html#term-bytecode

  1. .pyc 是一个缓存文件,只有在其他地方导入这个模块时才会生成。

  2. .pyc 是为了提高加载速度,而不是执行速度。

  3. 运行 python foo.py 不会生成 foo.pyc,除非 foo 在其他地方被导入。

  4. Python 有一个字节码编译器(用来生成 .pyc 文件)。

  5. Python 的虚拟机执行字节码。

所以,当我运行 python foo.py 时,如果 foo.py 没有被导入到任何地方,Python 实际上会在内存中创建字节码吗?

缺少 .pyc 似乎打破了 Python 虚拟机的概念。

这个问题扩展到在 Python 解释器中执行代码(在终端运行 python)。我相信 CPython(或者几乎任何语言的实现)不能做到纯解释。

我认为问题的核心是:虚拟机是否真的读取 .pyc 文件?我假设虚拟机会将 .pyc 加载到执行环境中。

3 个回答

1

Python不能直接执行源代码,这和一些其他脚本语言(比如Bash)不同,那些语言可以随便解析代码。所有的Python源代码必须先编译成字节码,不管源代码是什么。(这也包括通过evalexec运行的代码。)生成字节码的过程比较耗费资源,因为它需要运行一个解析器,所以把字节码缓存起来(存成.pyc文件)可以加快模块加载的速度,省去了解析的步骤。

import foopython foo.py的区别就是,后者不会缓存生成的字节码。

1

有趣的是……我做的第一件事就是调用 --help

$ python --help
usage: python [option] ... [-c cmd | -m mod | file | -] [arg] ...
Options and arguments (and corresponding environment variables):
-B     : don't write .py[co] files on import; also PYTHONDONTWRITEBYTECODE=x
...

我看到的第一个选项是禁用在导入时自动生成 pyc 和 pyo 文件,不过这可能是因为它按字母顺序排列的。

让我们来做一些测试

$ echo "print 'hello world'" > test.py
$ python test.py 
hello world
$ ls test.py*
test.py
$ python -c "import test"
hello world
$ ls test.py*
test.py     test.pyc

所以它只在被导入时生成了 pyc 文件。

现在为了检查哪些文件被使用,我会用 OS X 的 dtruss,类似于 Linux 的 truss,来进行全面跟踪……

$ echo '#!/bin/sh 
 python test.py' > test.sh 
$ chmod a+x test.sh
$ sudo dtruss -a ./test.sh 2>&1 | grep "test.py*"
975/0x5713:    244829       6      3 read(0x3, "#!/bin/sh \npython test.py\n\b\0", 0x50)         = 26 0
975/0x5713:    244874       4      2 read(0xFF, "#!/bin/sh \npython test.py\n\b\0", 0x1A)        = 26 0
977/0x5729:    658694       6      2 readlink("test.py\0", 0x7FFF5636E360, 0x400)        = -1 Err#22
977/0x5729:    658726      10      6 getattrlist("/Users/samyvilar/test.py\0", 0x7FFF7C0EE510, 0x7FFF5636C6E0 = 0 0
977/0x5729:    658732       3      1 stat64("test.py\0", 0x7FFF5636DCB8, 0x0)        = 0 0
977/0x5729:    658737       5      3 open_nocancel("test.py\0", 0x0, 0x1B6)      = 3 0
977/0x5729:    658760       4      2 stat64("test.py\0", 0x7FFF5636E930, 0x1)        = 0 0
977/0x5729:    658764       5      2 open_nocancel("test.py\0", 0x0, 0x1B6)      = 3 0

看起来 Python 根本没有碰到 test.pyc 文件!

$ echo '#!/bin/sh 
 python -c "import test"' > test.sh
$ chmod a+x test.sh
$ sudo dtruss -a ./test.sh 2>&1 | grep "test.py*"
$ sudo dtruss -a ./test.sh 2>&1 | grep "test.py*"
1028/0x5d74:    654642       8      5 open_nocancel("test.py\0", 0x0, 0x1B6)         = 3 0
1028/0x5d74:    654683       8      5 open_nocancel("test.pyc\0", 0x0, 0x1B6)        = 4 0
$

这很有意思,它似乎是先打开了 test.py,然后是 test.pyc。

如果我们删除 pyc 文件会发生什么呢?

$ rm test.pyc
$ sudo dtruss -a ./test.sh 2>&1 | grep "test.py*"
1058/0x5fd6:    654151       7      4 open_nocancel("/Users/samyvilar/test.py\0", 0x0, 0x1B6)        = 3 0
1058/0x5fd6:    654191       6      3 open_nocancel("/Users/samyvilar/test.pyc\0", 0x0, 0x1B6)       = -1 Err#2
1058/0x5fd6:    654234       7      3 unlink("/Users/samyvilar/test.pyc\0", 0x1012B456F, 0x1012B45E0)        = -1 Err#2
1058/0x5fd6:    654400     171    163 open("/Users/samyvilar/test.pyc\0", 0xE01, 0x81A4)         = 4 0

它首先打开了 test.py,然后“尝试”打开 test.pyc,但返回了一个错误,然后调用 unlink 重新生成了 pyc 文件……有趣,我以为它会先检查一下。

如果我们删除原始的 py 文件呢?

$ sudo dtruss -a ./test.sh 2>&1 | grep "test.py*"
1107/0x670d:    655064       4      1 open_nocancel("test.py\0", 0x0, 0x1B6)         = -1 Err#2
1107/0x670d:    655069       8      4 open_nocancel("test.pyc\0", 0x0, 0x1B6)        = 3 0

这没什么意外,它无法打开 test.py,但仍然继续执行。到现在为止,我不确定这是否“正常”,Python 应该给出某种警告。我之前有几次因为意外删除文件而被坑,运行测试时松了一口气,结果通过了,后来却发现找不到源代码,真是让人汗颜!

经过这些测试,我们可以假设 Python 只在直接调用时使用 pyc 文件,比如 python test.pyc,或者在导入时间接使用,否则似乎并不使用它们。

据说 CPython 的编译器设计得相当快,它不做太多类型检查,可能生成的是非常高级的字节码,所以大部分工作实际上是由虚拟机完成的……它可能只进行一次处理,词法分析->编译->字节码一次性完成,每次读取命令行中的 Python 文件或导入时,如果没有 pyc 文件,就会创建一个。

这可能是为什么其他一些实现更快,因为它们花更多时间编译,但生成的字节码更原始,可以很好地优化。

构建一个高效的纯解释型虚拟机是非常困难的……

这都是关于平衡,字节码越强大,编译器可以越简单,但虚拟机就必须越复杂和慢,反之亦然……

4

你提到的第1到第5点都是对的,除了第4点(如果我们要精确一点的话)。Python解释器有一个部分叫做字节码编译器,它会把源代码转换成一个叫做<code object at 0x...>的东西。你可以通过输入f.__code__来查看任何函数f的这个字节码对象。这就是被解释的真正字节码。然后,这些代码对象可以作为一个单独的步骤,保存在.pyc文件里。

下面是更详细的操作说明。字节码编译器每个模块只运行一次,当你加载foo.py和它所导入的每个模块时。这个过程并不太长,但如果你的模块导入了很多其他模块,还是会花一些时间。这就是.pyc文件发挥作用的地方。在一个import语句调用了字节码编译器后,它会尝试把生成的<code object>保存到一个.pyc文件里。下次如果.pyc文件已经存在,并且.py文件没有被修改,那么就会从这个文件里重新加载<code object>。这只是一个优化:它避免了再次调用字节码编译器的开销。在这两种情况下,结果是一样的:在内存中创建了一个<code object>,并且会被解释。

这个机制只适用于import语句,而不适用于主模块(也就是在命令行中输入python foo.py时的foo.py)。这个想法是,这其实并不重要——在一个典型的中到大型程序中,字节码编译器浪费时间的地方是编译所有直接和间接导入的模块,而不仅仅是编译foo.py

撰写回答