查看原文
其他

货拉拉iOS司机端线程治理总结

司机组iOS 团队 货拉拉技术 2022-12-14

司机组iOS 团队,负责国内货运司机端 iOS APP 开发,同时支撑国内 iOS 业务线的业务基础架构的开发和维护。

背景介绍

  • 由于在过去几年,货拉拉业务高速发展的同时,作为核心业务入口的司机端,同样在以「快」为第一目标实现业务需求迭代,积累了较多的技术债(各项技术指标与业界优秀的app相比都差强人意),并且线上经常会收到司机反馈手机发烫,耗电,crash等等问题。

  • 司机使用的手机相比用户来说性能普遍较差,同时司机的在线时长较高(平均3.5小时),由于以上客观原因的存在,给司机端性能优化带来了巨大的挑战。

综上,线程治理专项应运而生,目的就是降低crash,手机发烫,耗电等问题,尽量给原本并不富裕的内存,雪中送炭。

问题分析

  1. ### 滥用使用全局队列,并且使用了队列的默认优先级

  1. dispatch_async(dispatch_get_global_queue(0, 0), ^{


  2. //TODO


  3. });

开发人员在需要开启线程处理任务时,大多都采用了全局队列默认优先级来处理,所以项目中积累了大量的全局队列默认优先级,导致了一人干活,全家围观。

  1. ### 大量不必要的线程切换

  1. dispatch_async(dispatch_get_global_queue(0, 0), ^{


  2. //loadData


  3. dispatch_async(dispatch_get_main_queue(), ^{


  4. //刷新UI


  5. });


  6. });

一般的业务处理中,用子线程确实可以提高任务处理效率,但是也不能忽视队列切换带来的性能损耗。如果loaddata,是较为耗时的操作,用子线程处理无可厚非,但是仅仅是为了读取本地的一些简单配置或者数据,而开启线程,就有点多余了。(这里的loaddata,需要根据自身业务进行评估,是否有必要开启)

  1. ### 在高并发场景,没有控制并发,而使用了全局队列创建了大量线程

  1. //实时获取位置信息 异步


  2. - (void)getDriverCurrentAddress:(void(^)(HLLAddressComponent *component))complete


  3. {


  4. dispatch_async(dispatch_get_global_queue(0, 0), ^{


  5. __block CLLocation *location;


  6. __block NSDictionary *regeoInfo;


  7. //业务处理


  8. });


  9. }

多个业务请求需要依赖getDriverCurrentAddress异步返回的数据,所以会导致,多个getDriverCurrentAddress并发,然而方法内部并未控制并发,而且还采用了全局队列默认优先级,当业并发大的时候,这里会偶现死锁。

  1. ### 业务使用线程的不合理

  1. dispatch_async(dispatch_get_global_queue(0, 0), ^{


  2. NSMutableArray<NSDictionary *> *imageArray = [NSMutableArray array];


  3. for (NSDictionary *photoDict in readyUploadImageArray) {


  4. // 上传照片


  5. }


  6. });

业务使用线程不合理,业务要求是所有需要上传的图片,并发上传。实际上全局队列默认优先级分配一个线程后,多个任务挤在一个线程,并未达到业务预期的目的。

  1. ### 线程死锁引起的crash

当大面积出现psynchcvwait,semwaitsignal,psynchmutexwait,psynchmutextrylock,dispatchsyncfslow等信息时,可以初步判定为线程死锁。比如:

当然优先级反转也会导致死锁,具体来说,如果一个低优先级的线程获得锁并访问共享资源,这时一个高优先级的线程也尝试获得这个锁,它会处于 spin lock 的忙等状态从而占用大量 CPU。此时低优先级线程无法与高优先级线程争夺 CPU 时间,从而导致任务迟迟完不成、无法释放 lock。导致陷入死锁 。

  1. ### 子线程刷新UI引起的crash

子线程刷新UI的问题,有比较具体的提示信息,还是比较容易发现的。

  1. ### 线程安全引发的crash

由于多线程读写问题的crash比较隐秘,发现难,定位难,所以,当出现pthreadkill,objc_release,malloc: error for object 0x7913d6d0: pointer being freed was not allocated等信息时,可以初步判定为多线程读写问题。

仅仅光靠这些还是不够的,如果没有做特殊的队列处理,还是要做大量的调试,如果发现某处业务可能会被多个线程访问时,也需要重点关注。

方案介绍

  1. ### 采取新的队列管理和分配制度

全局队列默认优先级dispatchgetglobal_queue(0, 0)的滥用,导致了一人干活,全组围观。当大量并发的业务使用了全局队列的默认优先级时,会为此优先级创建远超CPU核数的线程,不仅让CPU疲于奔命,同时还增加了造成线程死锁风险,从而引发crash。

由于货拉拉的业务特点,我们决定为不同的优先级,创建与CPU核数相等的串行队列,通过优先级的合理使用和串行队列的调度,充分利用时间片和多核的效率,同时不出现相关副作用的情况下实现多线程操作。

  1. #import <Foundation/Foundation.h>


  2. NS_ASSUME_NONNULL_BEGIN


  3. @interface HLLQueuePool : NSObject


  4. //与用户交互的任务,这些任务通常跟UI级别的刷新相关,比如动画,cell高度,frame等UI的计算

  5. extern dispatch_queue_t HLLQueueForQoSUserInteractive(void);


  6. //由用户发起的并且需要立即得到结果的任务,比如读取数据(配置,用户信息等)来加载UI,会在几秒或者更短的时间内完成

  7. extern dispatch_queue_t HLLQueueForQoSUserInitiated(void);


  8. //一些耗时的任务,比如复杂的组合的网络请求,图片下载,上传

  9. extern dispatch_queue_t HLLQueueForQoSUtility(void);


  10. //对用户不可见,可以长时间在后台运行,比如,拉取配置,地理位置上报,日志上报等

  11. extern dispatch_queue_t HLLQueueForQoSBackground(void);


  12. //默认,不推荐作为首选使用

  13. extern dispatch_queue_t HLLQueueForQoSDefault(void);


  14. @end


  15. NS_ASSUME_NONNULL_END

业务使用改动小,只需在原有基础上根据业务特点,补充合理的优先级即可。

  1. dispatch_async(HLLQueueForQoSUserInitiated(), ^{


  2. //垃圾机型,读取data,可能会导致卡顿,所以加了个线程。

  3. NSData *data = [NSData dataWithContentsOfURL:[NSURL URLWithString:urlString]];


  4. });

  1. ### 梳理线程并发较大的业务进行重构

业务场景: 当司机端的业务请求依赖getDriverCurrentAddress异步回调的数据

当大量的业务并发,调用getDriverCurrentAddress时,getDriverCurrentAddress方法内部采用全局队列(默认的优先级)生成大量线程去处理数据,从而造成死锁或者线程资源耗尽,crash。

业务重构:

  1. 梳理业务,适当降低并发甚至规避并发。

  2. 当业务并发调用getDriverCurrentAddress时,如果有该业务数据缓存,则直接返回,同时获取新的数据并缓存。如果没有业务缓存,则getDriverCurrentAddress内部只能有一个任务执行,其他的任务需等待回调后一并返回。

  3. ### 线程使用的合理性评估与改造

多线程可以提高系统资源利用率,但是开启多线程需要花费时间(90微妙)和空间(0.5兆),开启的线程过多,CPU频繁的在多个线程中调度会消耗大量的CPU资源,会导致个别线程无法完成任务而假死,并且容易造成数据同步和死锁的问题,所以不要在系统中同时开启过多的子线程。

线程使用的合理性评估标准:

  1. 不可预估完成时间的任务,比如图片上传下载,普通接口请求

  2. 计算量比较大的,比如加解密,数据计算和处理

  3. 有可能卡顿主线程的任务,比如UI的计算与渲染

  4. 如无必要,不要随意开启线程。

  5. ### 死锁问题的重点攻坚

首先我们要了解线程的生命周期:

  1. 新建:实例化线程对象

  2. 就绪:向线程对象发送start消息,线程对象被加入可调度线程池等待CPU调度。

  3. 运行:CPU 负责调度可调度线程池中线程的执行。线程执行完成之前,状态可能会在就绪和运行之间来回切换。就绪和运行之间的状态变化由CPU负责,程序员不能干预。

  4. 阻塞:当满足某个预定条件时,可以使用休眠或锁,阻塞线程执行。sleepForTimeInterval(休眠指定时长),sleepUntilDate(休眠到指定日期),@synchronized(self):(互斥锁)。

  5. 死亡:正常死亡,线程执行完毕。非正常死亡,当满足某个条件后,在线程内部中止执行/在主线程中止线程对象

然后,由于死锁问题比较隐蔽,通常很难发现从而去排查,我们只能通过在bugly和内部的crash系统上,分析堆栈信息:

当发现线程大面积的堆栈出现了psynchcvwait,semwaitsignal,psynchmutexwait,psynchmutextrylock,dispatchsyncfslow等信息时,就可以大胆怀疑线程非正常原因阻塞,而导致的死锁。

最后,因为线程是一把双刃剑,不使用线程就不会造成死锁,就需要根据堆栈信息排查对应的业务:

  1. 锁用的是否合理

  2. 线程的数量是否远超平时的线程数量

  3. 是否使用了NSRecursiveLock,此递归锁不支持多线程递归,因为会造成优先级反转

  4. 排查业务,线程长时间的阻塞,导致任务无法正常执行,也会造成死锁

  5. SCNetworkReachabilityGetFlags,此方法只能在子线程调用,否则会造成主线程同步阻塞

  6. ### 子线程刷新UI的重点排查与治理

