CPython中的模块加载是如何工作的?
在CPython中,模块加载是怎么运作的?特别是,C语言编写的扩展是如何动态加载的?我可以在哪里学习这些内容?
我觉得源代码看起来有点复杂。我知道在支持的系统上使用了老牌的dlopen()
等函数,但如果没有整体的理解,从源代码中搞明白这一切会花费很长时间。
关于这个话题可以写很多东西,但据我所知,几乎没有人写过。网上关于Python语言本身的网页太多了,这让人很难找到相关信息。一个好的回答应该提供一个相对简洁的概述,并推荐一些我可以深入学习的资源。
我主要关心的是在类Unix系统上是如何工作的,因为我对这些系统比较熟悉,但我也想知道其他地方的过程是否相似。
更具体一点(但也可能假设太多),CPython是如何利用模块方法表和初始化函数来“理解”动态加载的C代码的?
1 个回答
简短版本在这里加粗。
本文提到的Python源代码是基于2.7.6版本。
Python通过动态加载大多数用C语言编写的扩展。 动态加载是一个比较复杂的话题,文档不多,但这是绝对必要的。在解释Python是如何使用动态加载之前,我必须简要说明一下什么是动态加载以及为什么Python要使用它。
历史上,Python的C扩展是静态链接到Python解释器本身的。这意味着每次想用新的C模块时,Python用户都需要重新编译解释器。可以想象,随着社区的发展,这变得非常不方便。如今,大多数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(类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}()函数。 对于一个名为spam的模块,编译为名为“spam.so”的共享库,我们可能提供这个initspam()函数:
PyMODINIT_FUNC
initspam(void)
{
PyObject *m;
m = Py_InitModule("spam", SpamMethods);
if (m == NULL)
return;
}
如果初始化函数的名称与文件名不匹配,Python解释器就无法知道如何找到它。例如,将spam.so重命名为notspam.so并尝试导入会得到以下结果。
>>> import spam
ImportError: No module named spam
>>> import notspam
ImportError: dynamic module does not define init function (initnotspam)
如果违反命名约定,就无法确定共享库是否包含初始化函数。
第二个关键约定是,一旦被调用,初始化函数负责通过调用Py_InitModule来初始化自己。 这个调用将模块添加到解释器维护的“字典”/哈希表中,该表将模块名映射到模块数据。它还在方法表中注册C函数。在调用Py_InitModule之后,模块可以通过其他方式初始化自己,例如添加对象。(例如:Python C API教程中的SpamError对象)。(Py_InitModule实际上是一个宏,它创建真实的初始化调用,但带有一些信息,比如我们编译的C扩展使用了哪个版本的Python。)
如果初始化函数的名称正确,但没有调用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指针的函数的指针类型,它接受两个PyObject指针作为参数。作为第三个约定的补充,注册到方法表的C函数都具有相同的签名。
Python通过使用有限的C函数签名,绕过了动态加载中的许多困难。特别是大多数C函数使用的是一种签名。 需要额外参数的C函数可以通过转换为PyCFunction“偷偷”传入。(请参见Python C API教程中的keywdarg_parrot示例。)即使是那些在Python中不带参数的Python函数,在C中也会接受两个参数(如下所示)。所有函数也都期望返回某种东西(这可能只是None对象)。在Python中接受多个位置参数的函数必须在C中从一个对象中解包这些参数。
这就是如何获取和存储与动态加载C函数接口的数据。最后,这里有一个示例,展示了这些数据是如何使用的。
这里的背景是我们正在逐条评估Python的“操作码”,并且我们遇到了一个函数调用的操作码。(见https://docs.python.org/2/library/dis.html,值得一看。)我们已经确定Python函数对象是由C函数支持的。在下面的代码中,我们检查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参数。在底部,你可以看到