【问题标题】:How does module loading work in CPython?模块加载在 Python 中是如何工作的?
【发布时间】:2014-10-29 22:51:35
【问题描述】:

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

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

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

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

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

【问题讨论】:

  • import system documentation中有很多关于模块加载的信息
  • 这是个好问题;我现在很好奇。如果我的目标不是 10k,我会获得更多的赏金。
  • Python 为任何给定的模块搜索几个不同的名称;您可以在此处阅读有关该过程的部分内容:stackoverflow.com/questions/6319379/…
  • 这是一个很好的链接,但这些都是高级问题,我已经知道了一点。这个问题似乎涉及到较低级别的问题,因为我是一个很少将低级语言用于任何严肃的事情的人,所以我很想了解这些细节,但目前几乎一无所知。
  • @Veedrac 感谢您花时间挖掘源代码的动力。我学到了很多东西,现在正在格式化我几乎输入的庞大答案。

标签: python python-import cpython dynamic-loading python-internals


【解决方案1】:

TLDR 短版本加粗。

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

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

从历史上看,Python 的 C 扩展与 Python 解释器本身是静态链接的。这要求 Python 用户每次想要使用用 C 编写的新模块时都重新编译解释器。正如您可以想象的那样,正如 Guido van Rossum describes,随着社区的发展,这变得不切实际。今天,大多数 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)

这些函数包含针对不同操作系统的不同动态加载机制。在 10.2 之后的 Mac OS 和大多数 Unix(-like) 系统上动态加载的机制是 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}() 函数。 对于编译为名为“spam.so”的共享库的a module named spam,我们可能会提供这个 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 后,模块可能会以其他方式初始化自己,例如添加对象。 (例如:the SpamError object in the Python C API tutorial)。 (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 所必需的第三个也是最后一个关键约定。方法表是一个结构 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 是指向函数的指针的类型定义,该函数返回指向 PyObject 的指针,并将两个指向 PyObjects 的指针作为参数。 作为约定三的引理,在方法表中注册的 C 函数都具有相同的签名。

Python 通过使用一组有限的 C 函数签名来规避动态加载的大部分困难。大多数 C 函数都使用一个签名。 指向带有附加参数的 C 函数的指针可以通过强制转换为 PyCFunction 来“潜入”。 (请参阅Python C API tutorial 中的 keywdarg_parrot 示例。)即使是备份 Python 中不带参数的 Python 函数的 C 函数也会在 C 中使用两个参数(如下所示)。所有函数也应该返回一些东西(可能只是 None 对象)。在 Python 中接受多个位置参数的函数必须从 C 中的单个对象中解压缩这些参数。

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

这里的上下文是,我们正在逐条评估 Python“操作码”,并且我们遇到了一个函数调用操作码。 (请参阅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 结尾。

【讨论】:

  • 如果有人还在读这篇文章...可以肯定地说内置 C 模块是静态链接到 python 二进制文件中,而自定义扩展是动态链接的吗?
  • @SamuelN 您可以通过导入sys 并检查sys.builtin_module_names 来获取静态链接的模块列表。 Python 附带的许多看似基本的模块,例如math,并不是严格意义上的“内置”模块,实际上是动态加载的。您可以通过导入math 并检查math.__file__ 来查看这一点。内置和静态链接的模块是与解释器非常紧密集成的模块,例如sys。 (这个回复有点试探性。我已经有一段时间没有研究这个了,我还在仔细检查。)。
  • 你是对的!打印math.__file__ 时,我得到一个.so 文件。奇怪,但我注意到 math 模块对象在 Ubuntu 的默认 Python 安装中没有 __file__ 属性,但它确实来自 Python 3 env;也许该二进制文件是使用静态链接的math 构建的,而 Python 3 env 是动态链接的。感谢您回到这个旧线程。
  • @SamuelN cPython 构建系统允许配置静态链接的模块,这样才有意义。
猜你喜欢
  • 1970-01-01
  • 1970-01-01
  • 2011-12-09
  • 2020-01-01
  • 2021-10-31
  • 2019-07-03
  • 2018-09-22
  • 2013-11-10
  • 2020-10-21
相关资源
最近更新 更多