为什么子线程刷UI,只是偶现crash呢?因为在苹果现有框架下,刷新UI是一种线程不安全的操作,所以必须放在主线程。放在子线程,恰好竞争同一资源时,才会crash。

所以需要对以下场景,做统一检查处理

  1. h5交互的回调

  2. 二方库,三方库的代理和回调

  3. 通知

  4. kvo相关

  5. 接口回调

因为通知和kvo的触发和处理都在同一线程,如果子线程触发,那么就有可能子线程刷新UI

  1. ### 线程安全问题的梳理与重构

线程安全问题的实质,就是多线程写的问题,严谨的说,多线程读并不会造成线程安全问题,因为只是读取数据,并不会产生错误的结果,即使交错执行读取,最终结果也是正确的。

cpu读写内存是通过数据总线操作的,且只有一个。所以在涉及到多线程读写问题时,对所有的写进行串行或者加锁操作即可,不需要区分数据类型(虽然基本数据类型,多线程写,不会有问题)。

简单提一下锁和串行队列的区别,锁中间的执行操作相当于是串行队列。锁的特点是,锁定范围越小越好,但是锁会造成死锁。gcd串行队列,则不会有死锁的问题。关于用法,仁者见仁。

最后,线程安全问题,甚至比死锁问题还要顽固,顽强。由于祖传代码的原因,不得不对多个业务大类,进行了重构,将数据模型进行了拆分,同时对写这一块做了锁或者串行的操作。

长效机制的建立

线程问题比较头疼,在业务迭代和重构的过程中比较容易出现,如何才能降低线程问题对业务和性能的影响呢?

  1. ### 建立线程数量监控预警体系

pthread库中提供了一个用于监控线程创建、运行、结束、销毁的内省函数。

  1. typedef void (*pthread_introspection_hook_t)(unsigned int event, pthread_t thread, void *addr, size_t size);

在启动时,可以选择启动监控,开始监控线程数量。

  1. enum {


  2. PTHREAD_INTROSPECTION_THREAD_CREATE = 1, //创建线程


  3. PTHREAD_INTROSPECTION_THREAD_START, // 线程开始运行


  4. PTHREAD_INTROSPECTION_THREAD_TERMINATE, //线程运行终止


  5. PTHREAD_INTROSPECTION_THREAD_DESTROY, //销毁线程


  6. };

通过线程状态改变,来记录线程数量。

  1. void pthread_introspection_hook_t(unsigned int event,


  2. pthread_t thread, void *addr, size_t size)

  3. {

  4. //创建线程,则线程数量和线程增长数都加1

  5. if (event == PTHREAD_INTROSPECTION_THREAD_CREATE) {}


  6. //销毁线程,则线程数量和线程增长数都减1

  7. else if (event == PTHREAD_INTROSPECTION_THREAD_DESTROY){}

  8. }

预警上报

  • 当线程数量大于设定的某一阈值时(各业务,根据自己的业务情况进行阈值设定,通常采用平均值),采取预警。

  • 考虑到获取线程还是比较耗费性能的,所以第一阶段,在debug阶段,通过控制台预警,打印,看看使用情况和效果。

  • 后续通过APM收集上报

  1. ### 子线程刷新UI检测

Main Thred Checker (Runtime Issue)

除此之外,需要在xcode中,新增一个断点“Main Thred Checker (Runtime Issue)”

如果有UI崩溃,崩溃点就会出现在UI崩溃的位置。

除此之外,项目在运行时,也可以利用日志重定向匹配Main Thread Checker:开头的错误日志弹框提示。

  1. ### 规范线程使用

  • 业务所有的线程同一使用HLLQueuePool来进行调度,同时设定好和业务匹配的优先级即可,不需要关心调度。

  • 代码review。有线程相关的修改或者提交,需要说明,着重review。

复盘& 总结

本方案于4月份开始落地上线至今,通过数据采集和分析:

  • 涉及到的crash数量大约在26k左右,粗略计算降低了crash率万分之8

  • 线程的平均数量从之前的51.3,降低到现在的41.6,线程损耗大约是原来的81%,性能节省了大约18.7%

线程治理专项的目的,就是降低crash和性能损耗,从复盘数据来看,crash修复情况和性能优化均符合预期。

本次主要从队列的管理和分配,高并发业务的梳理和重构,线程使用的合理性评估与改造,线程相关crash的排查和修复,长效机制的建立几个方面介绍了货拉拉iOS司机端在线程治理方面的实践

希望我们团队遇到的问题以及解决的经验,能够在稳定性治理方面帮助到你。


您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存