对于大型的应用软件,特别是客户端应用软件,应用启动过程中,需要执行大量的逻辑,包括各个模块的初始化和注册等等逻辑。大型应用软件的启动过程都是非常复杂的,而客户端应用软件是对应用的启动性能有所要求的,不同于服务端的应用软件。设想,用户双击了桌面图标,然而等待几分钟,应用才启动完毕,那用户下一步会不会就是点击卸载了。为了权衡大型应用软件在启动过程,既需要执行复杂的启动逻辑,又需要关注启动性能,为此过程造一个框架是一个完全合理的事情。我所在的团队为启动过程造的库,就是本文将要和大家介绍我所在团队开源的 dotnetCampus.ApplicationStartupManager 启动流程框架的库
背景
这个库的起源是一次听 VisualStudio 团队的分享,当时大佬们告诉我,为了优化 VisualStudio 的启动性能,他的团队制定了一个有趣的方向,那就是在应用启动的时候将 CPU 和内存和磁盘跑满。当然,这是一个玩笑的话,本来的意思是,在 VisualStudio 应用启动的时候,应该充分压榨计算机的性能。刚好,我所在的团队也有很多个大型的应用,代码的 MergeRequest 数都破万的应用。这些应用的逻辑复杂度都是非常高的,原本只能是采用单个线程执行,从而减少模块之间的依赖复杂度导致的坑。但在后续为了优化应用软件的启动性能,考虑到进行机器性能的压榨策略,其中就包括了多线程的方式
然而在开多线程的时候,自然就会遇到很多线程相关的问题,最大的问题就是如何处理各个启动模块之间的依赖关系。如果没有一个较好的框架来进行处理,只靠开发者的个人能力来处理,做此重构是完全不靠谱的,或者说这个事情是做不远的,也许这个版本能优化,但下个版本呢
还有一点非常重要的是如何做启动性能的监控,如分析各个启动项的耗时情况。在进行逐个启动业务模块的性能优化之前,十分有必要进行启动模块的性能测量。而有趣的是,启动模块是非常和妖魔的用户环境相关的,也就是在实验室里测量的结果,和实际的用户使用的结果是有很大的误差的。这也就给启动流程框架提了一个重要的需求,那就是能支持方便的对各个启动模块进行性能测量监控
由于有多个项目都期望接入启动流程框架,因此启动流程框架应该做到足够的抽象,最好不能有耦合单一项目的功能
经过了大概一年的开发时间,在 2019 年正式将启动流程框架投入使用。当前在近千万台设备上跑着启动流程框架的逻辑
当前此启动流程框架的库在 GitHub 上,基于最友好的 MIT 协议,也就是大家可以随便用的协议进行开源,开源地址: https://github.com/dotnet-campus/dotnetCampus.ApplicationStartupManager
功能
我所在的团队开源的 ApplicationStartupManager 启动流程框架的库提供了如下的卖点
自动构建启动流程图
支持高性能异步多线程的启动任务项执行
支持 UI 线程自动调度逻辑
动态分配启动任务资源
支持接入预编译框架
支持所有的 .NET 应用
启动流程耗时监控
启动流程图
各个启动任务项之间,必然存在显式或隐式依赖,如依赖某个逻辑或模块初始化,或者依赖某个服务的注册,或者有执行时机的依赖。在开发者梳理完成依赖之后,给各个启动任务项确定相互之间的依赖关系,即可根据此依赖关系构建出启动流程图
假设有以下几个启动任务项,启动任务项之间有相互的依赖关系,如下图,使用箭头表示依赖关系
启动任务项 A :最先启动的启动任务项,如日志或容器的初始化启动任务项
启动任务项 B :一些基础服务,但是需要依赖 A 启动任务项完成才能执行
启动任务项 C :依赖 B 启动任务项的执行完成
启动任务项 D :另一个独立的模块,和 B C E 启动任务项没有联系,但是也依赖 A 启动任务项的完成
启动任务项 E :同时依赖 B C 启动任务项的完成
启动任务项 F :同时依赖 A D 启动任务项的完成
以上的启动任务项可以构成一个有向无环启动流程图,每个启动任务项都可以有自己的前置或后置。那为什么需要是无环呢?要是有两个启动任务项是相互等待依赖的,那就自然就无法成功启动了,如下图,有三个启动任务项都在相互依赖,那也就是说无论哪个启动任务项先启动,都是不符合预期的,因为先启动的启动任务项的前置没有被满足,启动过程中逻辑上是存在有前置依赖没有执行
为了更好的构建启动流程图,在逻辑上也加上了两个虚拟的节点,那就是启动点和结束点,无论是哪个启动任务项,都会依赖虚拟的启动点,以及都会跟随着结束点
另外,具体业务方也会定义自己的关联启动过程,也就是预设的启动节点,关键启动过程点将被各个启动项所依赖,如此即可人为将启动过程分为多个阶段
例如可以将启动过程分为如下阶段
启动点:虚拟的节点,表示应用启动,用于构建启动流程图
基础设施:表示在此之前应该做启动基础服务的逻辑,例如初始化日志,初始化容器等等。其他启动任务项可以依赖基础设施,从而认为在基础设施之后执行的启动任务项,基础设施已准备完成
窗口启动:在客户端程序的窗口初始化之前,需要完成 UI 的准备逻辑,例如样式资源和必要的数据准备,或者 ViewModel 的注入等。在窗口启动之后,即可对 UI 元素执行逻辑,或者注册 UI 强相关逻辑。或者是在窗口启动之后,执行那些不需要在主界面显示之前执行的启动任务项,从而提升主界面显示性能
应用启动:完成了启动的逻辑,在应用启动之后的启动任务项都是属于可以慢慢执行的逻辑,例如触发应用的自动更新,例如执行一下日志文件清理等等
结束点:虚拟的节点,表示应用启动过程完全完成,用于构建启动流程图
如图,每个启动任务项可以选择依赖的是具体的某个启动任务项,也可以选择依赖的是关键启动过程点
通过此逻辑,可以为后续的优化做准备,也方便上层业务开发者开发业务层的启动任务项。让上层业务开发者可以比较清晰了解自己新写的启动任务项应该放在哪个地方,也可以提供了调试各个模块的启动任务项的依赖情况,了解是否存在循环的依赖逻辑
高性能异步多线程的启动任务项执行
为了更好的压榨机器性能,进行多线程启动是必要的。在完成了启动流程图的构建之后,即可将启动任务项画成树形,自然也就方便进行多线程调度。基于 .NET 的 Task 方式调度,可以实现多线程异步等待,解决多个启动任务项的依赖在多线程情况下的线程安全问题
如使用线程池的 Task 调度,可以从逻辑上,将不同的启动任务项的启动任务链划分为给不同的线程执行。实际执行的线程是依靠线程池调度,甚至实际执行上,线程池只是用了两个实际线程在执行
对应用的启动过程中,在不明白 .NET 线程池调度机制的情况下,将在开启多线程问题上稍微有一点争议。核心争议的就是如果一个应用启动过程中,占满了 CPU 资源,是否就让用户电脑卡的不能动了。其实上面这个问题不好回答,如果大家有此疑惑,那就请听我细细分析一下。首先一点就是问题本身,先问 问题 本身一个问题,如果只是开一个线程启动,会不会也让用户的电脑卡的不能动了?答案是 是的,完全取决于用户电脑,包括电脑配置以及电脑的妖魔环境,例如一个渣配的设备配合国产的好几个杀毒软件一起,那么在应用启动的瞬间,就有大量的杀毒工作在执行,自然就卡的不能动了。而且,电脑卡的不能动了,是不是和 CPU 被占满是必然关系?答案是 完全不是,应用启动过程中,一定会存在 DLL 加载的过程,特别是应用的冷启动过程,大量的文件读写,对于一些机械盘来说,将会占满磁盘的读写,自然也就能让电脑卡的不能动了,这个过程和是否开启多线程,其实关系很小,毕竟机械盘和 CPU 之间的性能摆在这。第二个是卡的时间是否重要,例如应用开了多线程就卡了 500 毫秒,而如果应用启动只用单线程则需要 4 x 500ms = 2s 的耗时,那是否此时开多线程划得来呢?这个是需要权衡的,不同的应用逻辑自然不同,例如生产力工具,我本来开机就是为了用此工具,例如写代码用的 VisualStudio 工具,我打开了这个应用,过程中自然没有其他同步使用的需求,卡了就卡了咯。最后一个问题就是,开启 .NET 的多线程完全不等于占满了 CPU 资源,别忘了 IO 异步哦
当然了,会接入应用流程的开发者肯定不属于新手,相信对于线程方面知识已有所了解,会自己选择合适的方式执行启动任务项。这也侧面告诉大家,本启动流程框架的库接入是有一定的门槛的
支持 UI 线程自动调度逻辑
对于客户端应用,自然有一个特殊的线程是 UI 线程,启动过程,有很多逻辑是需要在 UI 线程执行的。由于 .NET 系的各个应用框架的 UI 线程调度都不咋相同,因此需要启动流程框架执行一定量的适配
在具体的启动任务项上标记当前的启动任务项需要在 UI 线程执行即可,框架层将会自动调度启动任务项到 UI 线程执行
设计上,默认将会调度启动任务项到非 UI 线程执行
动态分配启动任务资源
在用户端的各个启动任务项的