背景
在Pycharm等IDE调试Python程序时,我们有时候会注意到调用栈中,有部分栈帧显示frame not available
,如果使用inspect.stack
输出栈信息,可以看到这些栈帧处在冻结模块(带有frozen
字样的模块名)里,而pdb、pydevd等调试器往往不能进入这些冻结模块上进行调试,不方便我们研究内部的运行机制。本文以此为出发点,尝试通过多种手段,使得解释器能够加载这些冻结模块的非冻结版本(此谓解冻),从而使调试器能将其按普通Python模块对待,支持更全面的调试。
冻结模块在PyCharm的调试截图
使用inspect.stack输出的栈信息
何为冻结模块(Frozen Modules)
Python的文档很少提到Freeze的概念,属于实现上的细节,但我们仍然可以从非常旧的文档一窥这个比较神奇的概念。本质上,冻结模块的代码通过Python实现,但在Python编译的过程中,其编译成字节码,并通过冻结实用程序(freeze utilitiy)集成在Python解释器中。也就是说,我们在运行Python解释器时,这些冻结模块已经是编译后的字节码形式了,如此调试器无法步入到里面的执行过程。这或许出于性能的考虑,降低载入的开销;另一方面,可能也出于避免核心模块被外部破坏的考虑。
如何判断是否为冻结模块
如何判断模块是否为冻结模块呢?从Python文档中,我们知道冻结模块的importer
属于FrozenImporter
,如此,我们可通过模块对象(ModuleType)的__spec__
属性获取里面的规格说明,判断规格里的loader
是否为importlib.machinery.FrozenImporter
即可,详细的实现如下:
def is_frozen(module):
import importlib.machinery
if module is None:
return False
spec = getattr(module, '__spec__', None)
loader = None
# 注意, 有些模块不一定有__spec__属性的
# 比如__main__
if spec is None:
# 如果没有__spec__,看看有没有__loader__
if hasattr(module, '__loader__'):
loader = module.__loader__
else:
return False
else:
loader = spec.loader
if loader is None:
return False
if loader is importlib.machinery.FrozenImporter:
return True
return False
import math
# False: math模块不是冻结的
print(not is_frozen(math))
import importlib
# True: importlib._bootstrap模块是冻结的
print(is_frozen(importlib._bootstrap))
还有另一种方法,就是使用内置的_imp
库,通过_imp.is_frozen
获取,但该方法的参数为一个字符串,而非模块对象。使用方法如以下所示:
import _imp
import math
# 注意传入的参数是字符串
print(_imp.is_frozen('math')) # False
import importlib
# False: importlib._bootstrap并非是真正的模块名字
print(_imp.is_frozen('importlib._bootstrap'))
# 同样是False: importlib导入的时候将它的__name__属性替换掉了
print(_imp.is_frozen(importlib._bootstrap.__name__))
# True: importlib._bootstrap实际上的模块名是_frozen_importlib
print(_imp.is_frozen('_frozen_importlib')) # True
可以看到上述代码中有个意想不到的地方:_imp.is_frozen('importlib._bootstrap')
输出的是False
,这是因为importlib._bootstrap
模块实际名字是_frozen_importlib
,而importlib
在导入它的时候,将其__name__
改成了'importlib._bootstrap'
,且同时作为键加入到sys.modules
里。我们作为外部使用者,根本不清楚模块的实际名字,所以使用内置的_imp
库判断冻结模块会产生意想不到的行为(当然,这个模块的命名在新版的Python中都带有下划线前缀了,属于实现细节,意味着Python官方并不希望外部用户使用它)。
列举冻结模块
此外,我们能知道Python中包含哪些冻结模块吗?目前官方标准库暂时没有提供相应的外部接口,我们可以通过自己编写代码,看看已加载的模块中,有哪些属于冻结模块:
def is_frozen(module):
""" 参照前面的实现 """
...
for module_name, module_obj in sys.modules.items():
if is_frozen(module_obj):
print(module_name)
可以看到有些标准库是被冻结的:
被冻结的标准库
还有一种办法,就是进去Python的安装目录下,找到Tools/scripts/freeze_modules.py
脚本,里面列举了需要冻结的内置模块:
freeze_modules.py
脚本里面列举了需要冻结的内置模块
调试冻结模块
由于冻结模块是编译后的字节码形式,如果我们需要深入调试冻结模块内部的执行过程,而PyCharm等IDE往往都不支持对此类模块的调试,此时怎么办呢?还好Python在本地还带有这些冻结标准库的非冻结版本(即py源码文件),此时需要使用一些tricks,诱导Python解释器载入非冻结版本,从而使得调试器能获取到这些模块的源码信息,以提供源码级的调试支持。
下面我们使用PyCharm作为开发环境,以os
模块为例,在Python中,os
模块属于冻结模块,优先载入的是字节码形式的版本,如下图所示:
os
模块是冻结模块
可以看到,在加载冻结版本的情况下,os.py
里面的断点是触发不到的:
os
模块源码级调试失败
下文将尝试通过各种方法,使得PyCharm支持对os
模块源码级的调试。
Python 3.11+的推荐做法:向Python解释器提供选项
Python 3.11 -X
引入了一个新的调试值frozen_modules
,用于控制解释器对于冻结模块的载入方式,如果传入-Xfrozen_modules=off
,则Python解释器会忽略已冻结的模块,优先加载源码。PyCharm开发环境中可以在下图的Run/Debug Configuration
加入该选项:
向Python解释器提供-Xfrozen_modules=off
选项
可以看到,现在的os
模块属于从源码中载入的了:
os
模块载入的是源码版本
与此同时,也支持对os
模块的源码级调试!
os
模块断点触发成功
Python 3.11之前的做法
由于上述的-Xfrozen_modules
选项属于3.11之后引入的新特性,之前的版本是没办法通过该选项实现冻结模块的源码载入。我们需要自己编写代码,来,骗,来,诱导Python导入源码。完整的代码如下:
import sys
import importlib
import importlib.machinery
# ============
# 补丁代码
# ============
def is_frozen(module):
import importlib.machinery
if module is None:
return False
spec = getattr(module, '__spec__', None)
loader = None
# 注意, 有些模块不一定有__spec__属性的
# 比如__main__
if spec is None:
# 如果没有__spec__,看看有没有__loader__
if hasattr(module, '__loader__'):
loader = module.__loader__
else:
return False
else:
loader = spec.loader
if loader is None:
return False
if loader is importlib.machinery.FrozenImporter:
return True
return False
def reload_for_frozen_modules():
# 从sys.meta_path中移除FrozenImporter
for finder in sys.meta_path:
if finder is importlib.machinery.FrozenImporter:
sys.meta_path.remove(finder)
break
# 遍历sys.modules,对于通过FrozenImporter加载的模块,重新加载
# 重新加载的目的是为了让这些模块的__loader__变为SourceFileLoader
for name, module in dict(sys.modules).items(): # 注意这里要用dict(sys.modules), 因为sys.modules会变
if is_frozen(module):
old = sys.modules[name] # 保存原模块,以便加载源码失败后恢复
del sys.modules[name] # 需要删除,否则importlib.reload会直接返回原模块
try:
# 重新import模块
sys.modules[name] = importlib.__import__(name)
except: # 加载源码失败,恢复
print(f'Failed to reload {name}')
sys.modules[name] = old
reload_for_frozen_modules()
# ===========
# 测试代码
# ===========
def test_os():
import os
# os.walk
for root, dirs, files in os.walk('.'):
print(root, dirs, files)
test_os()
主要思路是,从元路径查找器sys.metapath
中去掉内置的FrozenImporter
,使得原本冻结的模块只能通过源码加载。然后,我们再遍历已加载的模块,如果属于冻结模块,则尝试重新加载(记得清掉缓存)。此时,由于找不到冻结模块的元路径查找器,这些模块只能通过源码加载!
我们重新运行程序,发现断点已经被成功触发到了!
通过补丁代码成功调试冻结模块
注意:在生产环境等非调试情境中慎用以上做法,可能会导致程序行为异常!
import_bootstrap
的调试方法
此时,我们可以调试大部分的冻结模块了,但对于import.bootstrap
,上述两个做法仍无法实现源码级的调试。对于第一种方法,官方文档已明确表示importlib_bootstrap
和importlib_bootstrap_external
始终使用冻结模块版本;而对于第二种方法,通过分析importlib
源码可知,其_bootstrap
属性是通过导入_frozen_importlib
这个不同名的冻结模块实现的,此时其仍存在缓存中。
该模块的调试方法值得另开一篇文章介绍,这里不再赘述。详见如何调试importlib_bootstrap模块一文。
总结
本文介绍了Python中冻结模块的概念,以及如何通过一些tricks,使得PyCharm等IDE支持对冻结模块的源码级调试。这对于我们研究Python内部的运行机制,或者调试一些内部模块的时候提供很大的帮助,嘿嘿~
最后再次强调,以上方法仅供调试使用,不建议在生产环境中使用!
参考资料
- Python文档:
importlib.machinery.FrozenImporter
- Python文档:
imp.init_frozen
- Python文档:
-X
选项 - Python文档:
sys.meta_path
- Python Issue: can’t step through _frozen_importlib/importlib._bootstrap using pdb
- StackOverflow: importlib._bootstrap and python interpreter initialization
- StackOverflow: Trace Python imports