本文最后更新于2024年10月9日,已超过 60 天没有更新,如果文章内容或图片资源失效,请留言反馈,我会及时处理,谢谢!

CPython源码解读:Python 如何导入一个C扩展库(动态链接库)

Motivate

最近接触了很多Python和C混编的知识,了解到Python可以通过import语句像导入纯Python编写的模块那样导入一个C/C++编译出来的动态库(在windows下为.pyd,在linux下为.so后缀),这其中的一些细节让我非常好奇:

1、import导入动态库的过程发生了什么,和纯python库的导入有什么区别?

2、pyd文件是什么,和dll文件有什么区别?

编译C Extension

首先我们准备一个简单的C扩展库,作为后续章节中使用的import对象。

文件 add.c

#include <stdio.h>

double add(double a, double b)
{
    return a + b;
}

一个简单的加法操作,值得注意的是,参数和返回值都是double类型,因为python的float类型是使用C的double实现的。

文件 bind.c

#include <Python.h>

double add(double, double);

# 对add做了一层封装,来实现和python数据结构的转换(boxing、unboxing)
PyObject *PyAdd(PyObject *Py_UNUSED(module), PyObject *args)
{
    double a, b;
    if (!PyArg_ParseTuple(args, "dd", &a, &b))
    {
        return NULL;
    }
    a = add(a, b);
    return Py_BuildValue("d", a);
}

# 定义module中的一个名为add的方法
static PyMethodDef module_methods[] = {
    {.ml_name = "add",
     .ml_meth = (PyCFunction)PyAdd,
     .ml_flags = METH_VARARGS,
     .ml_doc = "an add function"},
    {NULL, NULL, 0, NULL}};

# 定义一个module
static PyModuleDef cAdd = {
    PyModuleDef_HEAD_INIT,
    .m_name = "cAdd",
    .m_doc = "a demo program providing add function.",
    .m_size = -1,
    .m_methods = module_methods,
};

# 导出一个PyInit_cAdd符号到动态库的export table,返回一个实例化的module对象
PyMODINIT_FUNC PyInit_cAdd(void)
{
    return PyModule_Create(&cAdd);
}

这里使用CPython项目内的C API(Python.h)封装了上面用C实现的add函数,并将其添加到名为cAdd的包中,将这个包暴露给python。

由于目前我们不清楚python动态库的真面目,先使用一些工具如cmake的Python_add_library 函数为我们编译出需要的动态库 cAdd.cp311-win_amd64.pyd,这里我使用的是3.11版本的python,只要版本低于3.13都是可以的。(3.12引入了子解释器、3.13正在尝试no-GIL构建,这两个改进都围绕GIL问题,12版本的子解释器以前C API中就有,只是暴露给了python前端,13版本去掉了GIL底层变动相当大,所有C API都可能失效,目前python社区不推荐基于13版本构建项目,这是一个实验性版本)

Note:

cp311-win_amd64是一个ABI(Application binary interface)标签,ABI可以类比API理解,一个是二进制层面,一个是源码层面的调用约定,不同ABI间互不兼容,其中cp311表示使用的是3.11版本的cpython API,这里值得注意的是CPython间跨小版本之间的ABI不保证兼容,即cp310未必兼容cp311(这也是pypi中深受诟病的一个点,pypi从源码(source distribution (sdist))构建c extension的成功率很低,导致一个c extension库必须以二进制版本(wheel)分发,每个py小版本,每个平台和设备上都需要单独编译一份,这是python版本的依赖地狱(Dependency Hell)的根源之一),为保证大版本间的兼容性python在PEP 384中提出了SABI(Stable ABI),但是这种兼容性是以牺牲性能为代价的(Stable API无法保证使用当前最高性能的实现,所以并不流行),win_amd64是编译的目标平台和硬件信息,本篇我使用windows平台,对应的ABI完整格式为 .cp{major}{minor}-{platform_tag}.pyd (见Python/dynload_win.c),

调用

现在打开命令行切换到cAdd.cp311-win_amd64.pyd所在路径,打开python交互式命令行(REPL)

(base) PS E:\Laboratory\LoadPyCExt\bin> python
Python 3.11.5 | packaged by Anaconda, Inc. | (main, Sep 11 2023, 13:26:23) [MSC v.1916 64 bit (AMD64)] on win32
Type "help", "copyright", "credits" or "license" for more information.
>>> import cAdd
>>> type(cAdd)
>>> help(cAdd.add)
Help on built-in function add in module cAdd:

