CPython 中的模块加载是如何工作的?

2023-12-30

CPython 中的模块加载在幕后是如何工作的?特别是,用 C 语言编写的扩展的动态加载是如何工作的?我可以在哪里了解这方面的信息?

我发现源代码本身相当令人难以承受。我可以看到那个值得信赖的人dlopen()和朋友在支持它的系统上使用,但没有任何大局意识,需要很长时间才能从源代码中弄清楚这一点。

关于这个主题可以写出大量的文章,但据我所知,几乎没有任何内容——描述 Python 语言本身的大量网页使得搜索变得困难。一个很好的答案将提供相当简短的概述和我可以了解更多信息的资源参考。

我最关心的是它如何在类 Unix 系统上工作,因为这是我所知道的,但我感兴趣的是这个过程在其他地方是否相似。

更具体地说(但也有假设太多的风险),CPython 如何使用模块方法表和初始化函数来“理解”动态加载的 C?


TLDR 短版加粗。

对Python源代码的引用基于版本2.7.6。

Python 通过动态加载导入大多数用 C 编写的扩展。动态加载是一个深奥的主题,没有很好的文档记录,但它是绝对的先决条件。在解释之前howPython使用它,我必须简单解释一下这是什么 and whyPython 使用它。

历史上,Python 的 C 扩展是与 Python 解释器本身静态链接的。这要求 Python 用户每次想要使用用 C 编写的新模块时都重新编译解释器。 正如您可以想象的那样,吉多·范·罗森描述 http://python-history.blogspot.com/2009/03/dynamically-loaded-modules.html,随着社区的发展,这变得不切实际。如今,大多数 Python 用户从未编译过解释器一次。我们只需“pip install module”,然后“import module”,即使该模块包含已编译的 C 代码。

链接使我们能够跨已编译的代码单元进行函数调用。动态加载解决了在运行时决定链接内容时链接代码的问题。也就是说,它允许正在运行的程序与链接器交互并告诉链接器它想要链接什么。对于 Python 解释器来说,需要使用 C 代码导入模块。编写在运行时做出此决定的代码非常罕见,大多数程序员都会惊讶于这是可能的。简单地说,C 函数有一个地址,它希望您将某些数据放在某些位置,并且它承诺在返回时将某些数据放在某些位置。如果你知道秘密握手,你就可以调用它。

动态加载的挑战在于,程序员有责任正确地进行握手,并且没有安全检查。至少,它们没有提供给我们。通常,如果我们尝试调用具有不正确签名的函数名,我们会收到编译或链接器错误。通过动态加载,我们在运行时通过名称(“符号”)向链接器请求函数。链接器可以告诉我们是否找到了该名称,但它不能告诉我们如何调用该函数。它只是给了我们一个地址——一个空指针。我们可以尝试转换为某种函数指针,但这完全取决于程序员是否正确转换。如果我们在转换中发现函数签名错误,那么编译器或链接器警告我们就为时已晚了。当程序失去控制并最终不恰当地访问内存后,我们很可能会遇到段错误。使用动态加载的程序必须依赖于预先安排的约定和在运行时收集的信息来进行正确的函数调用。在我们讨论 Python 解释器之前,先看一个小例子。

文件1:main.c

/* gcc-4.8 -o main main -ldl */
#include <dlfcn.h> /* key include, also in Python/dynload_shlib.c */

/* used for cast to pointer to function that takes no args and returns nothing  */
typedef void (say_hi_type)(void);

int main(void) {
    /* get a handle to the shared library dyload1.so */
    void* handle1 = dlopen("./dyload1.so", RTLD_LAZY);

    /* acquire function ptr through string with name, cast to function ptr */
    say_hi_type* say_hi1_ptr = (say_hi_type*)dlsym(handle1, "say_hi1");

    /* dereference pointer and call function */
    (*say_hi1_ptr)();

    return 0;
}
/* error checking normally follows both dlopen() and dlsym() */

文件2:dyload1.c

