什么是classloader?
classloader直译过来是类加载器,类加载器主要用来动态加载库文件,动态加载是一种机制,计算机程序可以通过这种机制在运行时将库(或者其它二进制文件)加载到内存中,检索库中包含的函数和变量的地址,执行这些函数或访问这些变量。
跨平台的系统采用了动态加载,类加载器是JAVA的基石,需要插件(plugins)的系统也采用了动态加载,例如ROS和Apollo cyber的插件机制。
程序运行机制
程序运行一共有3种机制:
- 静态链接 (static linking)
- 动态链接 (dynamic linking)
- 动态加载(dynamic loading)
静态链接会把程序拷贝一份到可执行程序,带来的好处是不依赖任何外部程序就可以独立运行,坏处是浪费磁盘和内存空间,而动态链接和静态链接相反,动态链接是在编译时候指定链接到哪个库,在运行时查找对应的库并且加载到内存,而且不同的应用程序在内存中可以共享一份动态库,从而节省系统内存。
下面我们看一个例子,有2个文件hello.c和main.c分别如下:
#include
我们通过命令`gcc -fPIC -shared hello.c -o libhello.so`把hello.c编译为动态库。
#include
接下来用命令`gcc main.c -L. -lhello -o main`链接编译好的libhello库。
我们可以通过`ldd ./main`来查看运行程序依赖于哪些动态库。
(
同时也可以用`readelf -a ./main | grep interp`来查看运行时动态链接器。
用`LD_DEBUG=libs ./main`来打开打印信息,查看main函数加载的时候如何动态找到需要的库文件。
找到库文件之后,还需要动态绑定符号表,绑定符号表的过程就依赖于动态链接器,我们可以用`LD_DEBUG=bindings main`来打印绑定符号的过程。
也就是说程序运行的时候通过interpreter来找到链接的动态库,然后去对应的共享库中绑定符号表,这一切完成之后进行init初始化,然后再把控制权交给main函数,main函数执行完成之后,在执行对应的fini过程。
动态加载的过程和动态库加载的过程类似。
动态加载
unix系统的动态加载接口在"dlfcn.h"中,主要有3个接口函数
- dlopen 加载动态库
- dlsym 根据符号名称获取函数实例
- dlclose 卸载动态库
dlopen
加载动态库到内存,调用的是dlopen函数,dlopen的接口如下,第一个参数是动态库的路径,第二个参数是flag,传入一些标志位
void
flag的参数有以下几种
- RTLD_LAZY // 懒加载
- RTLD_NOW // 立即加载
- RTLD_GLOBAL // 全局可见
- RTLD_LOCAL // 局部可见
- RTLD_NODELETE
- RTLD_NOLOAD
- RTLD_DEEPBIND
dlopen如果重复打开同一个共享库,第二次打开的时候返回的是之前打开的句柄,也就是说返回的是之前已经加载的共享库的地址,而不会重复加载,但是每次加载的时候共享库的引用计数会加1,当调用dlclose的时候,必须和dlopen有相同的次数,共享库才会被卸载。
动态库加载的时候,在把控制权交给main函数之前,会进行初始化init,我们可以看以下例子。在上述"hello.c"加入静态变量a,并且添加默认构造函数打印"init before main()",并且换成g++编译。
#include
可以看到静态变量在main之前就执行默认构造函数了,之后才执行main函数中的内容。
dlsym
当共享库加载到内存之后,如果需要调用共享库的函数或者创建对象,可以通过dlsym根据函数名获取函数实例。
void
这里的symbol就是对应的函数名,因为c++有函数重载,为了区分函数和变量,c++中编译器创建符号表的时候会对变量做name mangling,因此函数名经过name mangling之后就变成我们根本不认识的了,下面是name mangling之后的函数名称。
如果需要通过函数名作为符号获取实例,必须用c语言来实现,因为c语言不会对进行name mangling。
dlclose
dlclose对动态库进行卸载
int
这里需要注意,dlclose的次数和dlopen的次数相等,共享库才会从内存中卸载。下面是一个例子。
B -> A
C -> A
先加载B,然后加载C,根据隐性依赖会加载2次A,卸载B的时候A的计数会减1,不会直接从内存中卸载(因为C还在内存,还依赖于A),但dlclose还是返回成功,只有当卸载C的时候,A的引用计数会清零,这时候A才会真正从内存中卸载。
poco库实现
poco库的动态库加载,借鉴了上述c语言不会进行name mangling,在c++中采用`extern "C"`嵌入c语言的函数,这样就可以直接通过dlsym函数名调用函数了。下面我们分析下poco库的加载过程。
首先是通过宏来注册类,注册分为3个宏定义,宏定义实际上就是注册`Poco::MetaObject`到`pManifest_`,而`Poco::MetaObject`类实现了`create()`接口创建注册好的类。
#define POCO_EXPORT_CLASS(cls)
Poco::MetaObject类实现如下:
template
最后poco库的重点是用extern "C"进行函数声明。
extern
总结下poco库的实现,是利用了c语言不会进行name mangling的特性,在loadlibrary的时候调用`pocoBuildManifest`,把对应的`Poco::MetaObject`注册到类中`pManifest_`,这样就可以通过`pManifest_`来创建注册好的对象了。
ROS实现
ROS的实现方式和poco库的实现略有差异,ROS也要对类进行注册,但是初始化的方式不一样,前面讲到过,在动态库被加载到内存,控制权交给main函数之前,会执行初始化的过程init,而ROS正是利用了这一特性,每个注册过程都会创建一个静态变量,而静态变量的初始化在类加载的时候会默认执行(因为静态变量不属于任何一个对象,因此程序加载的时候会自动创建静态变量,而这个过程在init中完成)。
知道了大概的原理,下面我们看以下ros中的具体实现。首先看注册类的宏定义。
class
上面通过`CLASS_LOADER_REGISTER_CLASS`宏定义完成注册派生类和基类。宏定义的具体实现如下
#define CLASS_LOADER_REGISTER_CLASS_INTERNAL_WITH_MESSAGE(Derived, Base, UniqueID, Message)
宏定义中就主动创建了一个静态类`g_register_plugin_ ## UniqueID`,而且动态类加载的时候会主动执行类的默认构造函数。
template
这里factoryMap为一个map结构,key是派生类名称,value是`impl::MetaObject`,而这里的`impl::MetaObject`和POCO库中的一样,也实现了`Create()`接口用来创建注册好的类。
template
之后就可以用注册好的`factoryMap`来创建对象了。
至此POCO库和ROS的classloader机制就分析完成了,而Apollo的classloader机制和ROS的基本类似。
动态加载的优缺点
优点
- 运行时更加灵活,可以动态的加载启动功能。
- 可以使用第三方的工具进行扩展
缺点
- 启动时间会慢20%
- 执行效率慢了5%,由于动态地址解析PIC
优秀实践
业界已经有不少c++的程序采用了动态加载机制,包括
- Apache HTTP Server
- ROS
- Apollo
- POCO
- Boost
其中COM组件的思想也是希望采用动态加载的机制实现跨平台和跨语言。
参考资料
- the_inside_story_on_shared_libraries_and_dynamic_loading
- ELF: From The Programmer's Perspective
- How To Write Shared Libraries