草庐IT

不改一行业务代码,飞书 iOS 低端机启动优化实践

徐霜晴 2023-03-28 原文
作者|徐霜晴

引言

在启动优化时,我们常常通过增加并发的方式来减轻主线程的耗时。而在 iOS 中,GCD 是并发编程最常用的框架。增加并发是否是启动优化的良策?开发者适合选用哪个优先级的 GCD 队列?本文将结合飞书启动优化,给出选取 GCD 队列的最佳实践,也提供针对低端机的启动优化思路。

应用此思路,我们在未修改飞书业务逻辑的情况下,在飞书低端机上,取得了不错的用户体验收益:首屏展示时间优化 100ms,消息列表首刷时间优化 1500ms。

低端机的特性

通过 Instruments 的 App Launch 功能,我们能看到 App 启动时的线程状态、Time Profiler 等信息。其中,我们发现不同设备在启动时的表现有很大差异。​​

以 iPhone 7p(低端)和 iPhone 12(高端)举例,它们的设备参数分别为:

设备


CPU 参数

实际核数

ProcessInfo.processInfo.activeProcessorCount

跑满的 CPU 占比(Xcode 测试)

iPhone 7p


A10 芯片[1],2 高性能 + 2 低功耗,但是只有 2 核能同时工作

2


200%


iPhone 12

A14 芯片[2],2 高性能 + 4 低功耗

6

600%


启动飞书时,我们通过 Instruments 观察两个设备的线程状态,经过统计发现,iPhone 7p 上,主线程 Preempted 和 Runnable 状态的占比高达 21%。Instruments 的图中能看到主线程大片被抢占。

一个典型的局部,能看到主线程是 preempted 状态,CPU0 在执行其他进程,CPU1 在执行 GCD 线程。

而 iPhone 12,主线程 Preempted 和 Runnable 状态占比则只占 1%从这里我们能发现:对低端机来说,CPU 已经成为了启动的瓶颈,“增大并发”已不是一个万能的启动优化措施,而想办法减少其他线程对主线程的抢占,可能会是优化思路。

GCD queue 对主线程的抢占评测

为了评估“减少其他线程对主线程的抢占”是否是一个可行的优化思路,我们首先需要弄明白,主线程被抢占的程度会有多大?

我们可以使用 Demo 制造一些极端场景,了解极端场景下,主线程有多少比例会被其他线程抢占,因此有了如下 Demo 实验:

实验组1:

  • 异步线程 QoS:DispatchQoS.userInteractive
代码:

for _ in 1...100 {
let queue = DispatchQueue.init(label: "serialQueue", qos: .userInteractive)
queue.async {
while true {
}
}
}
while true {
}
  • qos_class_self 数值:33
  • 主线程 Preempted + Runnable 占比:74%
实验组2:

异步线程 QoS:不指定 QoS 或 DispatchQoS.userInitiated

代码:

for _ in 1...100 {
let queue = DispatchQueue.init(label: "serialQueue")
queue.async {
while true {
}
}
}
while true {
}
  • qos_class_self 数值:25
  • 主线程 Preempted + Runnable 占比:73%
实验组3:

  • 异步线程 QoS:DispatchQoS.utility​
  • 代码:
for _ in 1...100 {
let queue = DispatchQueue.init(label: "serialQueue", qos: .utility)
queue.async {
while true {
}
}
}
while true {
}
  • qos_class_self 数值:17
  • 主线程 Preempted + Runnable 占比:1.3%
实验组4:

  • 异步线程 QoS:DispatchQoS.background​
  • 代码:
for _ in 1...100 {
let queue = DispatchQueue.init(label: "serialQueue", qos: .background)
queue.async {
while true {
}
}
}
while true {
}
  • qos_class_self 数值:9
  • 主线程 Preempted + Runnable 占比:1.3%​
不指定 QoS 下,一个极端 Demo,启动期间主线程长时间处于 preempted 状态,一直无法得到 running 的机会

从中我们能看到几个结论:

  1. 不指定 QoS 时,自行创建的 GCD queue 的 QoS 是 User-Initiated
  2. User-Initiated 及以上优先级,对主线程会有严重抢占现象;而 Utility 和 Background 则几乎不会抢占主线程。
另外,我们也做测试验证了,pthread_create 创建的线程,也有类似的抢占现象。

QoS 和 Priority

看到 iPhone 7p 上主线程被其他线程抢占,我们可能会有疑问:主线程不应该是优先级最高的么?怎么还会被其他线程抢占?

这里,我们需要理解一下 QoS 和线程 priority 两个概念。

QoS(quality of service)意指服务质量,它影响线程优先级(priority),也影响 I/O 吞吐、 CPU 吞吐等指标[3]。开发者可以用 qos_class_self() 接口获得当前线程 / 队列的 QoS。

苹果对于每个任务应该选用哪个 QoS,也有一些指导意见[4]:

QoS 和 priority 确实有对应关系,参考 xnu 源码和实验结果,对应关系为:​

QoS

Priority

User-Interactive

46,对于 UI 线程是 47

User-Initiated

37

Utility

20

Background

4

同时,线程的 priority 会随着执行动态调整。测试中我们会发现,主线程的 priority 在运行开始时是 QoS User-Interactive 对应的 47,但随着运行会出现下降的情况。

官方文档[5]中解释了线程 priority 变化的原因,priority 由 Mach scheduler 控制,为了防止计算密集的线程垄断资源,各个线程的 priority 会实时调整。

All of these mechanisms are operating continually in the Mach scheduler. This means that threads are frequently moving up or down in priority based upon their behavior and the behavior of other threads in the system.

进一步阅读 xnu 内核的源码[6],我们发现,线程 priority 的变化,是由各个 Mach scheduler 实现的 compute_timeshare_priority 接口控制的。在 iOS 使用的 Mach scheduler 中,compute_timeshare_priority 为同一个实现 sched_compute_timeshare_priority。线程调度时的 priority,会在线程固有 priority 的基础上,结合当前线程的 CPU 占用情况和当前设备的整体负载进行调整。

在这个实现中,我们能看到 Mach scheduler 对 priority 的调整会有一个极限:对于原先 priority = 47 的线程来说,向下调整的极限是 47 - ((BASEPRI_FOREGROUND - BASEPRI_DEFAULT) + 2) = 29。这和我们用多个设备测试到的结果吻合:主线程执行时,priority 的最低值是 29,依然高于 Utility 对应的 priority 20。

这也解释了,为什么 Demo 中当异步线程的 QoS 是 Utility 时,就几乎无法对主线程造成抢占。

优化落地

通过 Demo 实验,一个启动优化思路产生了:在飞书中,大量异步队列的 QoS 是 User-Initiated,尽管这一 QoS 低于主线程的 User-Interactive,但依然可能对主线程造成抢占;那么,如果将异步队列的 QoS 调低到 Utility,是不是就可以优先保障主线程执行,让首屏更早展现出来?

经过一些粗暴的实验,我们证实了飞书在这个思路上存在优化空间。但另一个问题随之而来:如何兼顾首屏、消息列表首刷等多个指标?

考虑消息列表首刷的场景:获取到最新的消息,不仅仅需要主线程构建 UI,还需要依赖数据库读取、网络请求等异步操作。如果我们粗暴地将所有异步队列的 QoS 调低,首屏确实能更快展现,但消息列表的首刷则随着异步操作的变慢更劣化了。这对用户体验反而带来了负向影响。

梳理出哪些异步操作是首刷依赖的,确保这些队列的 QoS ,是优化中非常重要的一环。我们首先通过不断用 Instruments 测试、阅读代码梳理出了首版白名单队列,并在线下和线上验证了首屏、首刷等关键指标的优化收益。在后来的迭代中,我们又开发了线下工具,通过在线下 hook dispatch_async 等函数,记录下首刷等时机依赖的 GCD 队列,达成了白名单队列自动生成的能力。

效果分析

这一优化在线上产生了不错的体验优化效果:

启动首屏展现时间优化 100ms

通过调整异步线程的 QoS,启动期间主线程 CPU 抢占现象有明显降低。更多计算资源集中到主线程,使得首屏展示速度明显加快。

消息列表首刷时间优化 1500ms

通过对消息列表首刷依赖的任务的分析,我们调低了无关线程的 QoS,这也让首刷依赖的数据库读取、网络请求等任务得到了更多资源,加速了它们的执行。

总结

“增加并发”在一定范围内可以作为启动优化的方案,但在低端机上,CPU 已经成为瓶颈,并发时异步线程对主线程的抢占也需要引起重视。

GCD 提供了四种 QoS 给开发者使用,官方也为这四种 QoS 提供了最佳实践建议。

经过评测和源码推理,User-Interactive 和 User-Initiated 对主线程有明显抢占,Utility 和 Background 对主线程的抢占极少。开发者创建的 GCD 队列,默认的 QoS 实际为 User-Initiated。因此在启动期间(或者任何耗时敏感期间),与启动无直接关系的 queue,应该主动设置为 Utility 或 Background,减少对主线程的抢占。

通过飞书上落地优化,我们能得出结论:对线程或 GCD queue 调整 QoS,能在不改变启动业务逻辑的情况下取得显著收益。

当然,比事后优化更好的操作,是在编码时就充分了解不同 QoS 的行为特性,选用最适合的 QoS。​

