性能优化之动态加载
在过去近三十年的职业生涯里,有几年专注于运行时环境的开发与实现。在runtime中,动态加载技术是其中的基石之一。动态加载技术是指在系统运行过程中,根据需要把程序和数据从外存或网络加载到内存中的过程。其中,lazy loading(懒加载),也被称为延迟加载,是动态加载技术的一种常见实现方式。
1. 什么是动态加载
所谓动态加载,指的是程序在运行期间需要调用某一模块的功能时,由加载器将该模块即时载入内存,进行相应的重定位处理后将控制权交还调用程序。动态加载机制运用动态链
接的原理使得系统具有动态的加载和动态解析的能力,模块只有在被调用执行时才被链接,进入系统执行。
动态加载一般分为下载、加载和卸载三个操作,其中下载完成从远程下载目标模块到本地,加载操作来完成读入模块到内存,然后对模块未解析的外部引用进行解析(一般地,也就是符号解析和重定位)使之可以运行的过程。当模块不再使用时就从内存中卸载。
1.1 动态加载中的基本概念——模块
模块是数据说明、可执行语句等程序对象的集合,它单独命名而且可以通过名字来访问,模块设计者可以通过有选择地在接口上输出其特性以达到控制其特性的目的。没被输出的特性不能被模块外部访问,因此防止了模块被误用并且保证了模块的封装独立性。
在模块化编程思想中,把程序分割成若干个独立的模块,然后逐块编程和独立编译,形成独立的可加载模块,模块在被加载前保持本身的独立性。一个应用可以由多个独立模块组成,独立的模块构成一个应用有两种方法:静态链接和动态链接。静态链接是独立模块事先链接好,在解决了所有的外部引用之后,编译生成一个可执行文件,随后装入内存就可以执行。进程在执行的过程中,代码段和数据段的位置都不能改变。
可加载模块作为编译单元被独立编译,这就意味着编译器会核实一个引入模块的每个引用同其根模块的一致性。模块链是所有被加载的目标模块依据模块之间的依赖关系被动态添加的一个链表,查询时只需要对该链表进行查找。在动态加载中,模块直到被载入前都保持独立。
一个模块可能与系统中的其他模块无关,也有可能与其中的一些模块进行交互,模块之间的交互就是模块间的通信。
1.2 动态加载的技术基础——动态链接
动态链接是系统在运行过程中根据需要把外部独立模块的可执行代码链接到系统中使之成为运行系统的一部分的过程。动态链接在执行过程中,允许在它的地址空间中增加、清除、取代或重定位目标模块。换句话说,允许程序发生变化。在程序执行的生命周期内,程序可以加入新的模块、清除旧的模块、甚至可以演变成一个完全不同的程序。
动态链接的一个典型应用就是动态链接库。动态链接库是一个可以被其它应用程序共享的程序模块,其中封装了一些可以被共享的例程和资源。
动态链接库是从C语言函数库和Pascal库单元的概念发展而来的。动态链接库不用重复编译或链接,一旦装入内存, 库中的函数可以被系统中任何正在运行的应用程序所使用,而不必再将动态链接库的另一拷贝装入内存。动态链接所调用的函数代码并没有被拷贝到应用程序的可执行文件中去,而是仅仅在其中加入了所调用函数的描述信息(往往是一些重定位信息)。只有当应用程序被装入内存开始运行时,在操作系统的管理下,才在应用程序与相应的动态链接库之间建立链接关系。
采用这种方法,动态链接库达到了复用代码的极限。另一个方便之处是对动态链接库中函数的修改可以自动传播到所有调用它的程序中,而不必对程序作任何改动或处理。
以windows 为例,动态链接库的实现方法主要有两种:
① 加载时动态链接(Load-time Dynamic Linking)
这种用法的前提是在编译之前已经明确知道要调用DLL中的哪几个函数,编译时在目标文件中只保留必要的链接信息,而不含DLL函数的代码;当程序执行时,利用链接信息加载DLL函数代码并在内存中将其链接入调用程序的执行空间中,其主要目的是便于代码共享。
②运行时动态链接(Run-time Dynamic Linking)
这种方式是指在编译之前并不知道将会调用哪些DLL函数,完全是在运行过程中根据需要决定应调用哪个函数,并用LoadLibrary和GetProcAddress动态获得DLL函数的入口地址。
在C/C++中,动态加载的功能可以很容易地利用动态链接库来实现。Win32 API函数LoadLibrary和FreeLibrary提供了在运行时刻加载新的功能模块和释放内存空间的功能。需要被更新的功能模块被封装在动态连接库中,主程序利用LoadLibrary函数装载该动态链接库,然后调用其中的功能模块。需要更新某功能模块的时候,首先终止运行该功能模块,利用FreeLibrary函数卸载现有的动态链接库,通过网络或者是其他通讯端口将新的动态链接库文件发送到指定目录下,然后通过再次调用LoadLibrary函数装载新的动态链接库就可以完成动态加载新模块的功能。
2. 动态加载的一般原理
动态加载机制所涉及到的关键问题包括加载模块的格式、模块间通信机制、符号管理等等。
可动态加载的模块有很多种格式类型。比如DOS下的.COM文件和.EXE文件,UNIX 的下a.out文件、ELF文件,以及.OBJ文件等都可以作为可加载模块被链接入系统。对于可重定位的模块还要包括处理目标代码时所需的扩展符号和重定位信息,比如符号表、重定位信息和字符串表等。模块间的通信方法可以参照进程间的通信方法,不同进程之间的通信有很多种方法:消息队列、管道和共享内存等。符号管理是设计与实现动态加载机制的前提,也是符号解析和重定位技术的基础。目标模块按照不同的类型读入内存后,不能马上就融入系统运行,动态加载机制还需要对其进行处理,也就是解决模块中符号的外部引用(符号解析)和重定位,这是动态加载过程中最重要的一个步骤。
动态加载是通过把符号的外部参考插入到运行时链接的目标文件中而实现,具有两个特点:
①动态的加载,就是当这个运行的模块在需要的时候才被映射入运行模块的虚拟内存空间中。如果一个模块在运行中要调用到另一模块中的函数,而在没有调用这个模块中的其它函数之前,不会把该模块加载到系统中(也就是内存映射)。
②动态的解析,就是存在于另一模块中的函数被调用的时候,才会去把这个函数在虚拟内存空间的起始地址解析出来,再写到调用模块中特定的存储地址内。例如,一个模块调用了另一个模块中的函数,那么该函数的地址直到被调用的时候才会被解析出来。
3. 动态加载的价值
动态加载技术对提高系统性能,提升可扩展性,保证系统的可靠性,延长系统生命周期,降低系统开发成本都具有十分重要的意义。
对于程序中不可忽视的错误,或者不能完全满足用户的需求,或者使用过程中需要不断地修改和升级等等,其对应的修改和升级都会往往导致停机维护。而停机对于如金融处理系统、电信交换机系统、交通控制系统,以及一些关键的军事应用上的卫星系统等等,用户不能忍受系统中断服务。因此,系统的在线扩展目前已经成为系统软件的一个基本需求,在系统运行状态下可以通过动态的添加模块来配置系统,也就是动态加载机制。动态加载也使系统的升级变得更为方便。升级时开发人员不必重新写整个系统,即可将升级限制在系统的一个或更多部分,例如如某种算法或某个数据表格。
动态模块升级还仅取决于基础系统提供的功能API(应用编程接口),而非取决于基础系统的静态地址。这意味着,一个动态模块可支持多个产品版本,只要所有版本提供的API相同即可。
另外,动态加载也是系统调试和功能完善的重要手段,具有动态加载功能的系统可以随时更新系统的程序,十分便于系统的调试、维护和功能的完善。
4. 操作系统内核的动态加载
Linux 内核模块是Linux中内存加载的一个特殊部分,它可以在不重启整个系统的情况下动态加载和卸载,具有很高的灵活性。在内核模块动态加载时,可以节约相当多的系统资源,而且还能避免不必要的内核模块以及它们的依赖组件被不必要地加载到内存中,从而提高系统的性能。
为了实现动态加载内核模块,Linux提供了系统调用(System call)机制。它是一种专门用于在操作系统中执行某一操作的特殊函数。当用户或应用程序要求加载内核模块时,系统会调用合适的系统调用。例如,可以使用“insmod”系统调用动态加载内核模块,在不需要的时候用rmmod命令卸载模块。
Linux内核动态加载机制的一般工作流程如下:
编写内核模块代码:开发者编写内核模块代码,实现特定的功能。这些代码通常以C语言编写,并使用Linux内核提供的API进行编程。
编译内核模块:开发者使用特定的编译工具链,将内核模块代码编译成可加载的模块文件(通常是.ko文件)。
加载内核模块:在系统运行期间,用户可以通过执行insmod命令,将编译好的内核模块加载到内核中。加载过程包括将模块代码映射到内核地址空间、初始化模块等步骤。
使用内核模块:一旦内核模块被加载,它的功能就可以被内核和其他系统组件使用。例如,如果加载的是一个驱动程序模块,那么内核就可以通过该模块与相应的硬件设备通信。
卸载内核模块:当不再需要某个内核模块时,用户可以执行rmmod命令将其从内核中卸载。卸载过程包括清理模块资源、撤销模块映射等步骤。
尽管有一些约束,使用eBPF 也可以实现内容模块中的细粒度动态加载。
5. Android 中的动态加载框架
Java反射通常用于检测和改变应用程序运行在虚拟机中的表现。使用方法Class.forName获得该类的Class文件,对得到的类用getField方法获得类的成员变量,用getMethod方法获得类的成员方法,也可以通过得到的成员方法的invoke方法执行该成员方法。对于得到的Field类型的成员变量,可以通过它的set方法替换掉该变量。
为完成Android的动态加载,需要修改系统底层源码,用到了大量的Java反射方法。让系统能启动插件组件,使用反射方法替换掉应用层发送给系统的组件名称,从而让系统启动插件组件。对于不同插件的资源加载问题,同样是通过反射执行系统资源管理等方法来解决的。
参考DruidPlugin 的实现, 我们可以得到如下的Android 动态加载框架:
下列简要介绍每个模块:
解析模块:解析插件安装包文件,获得插件的所有信息。
Manifest管理模块:解析结果,管理多个插件的manifest文件的替换和更新。
资源加载模块:管理宿主与多个插件程序编译后生成的资源id索引表,以便正常地通过资源id加载资源。
代理插件模块:实现插件与宿主间的替换工作,让系统以能够启动插件组件。
生命周期管理模块:使插件activity组件在宿主程序中能与普通的activity一样,拥有所有的生命周期方法,能够正常地运行。
插件管理模块:用于管理诸如插件的添加、删除、更新等操作。
安全模块:保证框架的安全性,确定通过网络通信得到的插件文件没有被修
改。
启动插件模块:生成多种插件的启动器,用于加载与启动插件。
该框架位于Framework层与应用层之间,作为应用程序与Android系统的中间桥梁,通过修改系统的方法调用,从而实现插件的加载。
其中, 插件启动器的onCreate方法示例伪代码如下:
1:thread <-Reflector.getActivityThread(app) //获取ActivityThread
2:base <- getInstrumentationByReflect(thread) //获得反射Instrumentation属性
3:wrapper <- InstrumentationReplace(base) //修改
4:setInstrumentationByReflect(wrapper) //重新注入
5:setMessageHandlerByReflect(callBack) //反射设置ActivityThread的m Callback
动态加载的过程如下所示:
其中, activity,bundle,uri 等插件中涉及的组件和参数同样需要做相应的调整。
6. 前端系统的动态加载
对PHP 开发环境下MVC 模式的网站代码设计来说,分离的组织代码路径的获取是令人头疼,也是代码运行中最容易产生错误的地方。为此,创建一个动态路径的加载应用会极大方便编码,提升开发效率。
用户首先访问入口页面视图,视图请求控制器,控制器响应特定行为,获取相应模型数据,而后将处理结果反馈到视图中呈现给用户。因而,在访问请求中需明确控制器和控制器执行的行为名称。为实现控制器类中方法能调用不同视图和模型,需要在实例化类对象之前,加载类的定义,即要完成对不同存储位置下类的引用。为优化代码的性能,节省无谓的精力消耗,应用类自动加载方案。将自动加载类__autoLoad()方法运用pl_auto⁃load_register()重新注册改写,当代码解析为新引用类时,自动调用改写方法,计算路由路径地址予以实例化加载,以实现不同文件目录下的类的自动加载。示例代码如下:
private static function autoLoad($class_name){
$class_map=array('MySqlDB' => CORE_PATH."MySqlDB.class.php",'Base' => CORE_PATH."Base.class.php" );
if(isset($class_map[$class_name])) require $class_map[$class_name];
elseif(substr($class_name, -5) == "Model") require MODEL_PATH.$class_name.".class.php";
elseif(substr($class_name, -10) == "Controller") require __URL__.$class_name.".class.php";
}
作为是一种网页优化技术,动态加载可以在网页加载时延迟加载不必要的资源,以提高页面的加载速度和性能。例如,在VUE中引入百度开源的echart包用于数据统计的绘图。
<template>
<div>using echart in vue project</div>
<div id="c1" style="width: 600px; height: 400px"></div>
</template>
<script setup lang="ts">
import * as echarts from "echarts";
import { onMounted } from "vue";
onMounted(() => {
const dom = document.getElementById("c1");
var myChart = echarts.init(dom as HTMLElement);
myChart.setOption({
title: {
text: "ECharts Bar usage",
},
tooltip: {},
xAxis: {
data: ["AA", "BB", "CC", "DD", "EE", "FF"],
},
yAxis: {},
series: [
{
name: "产量",
type: "bar",
data: [5, 10, 15, 12, 10, 20],
},
],
});
});
</script>
采用普通的加载方式,可以在路由配置中直接导入该页面:
import VueComponent from "@/pages/vue.vue";
const router = createRouter({
history: createWebHistory(),
routes: [
{ path: "/login", component: () => import("@/pages/login.vue") },
{
path: "/dashboard",
component: Dashboard,
children: [
{ path: "/", component: () => import("@/pages/dashboard/index.vue") },
{ path: "/vue", component: VueComponent },
{ path: "/react", component: () => import("@/pages/react.vue") },
],
},
],
});
会发现下载的包很大有1M多,在一般的网络条件下,至少5秒以上,用户体验交差。如果采用动态加载,性能则会有较大的提升。
const router = createRouter({
history: createWebHistory(),
routes: [
{ path: "/login", component: () => import("@/pages/login.vue") },
{
path: "/statistic",
component: Statistic,
children: [
{ path: "/", component: () => import("@/pages/statistic/index.vue") },
{ path: "/vue", component: () => import("@/pages/vue.vue") },
{ path: "/react", component: () => import("@/pages/react.vue") },
],
},
],
});
采用延迟加载之后,加载时间可以回归到1秒以内,用户体验的提升是显著的。
7. 小结
动态加载技术的核心思想是在程序运行时才加载所需的模块或组件,而不是在编译时静态链接。这种技术带来了许多优势,如代码的模块化、解耦、易于维护和扩展等。
动态加载使得代码更加模块化,降低了系统的复杂度,还有助于提高代码的可重用性,因为相同的模块可以在多个地方使用。动态加载能够实现代码的解耦,有助于提高团队协作效率,并提高系统的可维护性。掌握动态加载技术需要对编程语言、操作系统、网络通信等方面有深入的了解。因此,学习和实践动态加载技术有助于程序员提高自己的系统架构能力和编程技能。
动态加载技术在软件开发领域具有广泛的应用场景。合理使用动态加载技术不仅可以提高系统的可维护性和可扩展性,还可以提升程序员的系统架构能力和编程技能。因此,对于现代软件开发者来说,掌握动态加载技术是非常必要的。
对系统性能的追求,意味着对用户体验的尊重。如何对软件系统进行全方位的优化是一个系统工程,如果您希望成为一个对系统性能有追求的工程师,我们最近出版的《性能之道》一书可能会给您带来实际的帮助和较大的启发。
【关联阅读】
性能约定:API 限速
微秒级高性能网络初探
操作系统性能提升之内核锁优化
一种编译器视角下的python性能优化
导致系统性能失败的10个原因
系统性能设计的10个反模式
API设计中性能提升的10个建议
性能:关键路径的延迟分析
API的性能约定
服务器性能监控的温故知新
软件系统的多维性能模型
性能,10点系统性思考
浅谈面向客户端的性能优化
智能音箱场景下的性能优化