add(...)
    an add function

>>> cAdd.add(2,8)
10.0

可以看到,和普通的包没有区别,验证完毕,接下来我们将解剖import cAdd这条指令。

代码走读

本篇博客讨论的内容都是基于CPython项目,CPython是python的官方实现,使用C作为底层语言实现,CPython目前的Import机制主要受到三个PEP的影响:

  1. PEP 302 :把过去Import机制中由C实现的部分转为了Python实现,并添加了很多Hook,这两个操作都为自定义Import机制提供了便利(在此之前只能重写__import__)

  2. PEP 451: 在Python中添加了保存load模块所需要的所有信息的ModuleSpec类型,在代码中会频繁看到数据以spec对象传递的场景(如果没有找到这篇PEP可能会一脸懵逼)。

  3. PEP 420: 引入了“隐式命名空间包”的概念,这允许包在没有 __init__.py 文件的情况下存在。这种机制使得不同的分发包可以在同一个命名空间下共存,从而支持更灵活的项目结构和模块分发

下面我们分为CPython部分和Python部分来分开解读,上面三个PEP都在Python部分体现,在CPython部分我们着重介绍从Import语句定位到python代码实现的过程,也就是找到入口点。

1.CPython部分

CPython解释器(Interpreter)由两部分构成

1.编译器

2.虚拟机(Virtual Machine VM)

一个py文件在第一次执行的时候会被编译器编译为字节码(bytecode)并保存为.pyc文件,然后由虚拟机开启一个循环(evaluation loop),在一个巨大的switch case中逐行执行字节码。

我们可以通过dis模块得到可读(反编译)的字节码

>>> echo "import cAdd" | python -m dis
  0           0 RESUME                   0

  1           2 LOAD_CONST               0 (0)
              4 LOAD_CONST               1 (None)
              6 IMPORT_NAME              0 (cAdd)
              8 STORE_NAME               0 (cAdd)
             10 LOAD_CONST               1 (None)
             12 RETURN_VALUE

由于编译的过程是静态的,这条语句可以在任何地方执行(不一定非要在cAdd模块的目录下),import cAdd对应的字节码指令为

  1           2 LOAD_CONST               0 (0)
              4 LOAD_CONST               1 (None)
              6 IMPORT_NAME              0 (cAdd)
              8 STORE_NAME               0 (cAdd)

LOAD_CONST 0会从代码对象(PyCodeObject)中取出0号位置的值(0)存入栈帧(PyFrameObject) 的栈顶,LOAD_CONST 1同理,执行到IMPORT_NAME时栈中已经有了两个元素,从栈顶向下依次为None、0,接下来我们看看IMPORT_NAME的代码看它到底做了什么。

//....
switch (opcode) {
    //....
    TARGET(IMPORT_NAME) {
        PyObject *name = GETITEM(names, oparg);
        PyObject *fromlist = POP();
        PyObject *level = TOP();
        PyObject *res;
        res = import_name(tstate, frame, name, fromlist, level);
        //....
    }
    //....
}
//....

IMPORT_NAME 0 (cAdd) 指令首先取出 "cAdd"存入name,取出None存入fromlist,0存入level,

Note:

fromlist是 import <> from <fromlist> 中出现在from后面的变量名,是一个Tuple[str]

level为0表示绝对路径导入,import . from <>时level为1,..时level为2,...时level为3 等,level为0时会在sys.path中搜索。

python中的 __import__ 函数也接受这两个变量,__import__(name, globals=None, locals=None, fromlist=(), level=0)

然后额外加入tstate(python线程状态)和frame(栈帧对象)调用了import_name函数。

static PyObject *
import_name(PyThreadState *tstate, _PyInterpreterFrame *frame,
            PyObject *name, PyObject *fromlist, PyObject *level)
{
    // ....
    import_func = _PyDict_GetItemWithError(frame->f_builtins, &_Py_ID(__import__));
    // ....
    if (import_func == tstate->interp->import_func) {
        // ....
        res = PyImport_ImportModuleLevelObject(
                        name,
                        frame->f_globals,
                        locals == NULL ? Py_None :locals,
                        fromlist,
                        ilevel);
        return res;
    }
    // ....
    stack[0] = name;
    stack[1] = frame->f_globals;
    stack[2] = locals == NULL ? Py_None : locals;
    stack[3] = fromlist;
    stack[4] = level;
    res = _PyObject_FastCall(import_func, stack, 5);
    // ....
    return res;
}

