之前实现了python module多版本动态加载,但是在最近一次一个小哥上传的模型中居然有对.so文件的import,十万只草泥马,Python居然还有这种操作,所以再一次对Python的import流程做了一次梳理。

python import的核心实现都在_bootstrap.py中,很郁闷的是明明python lib下有_bootstrap.py源码,但是eclipse+pydev就是Debug不进去,无奈只好再开个eclipse(还好有两个显示屏),导入python3.6.3的源码查看。

虽然Eclipse+pydev无法跳入python底层的一些源码,但是debug调试栈还是可以看见的,所以通过单步调试加上下文变量查看窗口,再结合源码,也可以进行跟踪,虽然麻烦点。

先看一下我们要跟踪代项目代码结构:

|- pth_nms.py
|- _ext
       |- __init__.py
       |- nms
             |- __init__.py
             |- _nms.so

我们再大致看一下pth_nms.py_ext.nms.__init__.py的部分代码,有个概念:

# pth_nms.py
from ._ext import nms
def pth_nms(dets, thresh):
    ...
    
# _ext.nms.__init__.py
from torch.utils.ffi import _wrap_function
from ._nms import lib as _lib, ffi as _ffi 

__all__ = []
def _import_symbols(locals):
    for symbol in dir(_lib):
        fn = getattr(_lib, symbol)
        if callable(fn):
            locals[symbol] = _wrap_function(fn, _ffi)
        else:
            locals[symbol] = fn
        __all__.append(symbol)

_import_symbols(locals())

这段代码还是挺有意思的,__init__.py中解析了_nms.so中的方法,然后放入_ext.nms__all__属性中,这样后面想用_nms.so中的方法,可以通过nms这个module进行限定,例如:

from ._ext import nms
nms.cpu_nms(xx,xx,xx) #cpu_nms为_nms.so中的一个方法

在这个例子中我们的重点放在:

from ._ext import nms
from ._nms import lib as _lib, ffi as _ffi 

这两行代码,我们先将断点放在from ._ext import nms上,然后debug一步一步看加载的流程!

Python Import

-------------------------------------- _ext 查找加载 --------------------------------------

_bootstrap._find_and_load()

def _find_and_load(name, import_):
    """Find and load the module."""
    with _ModuleLockManager(name):
        module = sys.modules.get(name, _NEEDS_LOADING)
        if module is _NEEDS_LOADING:
            return _find_and_load_unlocked(name, import_)

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

    _lock_unlock_module(name)
    return module

这里有一个比较有意思的python特性,就是with语法,with语法块会将代码块的进入和退出通知被with的对象,如上面with _ModuleLockManager(name),当进入代码块时,会通知_ModuleLockManager__enter__方法,退出时通知__exit__方法。

_ModuleLockManager是module锁管理对象,使用with特性可以很好进行锁的申请、释放管理。贴一下_ModuleLockManager源码:

class _ModuleLockManager:

    def __init__(self, name):
        self._name = name
        self._lock = None

    def __enter__(self):
        self._lock = _get_module_lock(self._name)
        self._lock.acquire()

    def __exit__(self, *args, **kwargs):
        self._lock.release()

回到正题,继续_find_and_load,该函数接受两个参数,进过跟踪变量值为:

  • name='_ext'
  • import_为内置函数__import__

_find_and_load主要逻辑为从sys.modules查找该名称是否已经存在加载过的module,如果存在则返回,不存在则进行查找和加载;存在且module为None的话。sys.modules缓存了所有已经被加载的module。

_bootstrap._find_and_load_unlocked()

同样先贴出源码:

def _find_and_load_unlocked(name, import_):
    path = None
    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]
        try:
            path = parent_module.__path__
        except AttributeError:
            msg = (_ERR_MSG + '; {!r} is not a package').format(name, parent)
            raise ModuleNotFoundError(msg, name=name) from None
    spec = _find_spec(name, path)
    if spec is None:
        raise ModuleNotFoundError(_ERR_MSG.format(name), name=name)
    else:
        module = _load_unlocked(spec)
    if parent:
        # Set the module as an attribute on its parent.
        parent_module = sys.modules[parent]
        setattr(parent_module, name.rpartition('.')[2], module)
    return module