/* gcc-4.8 -o dyload1.so dyload1.c -shared -fpic */
/* compile as C, C++ does name mangling -- changes function names */
#include <stdio.h>

void say_hi1() {
    puts("dy1: hi");
}

这些文件是单独编译和链接的,但 main.c 知道在运行时寻找 ./dyload1.so。 main 中的代码假设 dyload1.so 将有一个符号“say_hi1”。它使用 dlopen() 获取 dyload1.so 符号的句柄,使用 dlsym() 获取符号的地址,假设它是一个不带参数且不返回任何内容的函数,然后调用它。它无法确切地知道“say_hi1”是什么——事先达成的协议就是阻止我们出现段错误的唯一方法。

我上面展示的是 dlopen() 系列函数。 Python 部署在许多平台上,并非所有平台都提供 dlopen(),但大多数平台都有类似的动态加载机制。Python通过将多个操作系统的动态加载机制包装在一个公共接口中来实现可移植的动态加载。

Python/importdl.c 中的这条注释总结了该策略。

/* ./configure sets HAVE_DYNAMIC_LOADING if dynamic loading of modules is
   supported on this platform. configure will then compile and link in one
   of the dynload_*.c files, as appropriate. We will call a function in
   those modules to get a function pointer to the module's init function.
*/

如前所述,在 Python 2.7.6 中,我们有这些 dynload*.c 文件:

Python/dynload_aix.c     Python/dynload_beos.c    Python/dynload_hpux.c
Python/dynload_os2.c     Python/dynload_stub.c    Python/dynload_atheos.c
Python/dynload_dl.c      Python/dynload_next.c    Python/dynload_shlib.c
Python/dynload_win.c

他们各自定义了一个具有以下签名的函数:

dl_funcptr _PyImport_GetDynLoadFunc(const char *fqname, const char *shortname,
                                    const char *pathname, FILE *fp)

这些函数包含针对不同操作系统的不同动态加载机制。 Mac OS 10.2 之后的版本和大多数 Unix(类似)系统上的动态加载机制是 dlopen(),在 Python/dynload_shlib.c 中调用。

浏览 dynload_win.c,Windows 的类似函数是 LoadLibraryEx()。它的用法看起来非常相似。

在 Python/dynload_shlib.c 的底部,您可以看到对 dlopen() 和 dlsym() 的实际调用。

handle = dlopen(pathname, dlopenflags);
/* error handling */
p = (dl_funcptr) dlsym(handle, funcname);
return p;

在此之前,Python 将其与要查找的函数名称组合成字符串。模块名称位于 Shortname 变量中。

 PyOS_snprintf(funcname, sizeof(funcname),
              LEAD_UNDERSCORE "init%.200s", shortname);

Python 只是希望有一个名为 init{modulename} 的函数,并向链接器询问它。从这里开始,Python 依赖于一小组约定来使 C 代码的动态加载成为可能且可靠。

让我们看看 C 扩展必须执行哪些操作才能履行使上述对 dlsym() 的调用起作用的契约。对于已编译的 C Python 模块,允许 Python 访问已编译的 C 代码的第一个约定是 init{shared_library_filename}() 函数。 For 名为 spam 的模块 https://docs.python.org/2/extending/extending.html编译为名为“spam.so”的共享库,我们可以提供这个 initspam() 函数:

PyMODINIT_FUNC
initspam(void)
{
    PyObject *m;
    m = Py_InitModule("spam", SpamMethods);
    if (m == NULL)
        return;
}

如果 init 函数的名称与文件名不匹配,Python 解释器将无法知道如何找到它。例如,将 spam.so 重命名为 notspam.so 并尝试导入会产生以下结果。

>>> import spam
ImportError: No module named spam
>>> import notspam
ImportError: dynamic module does not define init function (initnotspam)

如果违反命名约定,则根本无法判断共享库是否包含初始化函数。

第二个关键约定是,一旦调用,init 函数负责通过调用 Py_InitModule 来初始化自身。此调用将模块添加到解释器保存的“字典”/哈希表中,将模块名称映射到模块数据。它还在方法表中注册 C 函数。调用 Py_InitModule 后,模块可以通过其他方式初始化自身,例如添加对象。 (前任:Python C API 教程中的 SpamError 对象 https://docs.python.org/2/extending/extending.html)。 (Py_InitModule 实际上是一个宏,它创建真正的 init 调用,但包含一些信息,例如我们编译的 C 扩展使用的 Python 版本。)

如果 init 函数有正确的名称但没有调用 Py_InitModule(),我们会得到:

SystemError: dynamic module not initialized properly

我们的方法表恰好称为 SpamMethods,如下所示。

static PyMethodDef SpamMethods[] = {
    {"system", spam_system, METH_VARARGS,
     "Execute a shell command."},
    {NULL, NULL, 0, NULL}
};

方法表本身及其所涉及的函数签名契约是第三个也是最后一个关键约定Python 理解动态加载的 C 语言所必需的。方法表是一个带有最终哨兵条目的 struct PyMethodDef 数组。 PyMethodDef 在 Include/methodobject.h 中定义如下。

struct PyMethodDef {
    const char  *ml_name;   /* The name of the built-in function/method */
    PyCFunction  ml_meth;   /* The C function that implements it */
    int      ml_flags;  /* Combination of METH_xxx flags, which mostly
                   describe the args expected by the C func */
    const char  *ml_doc;    /* The __doc__ attribute, or NULL */
};

这里的关键部分是第二个成员是 PyCFunction。我们传入了一个函数的地址,那么什么是PyCFunction呢?它是一个 typedef,也在 Include/methodobject.h 中

typedef PyObject *(*PyCFunction)(PyObject *, PyObject *);

PyCFunction 是指向函数的指针的 typedef,该函数返回指向 PyObject 的指针,并采用两个指向 PyObject 的指针作为参数。作为约定三的引理,使用方法表注册的 C 函数都具有相同的签名。

Python 通过使用一组有限的 C 函数签名来规避动态加载的大部分困难。大多数 C 函数都使用一种特殊的签名。指向带有附加参数的 C 函数的指针可以通过转换为 PyCFunction 来“隐藏”。 (参见 keywdarg_parrot 示例Python C API 教程 https://docs.python.org/2/extending/extending.html.)即使是备份 Python 函数的 C 函数(在 Python 中不带参数)也会在 C 中带两个参数(如下所示)。所有函数都应该返回一些东西(可能只是 None 对象)。 Python 中采用多个位置参数的函数必须从 C 中的单个对象中解压这些参数。

这就是与动态加载的 C 函数接口的数据的获取和存储方式。最后,这是一个如何使用该数据的示例。

这里的上下文是,我们正在逐条指令地评估 Python“操作码”,并且我们已经命中了函数调用操作码。 (看https://docs.python.org/2/library/dis.html https://docs.python.org/2/library/dis.html。值得浏览一下。)我们已经确定 Python 函数对象由 C 函数支持。在下面的代码中,我们检查 Python 中的函数是否不带参数(在 Python 中),如果是,则调用它(在 C 中使用两个参数)。

Python/ceval.c。

if (flags & (METH_NOARGS | METH_O)) {
    PyCFunction meth = PyCFunction_GET_FUNCTION(func);
    PyObject *self = PyCFunction_GET_SELF(func);
    if (flags & METH_NOARGS && na == 0) {
        C_TRACE(x, (*meth)(self,NULL));
    }

当然,它确实需要 C 语言的参数——正好两个。由于 Python 中一切都是对象,因此它有一个 self 参数。在底部你可以看到meth分配一个函数指针,然后取消引用并调用该函数指针。返回值以 x 结尾。

本文内容由网友自发贡献,版权归原作者所有,本站不承担相应法律责任。如您发现有涉嫌抄袭侵权的内容,请联系:hwhale#tublm.com(使用前将#替换为@)

CPython 中的模块加载是如何工作的? 的相关文章

随机推荐