(我省略了一些琐碎无聊的内容 如无关的变量定义和处理引用计数的代码,后面默认都会这样处理。)它获取了Python中的__import__函数,如果__import__没有被重写,就会调用PyImport_ImportModuleLevelObject,否则就执行__import__函数。接下来我们看看PyImport_ImportModuleLevelObject的代码细节。

PyObject *
PyImport_ImportModuleLevelObject(PyObject *name, PyObject *globals,
                                 PyObject *locals, PyObject *fromlist,
                                 int level)
{
    // 参数检查
    mod = import_get_module(tstate, abs_name);
    // ....
    if (mod != NULL && mod != Py_None) {
        // 检查包是否已经初始化
    }
    else {
        // ....
        mod = import_find_and_load(tstate, abs_name);
        // ....
    }
    // 处理fromlist
    return final_mod;
}

它首先调用import_get_module在已经加载过的包中搜索(import_get_module其实是在python的sys.module中搜索的,sys.module是一个字典,缓存所有已经加载过的包,并且允许修改),如果已经存在就直接返回,如果没有就执行import_find_and_load去搜索包。(目前我们先只关注14行即import <>的处理逻辑,忽略fromlist的处理逻辑,否则会过于复杂)

static PyObject *
import_find_and_load(PyThreadState *tstate, PyObject *abs_name)
{
    // ....
    // 广播pyaudit事件
    PyObject *sys_path = PySys_GetObject("path");
    PyObject *sys_meta_path = PySys_GetObject("meta_path");
    PyObject *sys_path_hooks = PySys_GetObject("path_hooks");
    if (_PySys_Audit(tstate, "import", "OOOOO",
                     abs_name, Py_None,
                     sys_path ? sys_path : Py_None,
                     sys_meta_path ? sys_meta_path : Py_None,
                     sys_path_hooks ? sys_path_hooks : Py_None) < 0) {
        return NULL;
    }
    // ....
    mod = PyObject_CallMethodObjArgs(interp->importlib, &_Py_ID(_find_and_load),
                                     abs_name, interp->import_func, NULL);
    // ....
    return mod;
}

import_find_and_load首先广播了一个pyaudit事件,这是有关import机制的Hook之一,我们可以在python中捕获这个事件,通过sys.addaudithook函数,

>>> def import_hook(event, args):
...     if event == 'import':
...             print('trigger import event:', args)
...
>>> import sys
>>> sys.addaudithook(import_hook)
>>> import demo # 一个不存在的库
trigger import event: ('demo', None, [..<sys_path省略>..], [..<sys_meta_path省略>..], [..<sys_path_hooks省略>..])
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
ModuleNotFoundError: No module named 'demo'
>>>

import_hook函数中event的参数是事件名,args对应 import_find_and_load中10~13行所定义的。不过在PEP 302 之后有一套可定制性更强的Hook机制,后面细说。

发送完pyaudit事件之后,import_find_and_load会调用python中的importlib._bootstrap._find_and_load ,把

  1. abs_name 模块名
  2. interp->import_func 即 Python的(PyRuntime的) __import__函数 作为参数。

接下来的部分都由python代码实现。

Note:

PyImportImportModuleLevelObject和 __import\_的功能上几乎没有差别,这是一种快捷通道,import语句最多的场景是导入一个内置模块或者重复导入一个内置/扩展模块,这部分用C实现可以提速。

2.Python部分

1.meta_path hooks

python部分的入口从importlib._bootstrap._find_and_load 开始(在没有重写__import__函数的情况下)

_NEEDS_LOADING = object()

def _find_and_load(name, import_):
    """Find and load the module."""

    # Optimization: we avoid unneeded module locking if the module
    # already exists in sys.modules and is fully initialized.
    module = sys.modules.get(name, _NEEDS_LOADING)
    if (module is _NEEDS_LOADING or
        getattr(getattr(module, "__spec__", None), "_initializing", False)):
        with _ModuleLockManager(name):
            module = sys.modules.get(name, _NEEDS_LOADING)
            if module is _NEEDS_LOADING:
                return _find_and_load_unlocked(name, import_)

        # Optimization: only call _bootstrap._lock_unlock_module() if
        # module.__spec__._initializing is True.
        # NOTE: because of this, initializing must be set *before*
        # putting the new module in sys.modules.
        _lock_unlock_module(name)

    if module is None:
        message = ('import of {} halted; '
                   'None in sys.modules'.format(name))
        raise ModuleNotFoundError(message, name=name)

    return module

首先也是判断sys.modules是否已经缓存了,但是缓存过也不代表可以用,有两种情况不能用,

  1. 缓存过但是模块有问题在初始化时失败了 即module.__spec__._initializing为False,
  2. 缓存的值为None,

如果缓存过且能用,就会直接return出去,不能用将分别进入两个最外层的if处理。

如果没有缓存过则进入第一个if中的_find_and_load_unlocked函数。

Note:

_ModuleLockManager 会给模块上锁,用于检测导入包时死锁的情况并抛出异常,注意死锁不是循环导入python是支持循环导入的。

接下来进入到_find_and_load_unlocked ,这个函数几乎包含了完整的模块导入流程的处理。

def _find_and_load_unlocked(name, import_):
    # ....
    parent = name.rpartition('.')[0]
    # ....
    if parent:
        if parent not in sys.modules:
            _call_with_frames_removed(import_, parent)
        # Crazy side-effects!
        if name in sys.modules:
            return sys.modules[name]
        parent_module = sys.modules[parent]
        # ....
        path = parent_module.__path__
        # ....
    spec = _find_spec(name, path)
    if spec is None:
        # ....
    else:
        # 循环导入相关代码 ....
        module = _load_unlocked(spec)
        # 循环导入相关代码 ....

    # 处理模块父子关系

    return module

首先第一个分支 if parent处理带点的 import语句 如, import A.B.C ,先取到A,检查是否在sys.modules中,如果不在就先去导入A(对应代码第7行,import_其实是CPython中传递过来的 __import__ 函数, _call_with_frames_removed(import_, parent) 相当于import_(parent)), 导入父模块后,如果是传统的模块即带有__init__.py的模块通常会把子模块也全导入进来,因此9行需要再次检查 sys.modules中是否包含当前模块,如果不包含就需要去找到子模块,这时候要找的范围就非常明确,就是parent模块的子目录,这正是13行的意思。

然而在我们的场景中 import cAdd并没有父模块,会直接跳过第一个if,进入15行_find_spec函数中,此时path参数为None(None在后面某个函数中会被解析为sys.path,如果是从if parent分支下来,path就是parent包的子目录),_find_spec才是去搜索包的逻辑,找到包后他会返回一个ModuleSpec对象,这是PEP 451 引入的一个对象,包含后续用于load包的所有信息。

在获取到ModuleSpec对象后,20行 利用其中的信息完成模块加载,记住这个_load_unlocked函数我们看完找包的逻辑后会回来看这个。

先来看看_find_spec函数

def _find_spec(name, path, target=None):
    """Find a module's spec."""
    meta_path = sys.meta_path
    # ....
    for finder in meta_path:
        with _ImportLockContext():
            # ....
            find_spec = finder.find_spec
            # ....
            spec = find_spec(name, path, target)

        if spec is not None:
            # return一个spec
    else:
        return None

它的核心逻辑是在sys.meta_path中遍历所有的finder,调用它们的find_spec方法找包,注意此时的传参

  • name = 模块名
  • path = None
  • target = None

其实这就是PEP 302中提出的hook之一。

>>> import sys
>>> sys.meta_path
[<class '_frozen_importlib.BuiltinImporter'>, 
<class '_frozen_importlib.FrozenImporter'>, 
<class '_frozen_importlib_external.PathFinder'>]

meta_path中有三个类型:

  • BuiltinImporter:用于寻找built-in modules,内置在解释器中用C实现的模块,如 sys, math ....。
  • FrozenImporter:用于寻找frozen modules,内置在解释器中用Python实现的模块,如 os, io ....。
  • PathFinder: 用于寻找所有 扩展modules。

如果你想扩展搜索逻辑就可以在这里加一个Finder

Note:

finder、loader和importer,是python中的术语,实现了find_spec方法类就是finder,实现了create_module或exec_module的是loader,如果一个类实现了两种,就叫做importer

2.path_hooks hooks

接下来进入到PathFinder.find_spec中, 在PathFinder.find_spec中会包含 PEP 420 隐式命名空间包 的逻辑,简单起见,我会隐藏掉这部分代码。

    @classmethod
    def find_spec(cls, fullname, path=None, target=None):
        """Try to find a spec for 'fullname' on sys.path or 'path'.

        The search is based on sys.path_hooks and sys.path_importer_cache.
        """
        if path is None:
            path = sys.path
        spec = cls._get_spec(fullname, path, target)
        if spec is None:
            return None
        elif spec.loader is None:
            # 处理命名空间包
        else:
            return spec        

它没有实质性的处理过程,当path为None时把path改为sys.path后就交给_get_spec函数了。

    @classmethod
    def _get_spec(cls, fullname, path, target=None):
        # 处理命名空间包
        for entry in path:
            if not isinstance(entry, str):
                continue
            finder = cls._path_importer_cache(entry)
            if finder is not None:
                if hasattr(finder, 'find_spec'):
                    spec = finder.find_spec(fullname, target)
                else:
                    # 兼容上个版本的代码
                if spec is None:
                    continue
                if spec.loader is not None:
                    return spec
                # 处理命名空间包
        else:
            # 处理命名空间包
            return spec

     @classmethod
    def _path_importer_cache(cls, path):
        # ....
        try:
            finder = sys.path_importer_cache[path]
        except KeyError:
            finder = cls._path_hooks(path)
            sys.path_importer_cache[path] = finder
        return finder

    @staticmethod
    def _path_hooks(path):
        """Search sys.path_hooks for a finder for 'path'."""
        # ....
        for hook in sys.path_hooks:
            try:
                return hook(path)
            except ImportError:
                continue
        else:
            return None

_get_spec函数遍历每个path,跳过非字符串对象(5行),对于每个entry都会遍历sys.path_hooks列表,调用其中的每一个对象(其中的对象如果可以处理该entry,就会返回一个finder,否则返回ImportError),尝试寻找可以处理该entry的finder(37\~41行),如果找到则缓存到sys.path_importer_cache中,以entry为键,并返回该finder(28\~30行),_get_spec在获取到finder后,调用finder的find_spec来在当前entry中搜索包(10行),如果找到就会返回ModuleSpec对象,并停止对其他entry的寻找。

Note:

需要注意的是,上面所说的finder和meta_path中的finder是不一样的,这里的被成为Path Entry Finder,meta_path中使用的被称为Meta Path Finder,Path Entry Finder只能在PathFinder中使用。

由此可见,sys.path_hooks是第二个hook,如果要添加对特定路径的搜索就在sys.path_hooks中加一项。

>>> sys.path_hooks
[<class 'zipimport.zipimporter'>, 
 <function FileFinder.path_hook.<locals>.path_hook_for_FileFinder at 0x0000023E87A702C0>]
  • zipimporter:专门处理zip文件,对他来说合理的路径为 '/tmp/myimport.zip' 或 '/tmp/myimport.zip/mydirectory'
  • FileFinder: 寻找py文件,C扩展或者pyc字节码文件,对他来说合理的路径为 任何目录类型的路径

到这里我们我们已经找到了最底层的负责找包的打工仔。

现在来到FileFinder

 class FileFinder:
    def __init__(self, path, *loader_details):
        """Initialize with the path to search on and a variable number of
        2-tuples containing the loader and the file suffixes the loader
        recognizes."""
        loaders = []
        for loader, suffixes in loader_details:
            loaders.extend((suffix, loader) for suffix in suffixes)
        self._loaders = loaders
        # Base (directory) path
        if not path or path == '.':
            self.path = _os.getcwd()
        elif not _path_isabs(path):
            self.path = _path_join(_os.getcwd(), path)
        else:
            self.path = path
        # ....

    # ....

__init__函数 可以看到,它需要一个path,以及一个loader_details可变参数,其中path可以使用相对路径或绝对路径,loader_details是一个包含多个(loader, suffixes) 的Tuple,其中suffixes也是一个Tuple[str],其实就是一个loader和它所能支持的所有后缀打包成一个元组,在cpython/Lib/importlib/bootstrap_external.py的结尾处可以看到,_get_supported_file_loaders函数的返回值就是loader_details,FileFinder需要寻找的文件类型以及每个文件对应的loader都在此处定义。

 class FileFinder:
def _get_supported_file_loaders():
    """Returns a list of file-based module loaders.

    Each item is a tuple (loader, suffixes).
    """
    extensions = ExtensionFileLoader, _imp.extension_suffixes()
    source = SourceFileLoader, SOURCE_SUFFIXES
    bytecode = SourcelessFileLoader, BYTECODE_SUFFIXES
    return [extensions, source, bytecode]