有关不改一行业务代码,飞书 iOS 低端机启动优化实践的更多相关文章

  1. ruby-on-rails - 使用 Ruby on Rails 进行自动化测试 - 最佳实践 - 2

    很好奇,就使用ruby​​onrails自动化单元测试而言,你们正在做什么?您是否创建了一个脚本来在cron中运行rake作业并将结果邮寄给您?git中的预提交Hook?只是手动调用?我完全理解测试,但想知道在错误发生之前捕获错误的最佳实践是什么。让我们理所当然地认为测试本身是完美无缺的,并且可以正常工作。下一步是什么以确保他们在正确的时间将可能有害的结果传达给您? 最佳答案 不确定您到底想听什么,但是有几个级别的自动代码库控制:在处理某项功能时,您可以使用类似autotest的内容获得关于哪些有效,哪些无效的即时反馈。要确保您的提

  2. ruby - 如何在 buildr 项目中使用 Ruby 代码? - 2

    如何在buildr项目中使用Ruby?我在很多不同的项目中使用过Ruby、JRuby、Java和Clojure。我目前正在使用我的标准Ruby开发一个模拟应用程序,我想尝试使用Clojure后端(我确实喜欢功能代码)以及JRubygui和测试套件。我还可以看到在未来的不同项目中使用Scala作为后端。我想我要为我的项目尝试一下buildr(http://buildr.apache.org/),但我注意到buildr似乎没有设置为在项目中使用JRuby代码本身!这看起来有点傻,因为该工具旨在统一通用的JVM语言并且是在ruby中构建的。除了将输出的jar包含在一个独特的、仅限ruby​​

  3. ruby-on-rails - Rails 源代码 : initialize hash in a weird way? - 2

    在rails源中:https://github.com/rails/rails/blob/master/activesupport/lib/active_support/lazy_load_hooks.rb可以看到以下内容@load_hooks=Hash.new{|h,k|h[k]=[]}在IRB中,它只是初始化一个空哈希。和做有什么区别@load_hooks=Hash.new 最佳答案 查看rubydocumentationforHashnew→new_hashclicktotogglesourcenew(obj)→new_has

  4. ruby-on-rails - 启动 Rails 服务器时 ImageMagick 的警告 - 2

    最近,当我启动我的Rails服务器时,我收到了一长串警告。虽然它不影响我的应用程序,但我想知道如何解决这些警告。我的估计是imagemagick以某种方式被调用了两次?当我在警告前后检查我的git日志时。我想知道如何解决这个问题。-bcrypt-ruby(3.1.2)-better_errors(1.0.1)+bcrypt(3.1.7)+bcrypt-ruby(3.1.5)-bcrypt(>=3.1.3)+better_errors(1.1.0)bcrypt和imagemagick有关系吗?/Users/rbchris/.rbenv/versions/2.0.0-p247/lib/ru

  5. ruby - 如何验证 IO.copy_stream 是否成功 - 2

    这里有一个很好的答案解释了如何在Ruby中下载文件而不将其加载到内存中:https://stackoverflow.com/a/29743394/4852737require'open-uri'download=open('http://example.com/image.png')IO.copy_stream(download,'~/image.png')我如何验证下载文件的IO.copy_stream调用是否真的成功——这意味着下载的文件与我打算下载的文件完全相同,而不是下载一半的损坏文件?documentation说IO.copy_stream返回它复制的字节数,但是当我还没有下

  6. ruby-on-rails - 浏览 Ruby 源代码 - 2

    我的主要目标是能够完全理解我正在使用的库/gem。我尝试在Github上从头到尾阅读源代码,但这真的很难。我认为更有趣、更温和的踏脚石就是在使用时阅读每个库/gem方法的源代码。例如,我想知道RubyonRails中的redirect_to方法是如何工作的:如何查找redirect_to方法的源代码?我知道在pry中我可以执行类似show-methodmethod的操作,但我如何才能对Rails框架中的方法执行此操作?您对我如何更好地理解Gem及其API有什么建议吗?仅仅阅读源代码似乎真的很难,尤其是对于框架。谢谢! 最佳答案 Ru

  7. ruby - 模块嵌套代码风格偏好 - 2

    我的假设是moduleAmoduleBendend和moduleA::Bend是一样的。我能够从thisblog找到解决方案,thisSOthread和andthisSOthread.为什么以及什么时候应该更喜欢紧凑语法A::B而不是另一个,因为它显然有一个缺点?我有一种直觉,它可能与性能有关,因为在更多命名空间中查找常量需要更多计算。但是我无法通过对普通类进行基准测试来验证这一点。 最佳答案 这两种写作方法经常被混淆。首先要说的是,据我所知,没有可衡量的性能差异。(在下面的书面示例中不断查找)最明显的区别,可能也是最著名的,是你的

  8. ruby - 寻找通过阅读代码确定编程语言的ruby gem? - 2

    几个月前,我读了一篇关于ruby​​gem的博客文章,它可以通过阅读代码本身来确定编程语言。对于我的生活,我不记得博客或gem的名称。谷歌搜索“ruby编程语言猜测”及其变体也无济于事。有人碰巧知道相关gem的名称吗? 最佳答案 是这个吗:http://github.com/chrislo/sourceclassifier/tree/master 关于ruby-寻找通过阅读代码确定编程语言的rubygem?,我们在StackOverflow上找到一个类似的问题:

  9. Ruby 文件 IO 定界符? - 2

    我正在尝试解析一个文本文件,该文件每行包含可变数量的单词和数字,如下所示:foo4.500bar3.001.33foobar如何读取由空格而不是换行符分隔的文件?有什么方法可以设置File("file.txt").foreach方法以使用空格而不是换行符作为分隔符? 最佳答案 接受的答案将slurp文件,这可能是大文本文件的问题。更好的解决方案是IO.foreach.它是惯用的,将按字符流式传输文件:File.foreach(filename,""){|string|putsstring}包含“thisisanexample”结果的

  10. ruby - Net::HTTP 获取源代码和状态 - 2

    我目前正在使用以下方法获取页面的源代码:Net::HTTP.get(URI.parse(page.url))我还想获取HTTP状态,而无需发出第二个请求。有没有办法用另一种方法做到这一点?我一直在查看文档,但似乎找不到我要找的东西。 最佳答案 在我看来,除非您需要一些真正的低级访问或控制,否则最好使用Ruby的内置Open::URI模块:require'open-uri'io=open('http://www.example.org/')#=>#body=io.read[0,50]#=>"["200","OK"]io.base_ur

随机推荐