作者|许双清介绍启动优化时,我们往往通过提高并发来降低主线程的耗时。在iOS中,GCD是最常用的并发编程框架。增加并发是开始优化的好方法吗?哪种优先级的GCD队列适合开发者使用?本文将结合飞书的启动优化给出选择GCD队列的最佳实践,同时也提供针对低端机的启动优化思路。应用这个思路,在不修改飞书业务逻辑的情况下,我们在飞书低端机上取得了很好的用户体验收益:首屏显示时间优化100ms,消息列表首次刷新时间优化1500ms。低端机特点通过Instruments的AppLaunch功能,我们可以看到App启动时的线程状态、TimeProfiler等信息。除其他外,我们发现不同的设备在启动时表现不同。以iPhone7p(低端)和iPhone12(高端)为例,它们的设备参数分别为:设备CPU参数、实际核心数、ProcessInfo.processInfo.activeProcessorCount、全CPU配比(Xcode测试)iPhone7pA10芯片[1],2个高性能+2个低功耗,但只能2核同时工作2200%iPhone12A14芯片[2],2个高性能+4个低功耗6600%启动飞书时,我们观察两种设备的线程通过Instruments统计,在iPhone7p上,主线程Preempted和Runnable状态占比高达21%。在Instruments图中,可以看到主线程有很大一部分被抢占了。一个典型的部分,可以看到主线程处于抢占状态,CPU0在执行其他进程,CPU1在执行GCD线程。在iPhone12上,主线程Preempted和Runnable状态仅占1%。从这里我们可以发现,对于低端机来说,CPU已经成为了启动的瓶颈,“提高并发”已经不是万能的启动优化了。想办法减少主线程被其他线程抢占,可能是一种优化思路。GCD队列抢占主线程的评估为了评估“减少其他线程抢占主线程”是否是一个可行的优化思路,我们首先需要弄清楚主线程会被抢占多少?我们可以使用Demo来创建一些极端场景,了解在极端场景下主线程有多少会被其他线程抢占,于是我们有如下Demo实验:实验组1:异步线程QoS:DispatchQoS.userInteractivecode:for_in1...100{letqueue=DispatchQueue.init(label:"serialQueue",qos:.userInteractive)queue.async{whiletrue{}}}whiletrue{}qos_class_selfvalue:33主线程Preempted+Runnable比率:74%实验组2:异步线程QoS:无QoS或DispatchQoS.userInitiated代码:for_in1...100{letqueue=DispatchQueue.init(label:"serialQueue")queue.async{whiletrue{}}}whiletrue{}qos_class_self值:25主线程Preempted+Runnable比例:73%实验组3:异步线程QoS:DispatchQoS.utility代码:for_in1...100{letqueue=DispatchQueue.init(label:"serialQueue",qos:.utility)queue.async{whiletrue{}}}whiletrue{}qos_class_self值:17主线程Preempted+Runnableratio:1.3%实验组4:异步线程QoS:DispatchQoS.background代码:for_in1...100{letqueue=DispatchQueue.init(label:"serialQueue",qos:.background)queue.async{whiletrue{}}}whiletrue{}qos_class_selfValue:9mainthreadPreempted+Runnableratio:1.3%不指定QoS,一个ExtremeDemo,主线程在启动时长期处于抢占状态,我们无法获得运行的机会。从中我们可以看出几个结论:当不指定QoS时,自建GCD队列的QoS为User-InitiatedUser-Initiated及以上优先级,会出现严重的主线程抢占;而Utility和Background几乎不会抢占主线程。另外,我们也做了测试,验证pthread_create创建的线程也有类似的抢占。QoS与优先级看到iPhone7p上主线程被其他线程抢占,我们可能会有疑问:主线程不应该是最高优先级吗?怎么会被其他线程抢占呢?这里,我们需要了解一下QoS和线程优先级这两个概念。QoS(qualityofservice)是指服务的质量,它影响线程的优先级(priority),也影响I/O吞吐量、CPU吞吐量等指标[3]。开发者可以使用qos_class_self()接口获取当前线程/队列的QoS。苹果对于每个任务应该选择哪种QoS也有一些指导[4]:QoS和优先级确实有对应关系。参考xnu源码和实验结果。对应关系为:QoSPriorityUser-Interactive46,对于UI线程同时为47User-Initiated37Utility20Background4,线程的优先级会随着执行动态调整。在测试过程中,我们会发现运行开始时主线程的优先级是47对应QoSUser-Interactive,但是随着运行的进行会逐渐降低。官方文档[5]解释了线程优先级变化的原因。优先级由Mach调度程序控制。为了防止计算密集型线程独占资源,每个线程的优先级都会实时调整。所有这些机制都在Mach调度器中连续运行。这意味着线程会根据它们的行为和系统中其他线程的行为频繁地提高或降低优先级。进一步阅读xnu内核的源代码[6],我们发现,线程优先级的变化是由各个Mach调度器实现的compute_timeshare_priority接口控制的。在iOS使用的Mach调度器中,compute_timeshare_priority与sched_compute_timeshare_priority的实现相同。线程调度时的优先级会根据线程固有的优先级,结合当前线程的CPU使用率和当前设备的整体负载情况进行调整。在这个实现中,我们可以看到Mach调度器对优先级的调整有一个限制:对于原来priority=47的线程,向下调整限制为47-((BASEPRI_FOREGROUND-BASEPRI_DEFAULT)+2)=29。这是一致的结合我们多台设备测试的结果:主线程执行时,最低的优先级值为29,仍然高于Utility对应的优先级20。这也解释了为什么在Demo中,当异步线程的QoS为Utility时,几乎不可能抢占主线程。优化落地通过demo实验,一个启动优化思路应运而生:在飞书,大量异步队列的QoS是User-Initiated。这个QoS虽然比主线程的User-Interactive低,但还是有可能抢占主线程;那么,如果将异步队列的QoS降为Utility,是否可以先保证主线程的执行,从而更早的显示首屏呢?经过一些粗略的实验,我们确认飞书在这个思路上是有优化空间的。但是另一个问题随之而来:如何兼顾首屏、消息列表首次刷新等多个指标?考虑第一次刷新消息列表的场景:获取最新消息不仅需要主线程构建UI,还需要依赖数据库读取、网络请求等异步操作。如果我们粗略的降低所有异步队列的QoS,首屏确实可以显示得更快,但是随着异步操作变慢,消息列表的第一次刷新会变差。这对用户体验有负面影响。梳理出哪些异步操作最先依赖,保证这些队列的QoS,是优化中非常重要的一环。我们首先通过Instruments不断测试和阅读代码,梳理出第一版白名单队列,并验证了首屏、首屏离线和在线等关键指标的优化效益。在后面的迭代中,我们开发了离线工具。我们通过offlinehookdispatch_async等函数记录了第一次刷新时机依赖的GCD队列,实现了自动生成白名单队列的能力。效果分析本次优化在线上产生了不错的体验优化效果:首屏显示启动时间优化为100ms。通过调整异步线程的QoS,显着降低主线程在启动时的CPU抢占。更多的计算资源集中在主线程上,使得首屏显示速度明显加快。消息列表首次刷新时间优化1500ms通过对消息列表第一次刷新依赖的任务进行分析,我们降低了无关线程的QoS,同时也允许读取数据库、网络请求等任务第一次刷新取决于获得更多资源并加快其执行速度。综上所述,“提高并发”可以作为一定范围内的启动优化方案,但是在低端机上,CPU成了瓶颈,异步线程抢占主线程也是需要付出代价的并发时注意。GCD提供了四种QoS供开发者使用,官方也针对这四种QoS提供了最佳实践建议。经过评估和源码推理,User-Interactive和User-Initiated明显抢占主线程,而Utility和Background很少抢占主线程。对于开发者创建的GCD队列,默认的QoS实际上是User-Initiated。因此,在启动期间(或任何时间敏感期),应主动将与启动不直接相关的队列设置为Utility或Background,以减少对主线程的抢占。通过在飞书上的落地优化,我们可以得出结论,在不改变启动业务逻辑的情况下,调整线程或GCD队列的QoS可以获得显着的收益。当然,比后期优化更好的操作是在编码时充分了解不同QoS的行为特点,选择最合适的QoS。