# ....
def _install(_bootstrap_module):
    """Install the path-based import components."""
    # ....
    supported_loaders = _get_supported_file_loaders()
    sys.path_hooks.extend([FileFinder.path_hook(*supported_loaders)])
    sys.meta_path.append(PathFinder)
类型 loader 后缀
C扩展 ExtensionFileLoader ['.cp311-win_amd64.pyd', '.pyd']
源码 SourceFileLoader ['.py']
字节码 SourcelessFileLoader ['.pyc']

FileFinder.find_spec 负责在self.path目录下寻找指定名字的包。

    def find_spec(self, fullname, target=None):
        """Try to find a spec for the specified module.

        Returns the matching spec, or None if not found.
        """
        # ....
        tail_module = fullname.rpartition('.')[2]
        # ....
        # Check if the module is the name of a directory (and thus a package).
        if cache_module in cache:
            base_path = _path_join(self.path, tail_module)
            for suffix, loader_class in self._loaders:
                init_filename = '__init__' + suffix
                full_path = _path_join(base_path, init_filename)
                if _path_isfile(full_path):
                    return self._get_spec(loader_class, fullname, full_path, [base_path], target)
            else:
                # 处理命名空间包
        # Check for a file w/ a proper suffix exists.
        for suffix, loader_class in self._loaders:
            # ....
            full_path = _path_join(self.path, tail_module + suffix)
            # ....
            if cache_module + suffix in cache:
                if _path_isfile(full_path):
                    return self._get_spec(loader_class, fullname, full_path,
                                          None, target)
        if is_namespace:
            # 处理命名空间包
        return None
    # ....
    def _get_spec(self, loader_class, fullname, path, smsl, target):
        loader = loader_class(fullname, path)
        return spec_from_file_location(fullname, path, loader=loader,
                                       submodule_search_locations=smsl)
    # ....

找包的逻辑非常简单,首先,有几个变量需要解释,

  • tail_module是模块最末尾的(如果有点号分隔的话)名字,如A.B.C中的C
  • cache_module是当前tail_module的小写形式(为了在大小写敏感的系统中实现包名和文件后缀大小写不敏感的效果),
  • cache是一个当前目录(self.path目录)下所有文件或目录组成的列表,也做了大小写敏感的相关处理。

如果第一个if分支处为True,即模块是一个package(因为cache_module是不带任何后缀的,这种匹配只有目录能配上),就会去找 __init__.pyd, __init__.py, __init__.pyc 是否存在,如果存在就使用_get_spec构建一个ModuleSpec对象返回,ModuleSpec对象中包含后续load包所需要的一切信息(10~16行),如果不存在就走命名空间包的逻辑,此处省略

如果能执行到20行,说明模块是一个module或者命令空间包,那么就会去找<模块名>.pyd, <模块名>.py, <模块名>.pyc是否存在,如果存在就使用_get_spec构建一个ModuleSpec对象返回(20~27行)

Note:

Python中的package和module的区别非常小,有__path__属性的module就是package,类比到文件系统package相当于一个目录,包含多个module或者子package

关于import找包的过程,可以通过打开python的verbose模式观察到,在python指令后加上-v就能开启,不过import的信息需要verbosity=2,用python -vv可以实现,或者设置一个全局变量PYTHONVERBOSE="2",开启后

>>> import cAdd
# trying E:\Laboratory\LoadPyCExt\bin\cAdd.cp311-win_amd64.pyd
# extension module 'cAdd' loaded from 'E:\\Laboratory\\LoadPyCExt\\bin\\cAdd.cp311-win_amd64.pyd'
# extension module 'cAdd' executed from 'E:\\Laboratory\\LoadPyCExt\\bin\\cAdd.cp311-win_amd64.pyd'
import 'cAdd' # <_frozen_importlib_external.ExtensionFileLoader object at 0x000001E99841D410>

知道了FileFinder的用法,现在可以尝试使用它来找包

>>> import importlib
>>> loader = importlib.machinery.ExtensionFileLoader
>>> finder = importlib.machinery.FileFinder('.', (loader, (".cp311-win_amd64.pyd",)))
>>> finder.find_spec("cAdd")
ModuleSpec(name='cAdd', loader=<_frozen_importlib_external.ExtensionFileLoader object at 0x00000243908E5510>, origin='E:\\Laboratory\\LoadPyCExt\\bin\\cAdd.cp311-win_amd64.pyd')
>>>