传入的变量值就是_find_and_load的两个变量,未做任何修改。该方法主要逻辑为:查找该name对应module的父module,然后得到父module的path路径,再根据父module的路径找我们要找的module。找到之后,我们则需要进行module的加载。

也就是说,到这里之后,我们的事情就变成了两件事:

  • 查找
  • 加载

在Python中,查找和加载主要对应Finder(查找器)、Loader(加载器),顺着例子继续看:

Finder

开启查找的第一步:

_bootstrap._find_spec()

def _find_spec(name, path, target=None):
    """Find a module's spec."""
    meta_path = sys.meta_path
    if meta_path is None:
        # PyImport_Cleanup() is running or has been called.
        raise ImportError("sys.meta_path is None, Python is likely "
                          "shutting down")

    if not meta_path:
        _warnings.warn('sys.meta_path is empty', ImportWarning)

    # We check sys.modules here for the reload case.  While a passed-in
    # target will usually indicate a reload there is no guarantee, whereas
    # sys.modules provides one.
    is_reload = name in sys.modules
    for finder in meta_path:
        with _ImportLockContext():
            try:
                find_spec = finder.find_spec
            except AttributeError:
                spec = _find_spec_legacy(finder, name, path)
                if spec is None:
                    continue
            else:
                spec = find_spec(name, path, target)
        if spec is not None:
            # The parent import may have already imported this module.
            if not is_reload and name in sys.modules:
                module = sys.modules[name]
                try:
                    __spec__ = module.__spec__
                except AttributeError:
                    # We use the found spec since that is the one that
                    # we would have used if the parent module hadn't
                    # beaten us to the punch.
                    return spec
                else:
                    if __spec__ is None:
                        return spec
                    else:
                        return __spec__
            else:
                return spec
    else:
        return None

该方法主要逻辑为遍历所有sys.meta_path,通过sys.meta_path中的定义的Importer/Finder进行moudle查找和加载实现规范,也就是说,如果我们想自己实现module加载可以实现Importer/Finder,然后放入sys.meta_path中.我们看一下sys.meta_path有哪些Importer/Finder:

  • BuiltinImporter
  • FrozenImporter
  • PathFinder
  • pkg_resources.extern.VendorImporter
  • pkg_resources._vendor.six._SixMetaPathImporter
  • six._SixMetaPathImporter

因为_ext 存在OS路径中,所以这里势必使用的是PathFinder,开头我们说到_bootstrap.py是python import的核心实现,而PathFinder类对应的_bootstrap_external.py则是基于路径import的核心实现。

_bootstrap._find_spec()中最关键的代码就是尝试使用不同的Importer/Finder查找module,这里我们直接定位调用PathFinder类的find_spec方法。

_bootstrap_external.PathFinder.find_spec()

@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:
	    namespace_path = spec.submodule_search_locations
	    if namespace_path:
	        # We found at least one namespace path.  Return a
	        #  spec which can create the namespace package.
	        spec.origin = 'namespace'
	        spec.submodule_search_locations = _NamespacePath(fullname, namespace_path, cls._get_spec)
	        return spec
	    else:
	        return None
	else:
	    return spec

这里有几个入参:

  • fullname='_ext',即module完整的名称。
  • path_ext父module的系统路径。
  • target为None

该函数核心为调用_get_spec(),参数不变的调用。这里的path不为none,但是如果为None则使用sys.path定义的路径,举个例子,site-packages下的module能够被发现,就是因为site-packages路径默认是追加在sys.path中的。这也给我们一个启发,除了自定义Importer/Loader放入sys.meta_path,通过加要加载module基础路径放入sys.path中来快速实现module的查找和加载。

