Online聊天卡顿崩溃原因和聊天数据源的优化
测试反馈的问题有一些是界面卡顿,测试的问题不易重现和追查困难。
怎么判断主线程是不是发生了卡顿?
一般来说,用户感受得到的卡顿大概有三个特征:
- FPS 降低
- CPU 占用率很高
- 主线程 Runloop 执行了很久
我们先思考一下,界面卡顿是由哪些原因导致的?
- 死锁:主线程拿到锁 A,需要获得锁 B,而同时某个子线程拿了锁 B,需要锁 A,这样相互等待就死锁了。
- 抢锁:主线程需要访问 DB,而此时某个子线程往 DB 插入大量数据。通常抢锁的体验是偶尔卡一阵子,过会就恢复了。
- 主线程大量 IO:主线程为了方便直接写入大量数据,会导致界面卡顿。
- 主线程大量计算:算法不合理,导致主线程某个函数占用大量 CPU。
- 大量的 UI 绘制:复杂的 UI、图文混排等,带来大量的 UI 绘制。
- 关于UI绘制的优化之前就已完善,在展示数据源之前就已把要展示的数据源都先计算好布局,等UI展示时直接用计算好的布局直接展示,不需要在UI展示刷新时重复计算UI布局数据。
- 关于消息列表的大量数据更新(用户接收大量消息和发送消息需要更新数据库,会产生抢锁和写入读取及更新大量数据的问题)已用计时器定时按需刷新UI来缓解同时更新大量数据时,更新的全量数据同时刷新UI带来的界面刷新压力。
最近Online更新的是关于聊天页面的数据源优化
聊天页面最基础的便是聊天当中的数据源(聊天信息)了,iOS可变数据源的容器通常都是可变数组,但可变数组 NSMutableArray 不是线程安全的,这就带来一个问题,主线程我们多次操作 都没有问题,但是多线程下短时间内有大量的读写操作的时候是否会引起数据的错乱?只要简单测试下 答案就会不言而喻,NSMutableArray在多线程下操作很容易引起数组越界从而导致crash。
开源工具YYThreadSafeArray的问题
先来看开源界常用的开源工具类YYKit中的YYThreadSafeArray,旨在提供线程安全的数组,其原理是继承NSMutableArray的,并且对其中必要的方法加锁来保证线程安全。但是YYThreadSafeArray依然有一些多线程方面的问题。
诚如YYThreadSafeArray 注释的那样 Fast enumerate(for..in) and enumerator is not thread safe (快速for..in枚举和枚举器不是线程安全的)
问题1:调用枚举方法导致死锁
1 | Fast enumerate(for..in) and enumerator is not thread safe, |
如下调用方式导致死锁
1 | YYThreadSafeArray *array = [[YYThreadSafeArray alloc] initWithObjects:@"hello world", nil]; |
原因分析:YYThreadSafeArray使用加锁的方式保证线程安全。加的锁是信号量:dispatch_semaphore。
1 | #define LOCK(...) dispatch_semaphore_wait(_lock, DISPATCH_TIME_FOREVER); \ |
dispatch_semaphore并不是可重入的。因此,遇到重入的情况,就会发生死锁问题。举的例子只是其中一个死锁场景。
YY选择使用dispatch_semaphore的原因,可能是判断dispatch_semaphore的执行效率较高。可以参考YY对各种锁的效率测评:https://blog.ibireme.com/2016/01/16/spinlock_is_unsafe_in_ios/
修复方法:
使用pthread_mutex替代dispatch_semaphore。pthread_mutex有参数可以设置为可重入。在YY测试的执行效率上,可重入的pthread_mutex是可重入锁中效率较高的一个。
问题2:for-in循环的线程不安全
YYThreadSafeArray在只用for-in循环时,是无法保证线程安全的。
调用方式:
1 | YYThreadSafeArray *threadSafeArray = [[YYThreadSafeArray alloc] init]; |
此时大概率会抛出异常:
1 | *** Terminating app due to uncaught exception 'NSGenericException', reason: '*** Collection <YYThreadSafeArray: 0x60000165aa20> was mutated while being enumerated.' |
原因分析:
在执行for-in循环时,会调用NSArray的如下方法
1 | - (NSUInteger)countByEnumeratingWithState:(NSFastEnumerationState *)state |
for-in循环之所以有更高的效率,是因为在循环时,它并非每次都访问NSArray数组,而是直接将一段NSArray数组,当作一个C数组来访问,直接便利这个C数组。对于NSArray来说,state.itemsPtr字段将返回这个C数组的指针。
但在这个方法中加锁,锁住的只是寻找这个C数组的过程,并不能锁住整个for-in循环过程。所以,当多线程进入时,会抛出was mutated while being enumerated异常。
修复方法:
这个问题没有想到优雅的修复方法。有2种不怎么优雅的方式可选:
- 让调用方用enumerateObjectsUsingBlock这种遍历方法替代for-in循环,但在效率上肯定有折损;
- 将YYThreadSafeArray种的锁暴露出去,让业务方在for-in循环时自行加锁。
但这两种方法都依赖于调用方以一个特定的姿势来调用。如果调用姿势无法保证,YYThreadSafeArray也无法保证线程安全了。
那如何实现一个线程安全的NSMutabeArray,以保证多个线程对数组操作(遍历,插入,删除)的安全?(对应Online聊天中各线程接受消息发送消息更新UI场景)
在实际开发中,有多个类可能在不同线程中同时操作消息数组,除了插入删除外,还有遍历,并且线程A在遍历时,线程B可能直接把数组给清空了,直接crash。 还有资源竞争造成的死锁。
要解决这个线程安全的问题,需要明白两个知识点
1.nonatomic 和atomic
这两个关键字是用来修饰成员变量的。前者是非原子操作即线程可以随便访问成员变量,后者是原子操作即线程访问按照一定的规则进行。
nonatomic:
如果只存在单个线程访问成员变量,用它修饰是非常不错的,因为没有对访问进行线程加锁,效率非常高。但是正因为没有加锁,所以可能同时进行读写,导致不可预期的错误。
atomic:
用atomic修饰成员变量,会给成员变量的getter 和 setter方法加锁,使访问每次只能进行一个,避免多个线程同时操作成员变量,所以适用于多线程访问成员变量的场景。
虽然atomic修饰的成员变量在多线程去访问时不会出现错误,但结果不一定准确:
比如说有一个成员变量name,当a线程去getter name的值,同时有b线程和c线程对name 进行setter值,那么name的值就不确定了,可能是b线程操作之前的值,也有可能是b线程操作之后的值,也有可能是c线程操作之后的值。
2.dispatch_barrier_async 和dispatch_barrier_sync
这是GDC里面的两个栅栏方法,需要配合队列使用。其作用是拦住前面添加到队列的任务,让这些任务执行完成,然后再执行栅栏里的任务,两个方法的区别是:
- dispatch_barrier_async不阻塞主线程;
- dispatch_barrier_sync阻塞主线程,非得等到栅栏里的任务执行完成程序才能执行主线程的任务。
- 另外一点需要明确的是,栅栏函数只对主队列和自身所在队列有影响,其他队列不受影响。
如果在队列中的栅栏之后再添加任务,则此任务要等到栅栏里的任务完成后才会执行。
看一段代码就一目了然了
先使用 dispatch_barrier_sync
1 | dispatch_queue_t concurrent_queue = dispatch_queue_create("concurrent", DISPATCH_QUEUE_CONCURRENT); |
打印结果如下
1 | 2019-07-31 17:22:17.599644+0800 ArrayTest[14396:408598] 任务一0 |
再使用dispatch_barrier_async
1 | // 这里使用异步栅栏函数 |
打印结果如下:
1 | 2019-07-31 17:25:28.130839+0800 ArrayTest[14457:413975] 任务一0 |
实现线程安全的数组 (使用dispatch_barrier函数)
通过上面的知识点可以知道,一个用nonatomic修饰的数组成员变量,它的线程访问是不受限制的,当然我们也已经知道用atomic修饰也并不合适,因为线程访问得到的值依然不够准确。
那要实现线程安全的数组,该怎么办呢?使用dispatch_barrier函数可以解决。
将数组的写(插入、修改、删除)操作放进队列中dispatch_barrier函数中,这样当进行写的操作时,会先等待前面的读的任务完成后再执行写操作;而且后面的读任务也要等待dispatch_barrier中的写操作执行完成后才会被执行。
代码
创建一个类给它添加一个可变数组的成员变量,给这个类添加访问数组成员变量的所有方法。不多说,看代码:
.h文件
1 | #import <Foundation/Foundation.h> |
.m文件
凡涉及更改数组中元素的操作,使用异步栅栏块;读取数据使用 同步+并行队列
1 | #import "ZHMutableArray.h" |
这样一个线程安全的数组就创建完成。
数据源保证线程安全的前提下,各项体验优化和bug处理工作也得以顺利进行。