到这里我们已经解析完了找包的逻辑,拿到ModuleSpec后,接下来需要load包,我们得先回到_find_and_load_unlocked函数

def _find_and_load_unlocked(name, import_):
    # ....
    parent = name.rpartition('.')[0]
    # ....
    if parent:
        if parent not in sys.modules:
            _call_with_frames_removed(import_, parent)
        # Crazy side-effects!
        if name in sys.modules:
            return sys.modules[name]
        parent_module = sys.modules[parent]
        # ....
        path = parent_module.__path__
        # ....
    spec = _find_spec(name, path)
    if spec is None:
        # ....
    else:
        # 循环导入相关代码 ....
        module = _load_unlocked(spec)
        # 循环导入相关代码 ....

    # 处理模块父子关系

    return module

目前我们已经了解到第15行,获取了spec,接下来回到_load_unlocked看看spec是如何被用于load包的。

def _load_unlocked(spec):
    # 兼容性代码 ....

    module = module_from_spec(spec)

    # This must be done before putting the module in sys.modules
    # (otherwise an optimization shortcut in import.c becomes
    # wrong).
    spec._initializing = True
    try:
        sys.modules[spec.name] = module
        try:
            # 命名空间包的spec.loader一定为None
            if spec.loader is None:
                # 命名空间包的逻辑
            else:
                spec.loader.exec_module(module)
        except:
            try:
                del sys.modules[spec.name]
            except KeyError:
                pass
            raise
        # Move the module to the end of sys.modules.
        # We don't ensure that the import-related module attributes get
        # set in the sys.modules replacement case.  Such modules are on
        # their own.
        module = sys.modules.pop(spec.name)
        sys.modules[spec.name] = module
        _verbose_message('import {!r} # {!r}', spec.name, spec.loader)
    finally:
        spec._initializing = False

    return module

C扩展包在4行就算load完成了,下面的代码是执行包的内容如 __init__.py,或者<包名>.py文件,会将它们编译然后用虚拟机执行,有趣的是9行和11行,在模块内容尚未被执行的时候就将模块添加到sys.modules中并设置spec._initializing为True,正是这个操作实现了python循环导包的逻辑。

如果有两个文件

a.py

import b
x = 10

b.py

import a

然后在解释器中导入a。

>>> import a
>>> 

没有任何问题,因为当我们用exec_module执行a.py中的代码,在开始执行import b之前,a就已经被添加到了sys.modules中(是一个空包),所以import b中执行import a的时候不会报错。

但是如果把b.py改为

import a
print(a.x)

在解释器中导入a

>>> import a
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "E:\Laboratory\LoadPyCExt\a.py", line 1, in <module>
    import b
  File "E:\Laboratory\LoadPyCExt\b.py", line 2, in <module>
    print(a.x)
          ^^^
AttributeError: partially initialized module 'a' has no attribute 'x' (most likely due to a circular import)
>>>

就会报错,因为a.py只是一个空包,还没执行完成,他压根没有x这个变量。

现在关于第一个问题 import导入动态库的过程发生了什么,和纯python库的导入有什么区别? 我相信大家已经非常清楚了。现在我们继续探索第二个问题。

第二个问题的答案得聚焦到_load_unlocked函数的第4行 module = module_from_spec(spec),在module_from_spec中调用了loader的create_module方法来创建module,当我们导入C扩展时,这个loader就是ExtensionFileLoader,所以答案就在ExtensionFileLoader的create_module方法中

class ExtensionFileLoader(FileLoader, _LoaderBasics):
    # ....
    def create_module(self, spec):
        """Create an uninitialized extension module"""
        module = _bootstrap._call_with_frames_removed(
            _imp.create_dynamic, spec)
        _bootstrap._verbose_message('extension module {!r} loaded from {!r}',
                         spec.name, self.path)
        return module
    # ....

然而它是调用 _imp.create_dynamic(spec)实现的,而_imp.create_dynamic由C接口实现,后面还有很多调用嵌套,由于篇幅问题,在此我直接给出路径,_imp.create_dynamic -> _imp_create_dynamic_impl -> _PyImport_LoadDynamicModuleWithSpec -> _PyImport_FindSharedFuncptrWindows,最终在_PyImport_FindSharedFuncptrWindows中找到了答案。