_bootstrap_external.PathFinder._get_spec()

@classmethod 
def _get_spec(cls, fullname, path, target=None):
        """Find the loader or namespace_path for this module/package name."""
        # If this ends up being a namespace package, namespace_path is
        #  the list of paths that will become its __path__
        namespace_path = []
        for entry in path:
            if not isinstance(entry, (str, bytes)):
                continue
            finder = cls._path_importer_cache(entry)
            if finder is not None:
                if hasattr(finder, 'find_spec'):
                    spec = finder.find_spec(fullname, target)
                else:
                    spec = cls._legacy_get_spec(fullname, finder)
                if spec is None:
                    continue
                if spec.loader is not None:
                    return spec
                portions = spec.submodule_search_locations
                if portions is None:
                    raise ImportError('spec missing loader')
                # This is possibly part of a namespace package.
                #  Remember these path entries (if any) for when we
                #  create a namespace package, and continue iterating
                #  on path.
                namespace_path.extend(portions)
        else:
            spec = _bootstrap.ModuleSpec(fullname, None)
            spec.submodule_search_locations = namespace_path
            return spec

该函数遍历给定的路径,然后根据不同路径会给出不同的Finder,我们可以看一下_path_importer_cache的实现:

@classmethod
def _path_importer_cache(cls, path):
        """Get the finder for the path entry from sys.path_importer_cache.
        If the path entry is not in the cache, find the appropriate finder
        and cache it. If no finder is available, store None.
        """
        if path == '':
            try:
                path = _os.getcwd()
            except FileNotFoundError:
                # Don't cache the failure as the cwd can easily change to
                # a valid directory later on.
                return None
        try:
            finder = sys.path_importer_cache[path]
        except KeyError:
            finder = cls._path_hooks(path)
            sys.path_importer_cache[path] = finder
        return finder

_path_importer_cache优先从cache看有没有对应处理该路径的Findler,没有则调用_path_hooks找可以处理该路径的Finder,再看看_path_hooks():

@classmethod
def _path_hooks(cls, path):
        """Search sys.path_hooks for a finder for 'path'."""
        if sys.path_hooks is not None and not sys.path_hooks:
            _warnings.warn('sys.path_hooks is empty', ImportWarning)
        for hook in sys.path_hooks:
            try:
                return hook(path)
            except ImportError:
                continue
        else:
            return None

_path_hooks()遍历sys.path_hooks中注册的路径钩子函数,如果有一个钩子函数能够处理给定的路径,并且返回Findler,则使用该Finder进行module的查找加载。看到这里我们发现,自定义module加载又多了一种方式,在我们的系统中实现module多版本动态加载,就是通过注册路径钩子函数进行实现的,如果想通过定义钩子函数实现动态加载的,可以参考我的另外一篇文章Python多版本多module动态加载

回到_bootstrap_external.PathFinder._get_spec()的主逻辑,找到对应的Finder之后,调用Finder的find_spec()

sys.path_hooks中注册的钩子函数返回的Finder和从sys.meta_path拿出来的Finder是有区别的,在这个例子中sys.meta_path给出的是PathFinder,sys.path_hooks给出的是FileFinder。

_bootstrap_external.FileFinder.find_spec()

这个里面代码比较多,就不在多贴了,大致说一下,该方法判断给定的名称是package还是正常的py,并给出用于该module加载使用的loader,最后返回ModuleSpec对象,该对象作为moudle加载的规范,用于后面module的加载。ModuleSpec是module的源信息,主要包含以下属性:

  • name ,module的绝对名称,
  • loader,用于module加载的加载器
  • is_package,该name对应的module是否为一个包
  • origin,module的路径,用于加载器从哪加载使用的位置
  • submodule_search_locations,用于加载该module下的子module时的查找路径。
  • parent ,module所在的包名

