老铁们,大家好,相信还有很多朋友对于maxalp网站源码分享和网站源码站的相关问题不太懂,没关系,今天就由我来为大家分享分享maxalp网站源码分享以及网站源码站的问题,文章篇幅可能偏长,希望可以帮助到大家,下面一起来看看吧!
应用的启动性能,作为和用户体验直接关联的重要指标,一直是各大技术团队花时间花精力去钻研优化的部分。由于在应用启动阶段,iOS系统和应用本身会做很多事情,包括binary加载、二方库启动、框架加载、界面渲染等等,这些事情涉及到iOS开发的方方面面。所以,一个应用的启动性能如何,能够直接体现技术团队的水准。
淘票票团队经过启动优化专项治理,将启动时间降低了28.2%。本文将分享在应用点击到应用完成加载这个阶段中,我们的技术优化策略。
一、应用点击到应用完成加载
一般情况下,我们将启动阶段大概分为四个:
1)Pre-Main:加载binary、静态库等;
2)Main:main.m中main方法执行;
3)ApplicationDidFinish:iOS系统回调,通知应用以完成启动加载,可以开始界面绘制;
4)界面绘制完成:首界面完全展现在用户设备屏幕上。
这里的阶段范围,涉及到我们这次分享的主要是Pre-Main阶段。
考虑是对Pre-Main阶段进行优化,实际上这里不太涉及对于代码的处理,因为在这个阶段我们写的代码还没有被真正的运行起来。这个阶段主要的工作都是iOS系统在做,系统会将binary从安装到本地的.app文件夹中找出来,通过签名验证binary身份,之后将binary加载到虚拟内存中。
这部分的优化,我们更多地需要去理解系统行为,在编译这个层级就做好优化准备。
说到理解系统行为,能够去看的东西很多也很少。iOS操作系统本身是很复杂的,虽然其是一个闭源系统,但iOS底层使用的是Unix,我们可以通过类Unix系统,也就是开源的Linux的很多系统行为来推测Unix的行为,并推测iOS的系统行为。但是iOS提供的相关文档和接口很少,使得就算是我们理解了一个行为,可能也没有办法将对应处理应用到iOS应用上。所以如何在众多可能性中找到那些我们可以做的事情,就比较重要了。
通过一系列的摸索,和调研其他团队的优化经验,启动阶段的PageFault优化进入了我们的视野。
二、PageFault
PageFault是什么?回答这个问题,我们先要回顾一下现代操作系统的内存处理机制。
1.早期的内存机制
在计算机发展早期,操作系统对于内存处理机制都是有多少内存用多少内存,系统本身的内存地址和操作系统中应用的内存地址在物理内存上都是一一对应的。当你写了一个软件并运行,其中一个变量的地址打印出来是0x000FF1,那么这个变量的实际储存地址就是物理内存上的0x000FF1这个位置。这种原始的内存处理方式,配合C语言中自由度极高的指针,带来了很多可能。程序员想要把其他应用在内存中的片段给引用到自己的软件中运行,直接将对应内容地址加载即可。实际上当年很多应用都是这么做的,通过这种手段避免了重复加载运行库的内容。
当然,这么做除了容易导致崩溃之外,还存在另一个比较严重的问题:在计算机蛮荒的时代,内存作为计算机中重要的高I/O性能硬件,价格是非常高的。如果操作系统和应用需要将所有运行内容都加载进内存,那么一个128MB的软件就会需要128MB的物理内存,考虑到操作系统自身的内存占用还会需要更多内存。
为了解决这么一个昂贵的硬件问题,操作系统的内存Paging机制被建立了起来。
2.虚拟内存
操作系统开发者发现,当一个应用加载进内存之后,并不是所有的内存内容都会随时被应用使用到。包括操作系统本身在内的应用,被完全加载进内存之后,也不是所有内容都会在同一时间被完全用上。那么是不是可以将部分暂时不用的内存内容,从内存移到硬盘上,等到需要的时候再从硬盘中读取呢?事实证明这个想法是可行的。
如果今天你手动安装过Linux系统,在安装时需要给系统设定一部分的Swap硬盘区域。在Windows系统中如果打开系统设置,也能找到虚拟内存的对应调整选项。这里的Swap和虚拟内存,就是Paging机制下系统在硬盘上建立的暂存内存数据的位置。
自此,应用程序真正能访问到的内存地址,和物理内存中的地址,被完全的分离开了。一个128MB的内存在合理的运用下可以配合1GB以上的硬盘(虚拟内存)完成binary大小高达1GB程序的运行。
3.Page-In,Page-Out
当一个应用开始运行时,操作系统会加载应用到内存中,这个过程我们称之为Page-In。当操作系统加载一段内容之后,突然发现物理内存不够继续加载更多内容了,这时候就需要释放一部分内存,也就是把之前的一部分内存里内容移动到虚拟内存上,这个过程我们称之为Page-Out。
由于Page-Out是一个将内存内容写到硬盘上的高I/O消耗操作,所以操作系统会尽量防止这种操作,也就是在Binary加载时尽量将足够多的内容加载到内存中。当然系统是不知道Binary里面哪些是需要使用,哪些是不需要使用的,所以系统实际上是尽量保证Binary头部的内容被加载到内存中,而剩下没有办法加载进去的部分,则放在硬盘的虚拟内存中。
4.PageFault
现在我们应用的Binary被加载完了,其中一部分在内存里,一部分在虚拟内存里,当然我们应用自己是不知道那些在内存里那些在虚拟内存里的,对于我们来说这都是一样的。然后我们应用开始运行,这时我们向操作系统请求内存里的内容,如果这个内容刚好在内存里,一切顺利;如果这个内容实际上并不是在内存里,而是在虚拟内存里,操作系统就需要把请求的内容从硬盘加载到内存中。就算是在现在,内存的I/O速度和硬盘I/O速度依旧存在数量级的差距,所以把内存中的内容移动到虚拟内存再移动回去,必然会产生不小的I/O开销。针对这种应用访问了在虚拟内存内容的情况,我们称之为PageFault。
进入现代之后,随着人们对电脑的使用量增加,大众对于操作系统稳定性、安全性的要求越来越高,所以在Paging机制的基础上,内存鉴权机制、ASLR(进程地址空间布局随机化)、签名验证机制都被加了进来。在iOS上,发生PageFault的时候,由于系统从虚拟内存中拿出内容放到内存中时,会再次进行签名验证防止出现非法代码注入,所以PageFault的开销就比想象中更大了。
那么启动阶段的PageFault问题如何解决呢?
5.PageFault解决
这里我们做一个设想:我们假设启动阶段并不会用到全部Binary内容,如果我们能够获取足够的内存空间,把所有启动阶段的内容都放到内存里,那么是不是可以做到在启动阶段完全没有PageFault了呢?
这个设想是美好的,但实际上我们运行的设备可能本身就没有足够的内存,所以我们只能通过优化来尽量实现这个目标,尽可能的减少PageFault。
那么,应该如何优化我们的应用Binary使得PageFault尽量减少呢?这里就要用到我们接下来介绍的这种古老的优化技术了。
三、二进制排序
就像我前面说的,PageFault这个问题,在早期操作系统开始使用Paging时就已经存在了,而那个年代的前辈开发者们已经在探索优化这个问题的方法。为了实现我们前面的设想,编译器开发者们很早就在编译器中提供了二进制排序这项技术。
如果你学习过编译器原理,了解过编译器的Binary编译过程,你会记得在编译的最后阶段,Linker会将之前生成的Mach-O文件合并成我们最终可以运行的Binary。
默认情况下,Linker会按照Mach-O文件顺序将里面的方法一个个写入到Binary中。不过在这个阶段,无论是使用llvm还是gcc,我们都可以通过提供一个OrderFile来引导Linker在生成Binary时,将方法按我们提供的照顺序排列在Binary中。
通过这种操作,我们可以尽量把启动阶段需要使用到的方法写在Binary前面,就像我之前说的那样,由于操作系统并不知道我们Binary中那些部分是需要一直在内存中的,所以系统会优先保证Binary头部内容是存在内存中的,如果我们应用启动只访问了这些写在Binary头部的方法,那么就不会发生PageFault了。
现在我们优化的手段有了,那么我们怎么知道哪些方法是需要优化的呢?当然我们可以手动去编辑OrderFile,但我们淘票票这个级别的应用,启动阶段可能涉及到的方法实在是太多了,光是我们自己的类中能够确定的就有好几十个,更不用提没有源代码的二方库和各种系统库了。
所以如何生成一个合理的OrderFile呢?
四、OrderFile生成
1.抖音的方案
首先我们最先想到的,是抖音这边分享[1]。抖音分享的方法分为两部分,一部分是针对Objc方法的,一部分是针对C++方法的。
Objc的方法处理是相对简单的,由于Objc方法都会通过objc_msgSend进行执行,所以我们只需要引入FishHook将objc_msgSend给Hook掉,记录下启动阶段每一个方法的地址,之后将地址和Binary的LinkMap进行匹配即可。
C++的方法处理抖音这边做的不是很好,他们的做法是通过分析LinkerMap拿到C++方法对应地址,然后在启动过程中将内存不断dump出来,一个个看里面包含了哪些方法。这个做法非常繁琐,操作成本很高。
当然,抖音也针对Block进行了Hook处理,一样是通过FishHookHook掉了block函数方法,拿到block地址,之后通过LinkerMap反查。
总得来说,抖音的方案整体实现是基于C的Hook配合基于内存地址反查的手工操作。
2.Facebook的方案
抖音之后,我们了解了Facebook分享的方法[2]。
根据Facebook在llvm邮件组里面的公开邮件[3],他们一开始使用的是dtrace这种工具来跟踪记录启动阶段的方法,然而面对不同情况下的应用启动流程,他们很难将多个dtrace结果生成一份OrderFile。所以他们开始通过改造llvm,通过参数使llvm在编译中加入插桩,运行时通过插桩来记录启动阶段执行的方法,最后进行汇总。
使用Facebook的这种方法,首先需要保证整个项目都是通过源代码的进行编译的,如果你的项目不是通过源代码编译的,而是引用了一些二方库,由于没有编译过程,所以llvm插桩也就不会插入,最终生成的OrderFile也就不会包含对应二方库的方法。
那么有没有更好的方案呢?
在研究Facebook的方法中间,通过llvm邮件组的公开邮件,我们发现了一个更加官方的解决方案。
五、Profile-GuidedOptimizations
Profile-GuidedOptimizations,简称PGO,是llvm中一项相对来说比较古老的技术,最早的介绍可以追溯到2013年的一份PPT[4]中。
从PPT中我们可以了解到,PGO可能是一个源于CodeCoverage的项目。llvm的工程师在处理完代码覆盖率之后,想到了可以通过代码覆盖率检查的方法将启动阶段中使用到的方法都梳理出来,之后生成类似于OrderFile的文件,引导编译过程中Linker阶段处理。
但不同于OrderFile只记录了方法和方法顺序,PGO生成的Profile文件还会记录对应方法的被调用次数和引用次数,帮助Linker做最后的顺序处理。
下面是一个模拟的PGO文件,可以看到这里不仅记录了方法,还记录了方法中关联的方法,和对应的引用次数。
MVAAA.m:__64-[MVAAAbbb]_block_invoke:\nHash:0x000000a49844645a\nCounters:3\nFunctioncount:0\nMVCCC.m:CGSizeMake:\nHash:0x0000000000000018\nCounters:1\nFunctioncount:7
PGO在llvm中的实现,主要是通过在llvmIR中进行插桩,之后让应用运行生成profile文件,后面编译时这份profile文件会被使用在编译的link环节中,Linker会解析这份文件并按照策略对程序方法进行排序。
由于相比OrderFile,PGO文件还会参考引用次数等变量,使得最终优化结果上,PGO的预期结果会高于OrderFile。
当然,PGO最大的好处还是在于苹果在Xcode中提供了方便的支持。[5]
通过苹果提供的工具,我们可以很方便的生成profile文件,并在编译时将profile文件添加到编译环节,获得启动性能提升。
配置完Profile之后,通过Instrument测量两次应用启动结果,可以看到FileBackedPageIn有了非常明显的减少,数值下降了23%左右。之后我们在新版中配置了Profile文件,通过线上数据统计,仅通过PGO这项优化,就带来了18%左右的启动时间下降,效果显著。
那么,这么做就是极致了么?
必然不是,就像我之前提到的Facebook方案一样,PGO整套流程的方法获取,也是依赖于llvmIR插针,这导致面对非源代码编译的组件,二方库、三方库,PGO对他们的无能为力。
如果可以的话,我们可以找到二方库和三方库的源代码,整个项目通过完整的源代码方式进行编译,通过这种方式生成的profile文件就会包含对应二方库和三方库的方法名。
但是集团现在内部的状态,要实现将所有二方库的代码凑齐,是一件不太可能的事情。不仅仅是我们团队,就算是手淘团队也没有办法做到完全源代码编译,该引用的编译之后的二方库,还是得去引用编译之后的二方库。
之前说到的抖音的那种内存分析似乎可以用在这个方面,但一定要做的那么繁琐么?还是说存在更好的方法?
六、静态库插桩
在PGO方案中,我们无法触及的主要是各种静态的二方库和三方库,这些库已经提前打包生成了binary文件,所以不会再通过llvm的打包插桩流程。那么有没有一种方法可以将他们里面的方法全部插桩记录呢?
就在最近,集团手淘团队提出了自己的方案。[6]
根据手淘的方案,我们可以针对二方库生成的Binary进行汇编处理,在二方库没有签名的情况下,我们可以向二方库的方法中注入记录方法,在每个方法调用启示节点进行插桩,这样在二方库里方法被调用时,对应的插桩记录就会输出出来被我们拿到。
实际上我们并不需要输出对应方法的名字,只需要输出执行时的内存地址,然后通过内存地址的偏移量来确定对应方法在Binary中的位置,通过LinkMap找到对应方法并写入OrderFile。
这种方法比起抖音的不断查询内存的方式更加简单,但是在操作上需要有一定的汇编和反汇编知识,要能够修改对应二方库的Binary文件。
好在,我们的二方库使用版本相对稳定,通过集团提供的方法操作一次之后,生成的OrderFile并不需要在后期继续进行改动。
当然,OrderFile本身相对于PGO生成的profile来说是缺少一部分信息的,OrderFile毕竟只是一个方法顺序,PGO文件本身还包含了方法调用次数和引用次数的数据。就像llvm开发组的PPT里面写的那样,提供数据越多,优化也就越有效果。
好在OrderFile可以通过添加在Xcode配置中和PGO进行混编。我们没有细致的去测试生成出来的Binary是否是最完美的,但从结果来看,添加上OrderFile给我们带来的提升并不明显。
七、极致的方案
也许通过结合手淘静态库插桩加上PGO是一个极致的方案,也许换用源代码编译出来的内容配上PGO才是一个最终的方案。
但更大的可能是,在现实中不存在极致的方案,存在的更多的是合理的方案。
最终能够做到哪一步,能够做到多极致,还是取决于每个项目的情况和开发组自己的取舍。
对于淘票票iOS组而言,通过PGO这种简单易用的方式进行优化,得出的结果已经足够满足我们的需求,之后的二方库相关内容,我们会通过PGO和OrderFile配合的形式来进行优化处理。由于我们整个项目在启动阶段使用的二方库并没有我们想象中那么多,而且由于二方库的不确定性,我们在前期优化中就将很多二方库的初始化从启动阶段移动到了后续进行,所以静态库插针这种方式给我们带来的提升并不是很明显,比起启用PGO来说差远了。
也许,以后会有更好的解决方案面世,比如说llvm可以参照手淘开发组的思想把PGO的插桩添加到Mach-O文件中,使得我们的二方库的方法顺序也可以直接生成到PGO文件中。针对这块我们也会持续跟踪跟进,再出现更好的方案时,尽快把方案应用到我们的项目上,把更好的体验带给我们的用户们。
引用
[1]https://mp.weixin.qq.com/s/Drmmx5JtjG3UtTFksL6Q8Q?spm=ata.13261165.0.0.6c984638unW2y9
[2]https://www.facebook.com/atscaleevents/videos/664302790740440/?spm=ata.13261165.0.0.6c984638unW2y9
[3]http://lists.llvm.org/pipermail/llvm-dev/2019-January/129268.html
[4]https://llvm.org/devmtg/2013-11/slides/Carruth-PGO.pdf
[5]https://developer.apple.com/library/archive/documentation/DeveloperTools/Conceptual/xcode_profile_guided_optimization/Introduction/Introduction.html
[6]https://mp.weixin.qq.com/s/YDO0ALPQWujuLvuRWdX7dQ
作者|淘票票高级无线开发工程师朔明
文章分享结束,maxalp网站源码分享和网站源码站的答案你都知道了吗?欢迎再次光临本站哦!
