线程调度和任务编排
优化总结:有哪些APP启动提速方法? https://mp.weixin.qq.com/s/LVz8D_pubcpGW-s7LOpMbA
八 线程调度和任务编排八 线程调度和任务编排
1 整体思路
对于任务编排有种打法,就是先把所有任务滞后,然后再看哪个是启动开始必须要加载的。效果立竿见影,很快就能看到最好的结果,后面就是反复斟酌,严格把关谁才是必要的启动任务了。
启动阶段的任务,先理出相关依赖关系,在框架中进行配置,有依赖的任务有序执行,无依赖独立任务可以在非密集任务执行期串行分组,组内并发执行。
这里需要注意的是Android 的 SharedPreferences 文件加载导致的 ContextImpl 锁竞争,一种解法是合并文件,不过后期维护成本会高,另一种是使用串行任务加载。你可能会疑惑,我没怎么用锁,那是不是就不会有锁等待的问题了。其实不然,比如在 iOS中,dispatch_once 里有 dispatch_atomic_barrier 方法,此方法就有锁的作用,因此锁其实存在各个 API 之下,如不用工具去做检查,有时还真不容易发现这些问题。
有 IO 操作的任务除了锁等待问题,还有效率方面也需要特别注意,比如 iOS 的 Fundation 库使用的是 NSData writeToFile:atomically: 方法,此方法会调用系统提供的 fsync 函数将文件描述符 fd 里修改的数据强写到磁盘里,fsync 相比较与 fcntl 效率高但写入物理磁盘会有等待,可能会在系统异常时出现写入顺序错乱的情况。系统提供的 write() 和 mmap() 函数都会用到内核页缓存,是否写入磁盘不由调用返回是否成功决定,另外 c 的标准库的读写 API fread 和 fwrite 还会在系统内核页缓存同步对应由保存了缓冲区基地址的 FILE 结构体的内部缓冲区。因此启动阶段 IO 操作方法需要综合做效率、准确和重要性三方面因素的权衡考虑,再进行有 IO 操作的任务编排。
针对初始化耗时的库,比如埋点库,可以延后初始化,先将所需要的数据存储到内存中,待到埋点库初始化时再进行记录。对一些主图上业务网络可以延后请求,比如闪屏、消息盒子、主图天气、限行控件数据请求、开放图层数据、Wi-Fi信息上报请求等。
2 多线程共享数据的问题
并发任务编排缺少一个统一的异步编程模型,并发通信共享数据方式的手段,比如代理和通知会让处理到处飞,闭包这种匿名函数排查问题不方便,而且回调中套回调前期设计后期维护和理解很困难,调试、性能测试也乱。这些通过回调来处理异步,不光复杂难控,还有静态条件、依赖关系、执行顺序这样的额外复杂度,为了解决这些额外复杂度,还需要使用更多的复杂机制来保证线程安全,比如使用低效的 mutex、超高复杂度的读写锁、双重检查锁定、底层原子操作或信号量的方式来保护数据,需要保证数据是正确锁住的,不然会有内存问题,锁粒度要定还要注意避免死锁。
并发线程通信一般都会使用 libdispatch(GCD)这样的共享数据方式来处理,也就异步再回调的方式。libdispatch 的 async 策略是把任务的 block 放到队列链表,使用时会在底层的线程池里找可用线程,有就直接用,没有就新建一个线程(参看 libdispatch[15] 源码,监控线程池 workqueue.c,队列调度 queue.c),使用这样的策略来减少线程创建。当并发任务多时,比如启动期间,即使线程没爆,但 CPU 在各个线程切换处理任务时也是会有时间开销的,每次切换线程,CPU 都需要执行调度程序增加调度成本和增加 CPU 使用率,并且还容易出现多线程竞争问题。单次线程切换看起来不长,但整个启动,切换频率高的话,整体时间就会增大。
多线程的问题以及处理方式,带来了开发和排查问题的复杂性,以及出现问题机率的提高,资源和功能云化也有类似的问题,云化和本地的耦合依赖、云化之间的关系处理、版本兼容问题会带来更复杂的开发以及测试挑战,还有问题排查的复杂度。这些都需要去做权衡,对基础建设方案提出了更高的要求,对容错回滚的响应速度也有更高的要求。
这里有个 book[16] 专门来说并行编程难的,并告诉你该怎么做。这里有篇文章[17]列出了苹果公司 libdispatch 的维护者 Pierre Habouzit 关于 libdispatch 的讨论邮件。
说了一堆共享数据方式的问题,没有体感,下面我说个最近碰到的多线程问题,你也看看排查有多费劲。
3 一个具体多线程问题排查思路
问题是工程引入一个系统库,暂叫 A 库,出现的问题现象是 CoreMotion 不回调,网络请求无法执行,除了全局并发队列会 pending block 外主线程和其它队列工作正常。
第一阶段,排查思路看是否跟我们工程相关,首先看是不是各个系统都有此问题,发现 iOS14 和 iOS13 都有问题。然后把A库放到一个纯净 Demo 工程中,发现没有出问题了。基于上面两种情况,推测只有将A库引入我们工程才会出现问题。在纯净 Demo 工程中,A库使用时 CPU 会占用60%-80%,集成到我们工程后涨到100%,所以下个阶段排查方向就是性能。
第二阶段的打法是看是否是由性能引起的问题。先在纯净工程中创建大量线程,直到线程打满,然后进行大量浮点运算使 CPU 到100%,但是没法复现,任务通过 libdispatch 到全局并发队列能正常工作。
怎么在 Demo 里看到出线程已爆满了呢?
libdispatch 可以使用线程数是有上限的,在 libdispatch 的源码[18]里可以看到 libdispatch 的队列初始化时使用 pthread 线程池相关代码: