模块加载如何在CPython中运行?

Pra*_*tic 13 python cpython dynamic-loading python-import python-internals

模块加载如何在CPython下工作?特别是,用C编写的扩展的动态加载如何工作?我在哪里可以了解到这一点?

我发现源代码本身相当压倒性.我可以看到,可靠的ol' dlopen()和朋友用在支持它的系统上但没有任何大局观,从源代码中解决这个问题需要很长时间.

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

我最关心的是它是如何在类Unix系统上运行的,因为这就是我所知道的但我感兴趣的是如果这个过程在其他地方类似.

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

Pra*_*tic 17

TLDR短版加粗.

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

Python通过动态加载导入大多数用C编写的扩展.动态加载是一个深奥的主题,没有详细记录,但它是绝对的先决条件.在解释如何 Python使用它,我必须简单介绍一下它是什么为什么 Python使用它.

从历史上看,Python的C扩展与Python解释器本身静态链接.这需要Python用户每次想要使用C语言编写的新模块时重新编译解释器.正如您可以想象的那样,正如Guido van Rossum所描述的那样,随着社区的发展,这变得不切实际.今天,大多数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() */
Run Code Online (Sandbox Code Playgroud)

文件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");
}
Run Code Online (Sandbox Code Playgroud)

这些文件是单独编译和链接的,但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.
*/
Run Code Online (Sandbox Code Playgroud)

正如所引用的,在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
Run Code Online (Sandbox Code Playgroud)

它们每个都使用此签名定义一个函数:

dl_funcptr _PyImport_GetDynLoadFunc(const char *fqname, const char *shortname,
                                    const char *pathname, FILE *fp)
Run Code Online (Sandbox Code Playgroud)

这些函数包含不同操作系统的不同动态加载机制.在大于10.2和大多数Unix(类似)系统的Mac OS上动态加载的机制是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;
Run Code Online (Sandbox Code Playgroud)

在此之前,Python使用它将查找的函数名来组成字符串.模块名称位于shortname变量中.

 PyOS_snprintf(funcname, sizeof(funcname),
              LEAD_UNDERSCORE "init%.200s", shortname);
Run Code Online (Sandbox Code Playgroud)

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

让我们看一下C扩展必须做什么才能完成上述调用dlsym()的合同.对于已编译的C Python模块,允许Python访问已编译的C代码的第一个约定是init {shared_library_filename}()函数.对于名为spam的模块编译为名为"spam.so"的共享库,我们可能会提供此initspam()函数:

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

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

>>> import spam
ImportError: No module named spam
>>> import notspam
ImportError: dynamic module does not define init function (initnotspam)
Run Code Online (Sandbox Code Playgroud)

如果违反了命名约定,则根本不知道共享库是否包含初始化函数.

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

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

SystemError: dynamic module not initialized properly
Run Code Online (Sandbox Code Playgroud)

我们的方法表恰好被称为SpamMethods,看起来像这样.

static PyMethodDef SpamMethods[] = {
    {"system", spam_system, METH_VARARGS,
     "Execute a shell command."},
    {NULL, NULL, 0, NULL}
};
Run Code Online (Sandbox Code Playgroud)

它需要的方法表本身和函数签名契约是 Python理解动态加载C所必需的第三个也是最后一个键约定.方法表是一个带有最终sentinel条目的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 */
};
Run Code Online (Sandbox Code Playgroud)

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

typedef PyObject *(*PyCFunction)(PyObject *, PyObject *);
Run Code Online (Sandbox Code Playgroud)

PyCFunction是一个指向函数的指针的typedef,该函数返回一个指向PyObject的指针,并为参数提供两个指向PyObjects的指针.作为约定3的引理,在方法表中注册的C函数都具有相同的签名.

Python通过使用一组有限的C函数签名来避免动态加载的大部分困难.特别是一个签名用于大多数C函数.带有附加参数的C函数指针可以通过强制转换为PyCFunction来"悄悄进入".(请参阅Python C API教程中的keywdarg_parrot示例.)即使是在Python中不支持参数的Python函数的C函数,也会在C中使用两个参数(如下所示).期望所有函数都返回一些东西(可能只是None对象).在Python中采用多个位置参数的函数必须从C中的单个对象解包这些参数.

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

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

蟒蛇/ 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));
    }
Run Code Online (Sandbox Code Playgroud)

它当然采用C中的参数 - 正好是两个.因为一切都是Python中的一个对象,所以它得到一个自我论证.在底部,您可以看到meth为其分配了一个函数指针,然后将其解除引用并调用.返回值以x结尾.

  • +1这是一个非常彻底的答案. (2认同)