[Python进阶] 如何解冻和调试冻结模块(Frozen Modules)

使其在PyCharm等IDE上支持堆栈信息显示、断点以及单步调试。

由Jeza Chen 发表于 October 1, 2024

背景

在Pycharm等IDE调试Python程序时,我们有时候会注意到调用栈中,有部分栈帧显示frame not available,如果使用inspect.stack输出栈信息,可以看到这些栈帧处在冻结模块(带有frozen字样的模块名)里,而pdb、pydevd等调试器往往不能进入这些冻结模块上进行调试,不方便我们研究内部的运行机制。本文以此为出发点,尝试通过多种手段,使得解释器能够加载这些冻结模块的非冻结版本(此谓解冻),从而使调试器能将其按普通Python模块对待,支持更全面的调试。

冻结模块在PyCharm的调试截图

冻结模块在PyCharm的调试截图

使用inspect.stack输出的栈信息

使用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`脚本里面列举了需要冻结的内置模块

freeze_modules.py脚本里面列举了需要冻结的内置模块

调试冻结模块

由于冻结模块是编译后的字节码形式,如果我们需要深入调试冻结模块内部的执行过程,而PyCharm等IDE往往都不支持对此类模块的调试,此时怎么办呢?还好Python在本地还带有这些冻结标准库的非冻结版本(即py源码文件),此时需要使用一些tricks,诱导Python解释器载入非冻结版本,从而使得调试器能获取到这些模块的源码信息,以提供源码级的调试支持。

下面我们使用PyCharm作为开发环境,以os模块为例,在Python中,os模块属于冻结模块,优先载入的是字节码形式的版本,如下图所示:

`os`模块是冻结模块

os模块是冻结模块

可以看到,在加载冻结版本的情况下,os.py里面的断点是触发不到的:

`os`模块源码级调试失败

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`选项

向Python解释器提供-Xfrozen_modules=off选项

可以看到,现在的os模块属于从源码中载入的了:

`os`模块载入的是源码版本

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_bootstrapimportlib_bootstrap_external始终使用冻结模块版本;而对于第二种方法,通过分析importlib源码可知,其_bootstrap属性是通过导入_frozen_importlib这个不同名的冻结模块实现的,此时其仍存在缓存中。

该模块的调试方法值得另开一篇文章介绍,这里不再赘述。详见如何调试importlib_bootstrap模块一文。

总结

本文介绍了Python中冻结模块的概念,以及如何通过一些tricks,使得PyCharm等IDE支持对冻结模块的源码级调试。这对于我们研究Python内部的运行机制,或者调试一些内部模块的时候提供很大的帮助,嘿嘿~

最后再次强调,以上方法仅供调试使用,不建议在生产环境中使用!

参考资料

  1. Python文档:importlib.machinery.FrozenImporter
  2. Python文档:imp.init_frozen
  3. Python文档:-X选项
  4. Python文档:sys.meta_path
  5. Python Issue: can’t step through _frozen_importlib/importlib._bootstrap using pdb
  6. StackOverflow: importlib._bootstrap and python interpreter initialization
  7. StackOverflow: Trace Python imports