dl_funcptr _PyImport_FindSharedFuncptrWindows(const char *prefix,
                                              const char *shortname,
                                              PyObject *pathname, FILE *fp)
{
    // ....

    Py_BEGIN_ALLOW_THREADS
    hDLL = LoadLibraryExW(wpathname, NULL,
                          LOAD_LIBRARY_SEARCH_DEFAULT_DIRS |
                          LOAD_LIBRARY_SEARCH_DLL_LOAD_DIR);
    Py_END_ALLOW_THREADS

    // ....

    Py_BEGIN_ALLOW_THREADS
    p = GetProcAddress(hDLL, funcname);
    Py_END_ALLOW_THREADS

    return p;
}

pyd文件使用LoadLibraryExW导入,使用GetProcAddress执行后产生PyModuleObject对象(即PyRuntime中的module类型),而这个接口正是Run-Time Dynamic Linking的用法详见 ,由此可以得出pyd实际上是dll,只是改了个名字。因此以后在构建pyd文件时,我们只需创建一个普通的动态库项目,然后修改后缀即可。

Note:

动态链接库有两种调用方式:

  1. load-time dynamic link : 就是我们常见的,生成动态库常常带有一个.lib文件,这种方式需要把.lib文件编译进调用者中,在编译时就确定可执行文件的信息。如果python用这种方式,那么所有c扩展库都需要和解释器编译一次,修改解释器,这显然是不显示的。
  2. run-time dynamic link:这种方式在运行时使用 LoadLibraryLoadLibraryExLoadLibraryExW ....加载库到内存中,且仍然保持共享库的特点,多个程序调用内存中也只有一份,然后使用 GetProcAddress 执行。

以下是验证代码,完整代码在此处获取

#include <Python.h>
#include <windows.h>
typedef FARPROC dl_funcptr;
typedef PyObject *(*PyModInitFunction)(void);

#define MODULE_NAME "cAdd"

int main()
{
    Py_Initialize();
    PyObject *module = NULL;
    PyObject *dic = NULL;
    PyObject *add = NULL;
    PyObject *args = NULL;
    PyObject *ret = NULL;
    PyObject *s_main = NULL;
    PyObject *py_main = NULL;

    char *full_path = MYLIBPATH "/" MODULE_NAME Python_SOABI;
    HINSTANCE hDLL = LoadLibraryEx(full_path, NULL, LOAD_WITH_ALTERED_SEARCH_PATH);
    if (hDLL == NULL)
    {
        printf("dll not find!\n");
        goto final;
    }
    char *funcname = "PyInit_" MODULE_NAME;
    dl_funcptr p = GetProcAddress(hDLL, funcname);
    if (p == NULL)
    {
        printf("function not find!\n");
        goto final;
    }
    module = ((PyModInitFunction)p)();
    // printf("type m is %s\n", Py_TYPE(m)->tp_name);
    // PyObject_Print(m, stdout, 0);
    dic = PyModule_GetDict(module);
    // PyObject_Print(dic, stdout, 0);
    add = PyObject_GetAttrString(module, "add");
    args = Py_BuildValue("(dd)", 2.f, 8.f);
    fprintf(stdout, "call in c  : 2 + 8 = ");
    ret = PyObject_CallObject(add, args);
    PyObject_Print(ret, stdout, 0);
    fprintf(stdout, "\n");

    s_main = PyUnicode_FromString("__main__");
    py_main = PyImport_GetModule(s_main);
    PyObject_SetAttrString(py_main, "cAdd", module);
    if (!PyRun_SimpleString("print('call in python: 2 + 9 = ', cAdd.add(2,9))"))
    {
        PyErr_Print();
        goto final;
    }
    fprintf(stdout, "\n");
final:
    Py_XDECREF(module);
    Py_XDECREF(dic);
    Py_XDECREF(add);
    Py_XDECREF(args);
    Py_XDECREF(ret);
    Py_XDECREF(s_main);
    Py_XDECREF(py_main);
    Py_Finalize();
    return 0;
}

参考

https://github.com/python/cpython/blob/v3.11.0

Expectations that projects provide ever more wheels - pypackaging-native

Run-Time Dynamic Linking - Win32 apps | Microsoft Learn

Load-Time Dynamic Linking - Win32 apps | Microsoft Learn

PEP 302 – New Import Hooks | peps.python.org

PEP 420 – Implicit Namespace Packages | peps.python.org

PEP 451 – A ModuleSpec Type for the Import System | peps.python.org

PEP 384 – Defining a Stable ABI | peps.python.org


有帮助的话请打个赏吧!