该例子执行到这步,返回的ModuleSpec对象内容大致如下:

  • name: '_ext'
  • loader:SourceFileLoader: <_frozen_importlib_external.SourceFileLoader object at 0x7f2df3838e80>
  • origin: '/home/.../_ext/__init__.py'
  • submodule_search_locations:['/home/.../_ext']
  • parent:_ext

其实成功返回了ModuleSpec,就意味着已经完成了python finder的流程,也就是Finder所干的事情!

对应这里我们找到了_ext这个module,实际上的是_ext.__init__.py,下面就是要对_ext.__init__.py进行加载。

Loader

回到_bootstrap._find_and_load_unlocked(),看对spec的加载,加载实际上就是ModuleSpec到module的过程。

_bootstrap._load_unlocked

def _load_unlocked(spec):
    # A helper for direct use by the import system.
    if spec.loader is not None:
        # not a namespace package
        if not hasattr(spec.loader, 'exec_module'):
            return _load_backward_compatible(spec)

    module = module_from_spec(spec)
    with _installed_safely(module):
        if spec.loader is None:
            if spec.submodule_search_locations is None:
                raise ImportError('missing loader', name=spec.name)
            # A namespace package so do nothing.
        else:
            spec.loader.exec_module(module)

    # We don't ensure that the import-related module attributes get
    # set in the sys.modules replacement case.  Such modules are on
    # their own.
    return sys.modules[spec.name]

该函数通过调用module_from_spec将ModuleSpec转换为module,我们先看module_from_spec:

_bootstrap.module_from_spec()

def module_from_spec(spec):
    """Create a module based on the provided spec."""
    # Typically loaders will not implement create_module().
    module = None
    if hasattr(spec.loader, 'create_module'):
        # If create_module() returns `None` then it means default
        # module creation should be used.
        module = spec.loader.create_module(spec)
    elif hasattr(spec.loader, 'exec_module'):
        raise ImportError('loaders that define exec_module() '
                          'must also define create_module()')
    if module is None:
        module = _new_module(spec.name)
    _init_module_attrs(spec, module)
    return module

该函数创建空的module对象,然后对module进行初始化,初始化包含对module的如下属性进行赋值:

  • name,module名称
  • loader,加载器
  • package,包名称,即ModuleSpec.parent
  • spec,ModuleSpec对象
  • path , module所在的路径,使用ModuleSpec.submodule_search_locations即可
  • file,module对应文件绝对路径。即ModuleSpec.origin

完成对module参数初始化,我们再次回到_bootstrap._load_unlocked中,下面做的事情就是module的loader,看一下spec.loader.exec_module(module)实现:

def exec_module(self, module):
        """Execute the module."""
        code = self.get_code(module.__name__)
        if code is None:
            raise ImportError('cannot load module {!r} when get_code() '
                              'returns None'.format(module.__name__))
        _bootstrap._call_with_frames_removed(exec, code, module.__dict__)

顺便贴出_bootstrap._call_with_frames_removed:

def _call_with_frames_removed(f, *args, **kwds):
    """remove_importlib_frames in import.c will always remove sequences
    of importlib frames that end with a call to this function

    Use it instead of a normal call in places where including the importlib
    frames introduces unwanted noise into the traceback (e.g. when executing
    module code)
    """
    return f(*args, **kwds)

其实事情比较简单,modul的所有信息都有了,就是读取source源码生成code对象,然后调用python的内置函数exec对code进行执行,到这里就完成了module的加载执行。

其实到这里我们只完成了from .ext import nms_ext的加载, 在加载执行完_ext.__init__.py之后,我们还是会返回到from .ext import nms断点。

-------------------------------------- nms 查找加载 --------------------------------------

还是要继续对nms进行加载,对于nms加载,从_bootstrap._handle_fromlist()方法进入:

def _handle_fromlist(module, fromlist, import_):
    if hasattr(module, '__path__'):
        if '*' in fromlist:
            fromlist = list(fromlist)
            fromlist.remove('*')
            if hasattr(module, '__all__'):
                fromlist.extend(module.__all__)
        for x in fromlist:
            if not hasattr(module, x):
                from_name = '{}.{}'.format(module.__name__, x)
                try:
                    _call_with_frames_removed(import_, from_name)
                except ModuleNotFoundError as exc:
                    # Backwards-compatibility dictates we ignore failed
                    # imports triggered by fromlist for modules that don't
                    # exist.
                    if exc.name == from_name:
                        continue
                    raise
    return module

先给出在我们的例子中这几个参数的值:

  • module,module: <module '_ext' from '/home/.../_ext/__init__.py'
  • fromlist,<class 'tuple'>: ('nms',)
  • import_, builtin_function_or_method: <built-in function __import__>

对于from _ext import nms实际要加载的是_ext.nms。执行_call_with_frames_removed()实际上执行:

__import__(('utils.lib.nms._ext.nms',),{}) 

最后还是逃避不了我们之前梳理出来的那一套Finder、Loader流程。也是还会回到_bootstrap._find_and_load()开始对_ext.nms查找和加载,通过前面的介绍,Finder返回ModuleSpec对象,Loader将ModuleSpec转为module对象,然后源码转为code进行执行,前面已经大篇幅给出了流程,后面不再赘述。因为_ext.nms实际也是包,所以加载的应该是_ext.nms.__init__.py

Finder阶段返回的ModuleSpec为:

  • loader,SourceFileLoader: <_frozen_importlib_external.SourceFileLoader object at 0x7f2df38385f8>
  • name,_ext.nms
  • origin,/home/.../_ext/nms/__init__.py
  • parent,_ext.nms
  • submodule_search_locations,['/home/.../_ext/nms']

Loader阶段得到的Module对象为:

module: <module '_ext.nms' from '/home/.../_ext/nms/__init__.py'>
  • name,_ext.nms
  • package,_ext.nms
  • path , ['/home/.../_ext/nms']
  • file,/home/.../_ext/nms/__init__.py

-------------------------------------- nms 查找加载 --------------------------------------

在exec _ext.nms.__init__.py时,遇到了要对.so加载的需求,也就是:

from ._nms import lib as _lib, ffi as _ffi 

其实总的Finder、Loader流程都是一致的,最大不同在于FileFinder中,针对.so文件会创建和.py不同的Loader。

其实FileFinder针对不同文件给不同Loader是在初始化就决定的,我们看一下代码:

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

*loader_details参数已经定义好不同后缀文件对应的Loader。之前介绍过FileFinder是由路径钩子函数创建的,刚好在FileFinder系统定义了FileFinder能够处理路径的钩子函数,顺着这个找,我们找到了_bootstrap_external._get_supported_file_loaders()

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]

sourcebytecode对应的后缀分别为.py.pyc,那么只有extensions是能够处理.so文件的,事实证明,确实是它。

找到了_nms.so路径和加载它的ExtensionFileLoader,后面的事情就是回到_bootstrap._find_and_load_unlocked()中对创建的ModuleSpec进行load,依旧是执行Loader类的exec_module(),对于.py文件底层最终会调用'exec'指令完成module的加载,而对于.so实际调用的是内置的_imp.exec_dynamic函数。

小结

Python的module加载核心就两个:

  • 查找器(Finder)
  • 加载器(Loader)

查找器目的在于找到要在家module的路径等信息,并为其绑定对应的加载器,最终封装成ModuleSpec返回,对于package找的是package下的__init__.py,而如果在查找过程中发现对应的父模块还未加载,那么会先去加载父module,这里面踩过一个坑,算了,不多说了,想知道的关注另外一篇文章:Python多版本多module动态加载.

查找器有两种,一种是通过sys.meta_path绑定,还有一种是通过路径构建函数进行绑定。如果需要自定义module的查找和加载,可以考虑这两点。

加载器就是完成对module的读取,然后执行。