回顾了多线程、Runloop相关的重点知识。

比较零碎。

多线程

基本

首先简单回顾几个常见的术语:
同步:只能在当前线程中执行任务,不具备开启新线程的能力。在队列里面的任务完成之前会一直等待。
异步:可以在新的线程中执行任务,具备开启新线程的能力。它不会做任何等待,可以继续执行任务。
串行:一个任务执行完毕后,再执行下一个任务。
并发:多个任务并发(同时)执行,并发队列的并发功能只有在异步函数下才有效。

自己创建的串行队列(非主队列),使用异步方式,会开启新线程执行任务。
并发队列下的异步方式,会开启新线程,并发执行任务。

死锁

dispatch_sync类型

主队列下的同步执行,会产生死锁。

1
2
3
4
5
6
7
8
9
// 在主线程执行task方法
- (void)task {
NSLog(@"a任务");
dispatch_sync(dispatch_get_main_queue(), ^{
// 追加到主队列中按照顺序执行
NSLog(@"b任务");
});
NSLog(@"c任务");
}

因为是同步执行,所以c任务需要等待b任务执行完毕之后才可以执行;又因为是主队列且a任务和c任务都要在主线程执行,所以b任务需要等待(a任务和)c任务执行完毕之后才可以执行;这样a任务与c任务互相等待产生了死锁。

再比如知识小集中的一个例子:

1
2
3
4
5
6
7
8
dispatch_queue_t queue = dispatch_queue_create("com.app.test", NULL);
dispatch_async(queue, ^{
dispatch_sync(queue, ^{
// 向串行队列queue中追加任务B
NSLog(@"B任务");
});
NSLog(@"A任务");
});

向串行队列queue中追加任务B,B任务需要等待A任务执行完毕才能执行,由于是串行队列下的同步执行,A任务需要等待B任务执行完毕才能执行,二者互相等待产生死锁。

可以总结,使用sync函数向当前串行队列中添加任务,会卡住当前的串行队列(产生死锁)

dispatch_once类型

1
2
3
4
5
6
7
void test() {
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
test();
});
printf("This is a test");
}

dispatch_once的递归调用也会产生死锁。

onceToken在第一次执行block之前,其值将由NULL变为指向第一个调用者的指针(&dow)。如果在block完成之前,有其它的调用者进来,则会把这些调用者放到一个waiter链表中(走else分支),直到block执行完成。waiter链中的每个调用者都会等待一个信号量(dow.dow_sema)。在block执行完成后,除了将onceToken置为DISPATCH_ONCE_DONE外,还会去遍历waiter链中的所有waiter,抛出相应的信号量,以告知waiter们调用结束。
递归调用test()时,第二次调用作为一个waiter,在等待block完成,而block的完成依赖于test()的执行完成,这就成了一个死锁。

下一篇文章看一下GCD源码吧。。

dispatch_apply

tutuge介绍了一个例子

1
2
3
4
5
6
7
8
9
dispatch_queue_t queue = dispatch_queue_create("com.app.test", DISPATCH_QUEUE_SERIAL);

dispatch_apply(3, queue, ^(size_t i) {
NSLog(@"apply loop outside %zu", j);
// 死锁
dispatch_apply(3, queue, ^(size_t j) {
NSLog(@"apply loop inside %zu", j);
});
});

dispatch_apply是将block追加到指定的Queue中执行指定次数,并等待全部block执行完毕,也即它用的是dispatch_sync。这自然解释了为啥会死锁了。官方文档上苹果推荐的使用方式是配合dispatch_get_global_queue全局队列。

线程同步

iOS中的线程同步方案有很多:

  • OSSpinLock(自旋锁,忙等)
  • os_unfair_lock(休眠,iOS10+)
  • pthread_mutex(互斥锁,可递归,可条件,休眠)
  • dispatch_semaphore(信号量)
  • dispatch_queue(DISPATCH_QUEUE_SERIAL)(串行同步)
  • NSLock(封装mutex)
  • NSRecursiveLock(封装mutex递归锁)
  • NSCondition(封装mutex条件锁)
  • NSConditionLock(封装NSCondition,可设置条件值)
  • @synchronized(封装mutex递归锁,语法简单)

ibireme在《不再安全的OSSpinLock》中介绍了自旋锁(比如OSSpinLock)优先级翻转的问题:
假定有优先级较高的线程a,优先级较低的线程b,一开始由线程b执行任务,则加锁,线程a处于忙等状态。但是由于线程a的优先级较高,获得的时间片也较多,那么线程b的时间片比较少,可能一直无法往下执行,无法释放锁。于是造成线程a一直处于忙等状态,而优先级较低的线程b却在一直执行任务,便出现了优先级反转。
当使用互斥锁时就不会有这种问题:线程a不会忙等,而是休眠,也即不会占用CPU资源,这样线程b的任务很快执行完毕并释放锁,线程a的任务就得到执行了。

什么情况使用自旋锁比较划算?

  • 预计线程等待锁的时间很短
  • 加锁的代码(临界区)经常被调用,但竞争情况很少发生
  • CPU资源不紧张
  • 多核处理器

什么情况使用互斥锁比较划算?

  • 预计线程等待锁的时间较长
  • 单核处理器
  • 临界区有IO操作
  • 临界区代码复杂或者循环量大
  • 临界区竞争非常激烈

在开发中常用的是信号量,性能较高(次于OSSpinLock和os_unfair_lock)且方便。

存取方法加锁

nonatomic与atomic是很常见的话题。atomic保证了getter和setter的原子性,方法内部是线程安全的。但是不能保证使用属性过程中的线程安全。而大多数情况下,需要保证“使用属性过程中的线程安全”。所以,这就造成我们经常选择nonatomic:
1, 项目中有太多的属性设置取值操作,每次都加锁,消耗性能;
2, 并不是每个属性取值设值操作都需要加锁的,完全可以在真正需要的地方自己加锁;

对于属性来说,最理想的自然是读操作,允许多条线程并发执行,写操作,同一时刻只能一条线程执行。实现方案有两种,读写锁pthread_rwlock或者GCD中的dispatch_barrier_async。个人喜欢第二种。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@synthesize name = _name;
- (void)setName:(NSString *)name {
dispatch_barrier_sync(self.propertyQueue, ^{
if (name != _name) {
_name = name;
}
});
}

- (NSString *)name {
__block NSString *name = nil;
dispatch_sync(self.propertyQueue, ^{
name = _name;
});
return name;
}

Runloop

原理

Runloop与线程
每条线程都有唯一的一个与之对应的RunLoop对象,RunLoop保存在一个全局的Dictionary里,线程作为key,RunLoop作为value。线程刚创建时并没有RunLoop对象,RunLoop会在第一次获取它时创建,
会在线程结束时销毁。主线程的RunLoop已经自动获取(创建),子线程默认没有开启RunLoop。

Runloop的Mode
CFRunLoopModeRef代表RunLoop的运行模式。一个RunLoop包含若干个Mode,每个Mode又包含若干个Source0/Source1/Timer/Observer。RunLoop启动时只能选择其中一个Mode,作为currentMode
如果需要切换Mode,只能退出当前Loop,再重新选择一个Mode进入(这样做可以保证专心处理一个Mode下的事件)。不同组的Source0/Source1/Timer/Observer能分隔开来,互不影响。如果Mode里没有任何Source0/Source1/Timer/Observer,RunLoop会立马退出。

kCFRunLoopDefaultMode是App的默认Mode,通常主线程是在这个Mode下运行。UITrackingRunLoopMode是界面跟踪 Mode,用于ScrollView追踪触摸滑动,保证界面滑动时不受其他Mode影响。

Mode下的Source0/Source1/Timer/Observer
Source0

  • 触摸事件处理
  • performSelector:onThread:

Source1

Timers

  • NSTimer
  • performSelector:withObject:afterDelay:(内部也是定时器)

Observers

  • 用于监听RunLoop的状态
  • UI刷新(BeforeWaiting)
  • Autorelease pool(BeforeWaiting)

Runloop的内部逻辑
01、通知Observers:进入Loop
02、通知Observers:即将处理Timers
03、通知Observers:即将处理Sources
04、处理Blocks
05、处理Source0(可能会再次处理Blocks)
06、如果存在Source1,就跳转到第8步
07、通知Observers:开始休眠(等待消息唤醒)
08、通知Observers:结束休眠(被某个消息唤醒)
(01)> 处理Timer
(02)> 处理GCD Async To Main Queue
(03)> 处理Source1
09、处理Blocks
10、根据前面的执行结果,决定如何操作
(01)> 回到第02步
(02)> 退出Loop
11、通知Observers:退出Loop

Runloop状态监听

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
 // 创建Observer
CFRunLoopObserverRef observer = CFRunLoopObserverCreateWithHandler(kCFAllocatorDefault, kCFRunLoopAllActivities, YES, 0, ^(CFRunLoopObserverRef observer, CFRunLoopActivity activity) {
switch (activity) {
case kCFRunLoopEntry: {
CFRunLoopMode mode = CFRunLoopCopyCurrentMode(CFRunLoopGetCurrent());
NSLog(@"kCFRunLoopEntry - %@", mode);
CFRelease(mode);
break;
}

case kCFRunLoopExit: {
CFRunLoopMode mode = CFRunLoopCopyCurrentMode(CFRunLoopGetCurrent());
NSLog(@"kCFRunLoopExit - %@", mode);
CFRelease(mode);
break;
}

default:
break;
}
});

// 添加Observer到RunLoop中
CFRunLoopAddObserver(CFRunLoopGetMain(), observer, kCFRunLoopCommonModes);
// 释放
CFRelease(observer);

休眠的实现原理
依靠mach_msg()函数,做到用户态处理消息,内核态等待消息,没有消息就让线程休眠。

应用

1,控制线程周期

借助Runloop实现线程保活:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
@interface YAPermenantThread()
@property (nonatomic, strong) NSThread *innerThread;
@end
@implementation YAPermenantThread
- (instancetype)init {
if (self = [super init]) {
_innerThread = [[NSThread alloc] initWithBlock:^{
CFRunLoopSourceContext context = {0};
CFRunLoopSourceRef source = CFRunLoopSourceCreate(kCFAllocatorDefault, 0, &context);
CFRelease(source);
// 第3个参数:returnAfterSourceHandled,设置为true,代表执行完source后就会退出当前loop
CFRunLoopRunInMode(kCFRunLoopDefaultMode, 1.0e10, false);
}];
[_innerThread start];
}
return self;
}

- (void)dealloc {
[self stop];
}

- (void)executeTask:(void (^)(void))task {
if (!self.innerThread || !task) return;
[self performSelector:@selector(executeInnerTask:) onThread:self.innerThread withObject:task waitUntilDone:NO];
}

- (void)stop {
if (!self.innerThread) return;
[self performSelector:@selector(stopThread) onThread:self.innerThread withObject:nil waitUntilDone:YES];
}

#pragma mark - private methods
- (void)stopThread {
CFRunLoopStop(CFRunLoopGetCurrent());
self.innerThread = nil;
}

- (void)executeInnerTask:(void (^)(void))task {
if (task) task();
}
@end

2,解决NSTimer在UIScrollView滑动时停止工作

修改Runloop的Mode即可。

3,监控应用卡顿

常见原因:
死锁:主线程拿到锁 A,需要获得锁 B,而同时某个子线程拿了锁 B,需要锁 A,这样相互等待就死锁了。
抢锁:主线程需要访问 DB,而此时某个子线程往 DB 插入大量数据。通常抢锁的体验是偶尔卡一阵子,过会就恢复了。
主线程大量 IO:主线程为了方便直接写入大量数据,会导致界面卡顿。
主线程大量计算:算法不合理,导致主线程某个函数占用大量 CPU。
大量的 UI 绘制:复杂的 UI、图文混排等,带来大量的 UI 绘制。

主线程绝大部分计算或者绘制任务都是以Runloop为单位发生。单次Runloop如果时长超过16ms,就会导致UI体验的卡顿。 ——by mrpeak前辈

方案一

创建一个子线程,监控主线程的活动情况,如果发现有卡顿,就将堆栈dump下来。如何监控?主要利用Runloop状态监听,kCFRunLoopExit的时间,减去kCFRunLoopEntry的时间,获得一次Runloop所耗费的时间。
实际上,主线程的RunLoop是在应用启动时自动开启的,主线程中的block、交互事件、以及其他任务都是在kCFRunLoopBeforeSources到kCFRunLoopBeforeWaiting之间执行,所以可以在即将开始执行Sources时,记录一下起始时间,并标记任务状态为YES,将要进入睡眠状态时,标记置为NO。

第一种方式是添加一个定时器,设定为2秒一次回调,也即每隔2秒计算一下时间差看是否会有卡顿发生。有同学已经给出了方法

每次定时器回调时,若任务状态仍然为YES(说明Runloop在kCFRunLoopBeforeSources和kCFRunLoopBeforeWaiting之间),则判断时间有没有超过阈值:

1
2
3
4
5
6
7
8
9
static void RunLoopTimerCallBack(CFRunLoopTimerRef timer, void *info) {
YAFluencyMonitorManager *manager = (__bridge YAFluencyMonitorManager *)info;
if (!manager.excuting) return;
NSTimeInterval excuteTime = [NSDate.date timeIntervalSinceDate:manager.startDate];
if (excuteTime >= manager.fault) {
NSLog(@"线程卡顿了%f秒", excuteTime);
[manager handleStackInfo];
}
}

比如,我从数据库中读出1000条数据,再把这1000条数据写到数据库中,这些操作都在主线程进行,利用第三方框架CrashReporter可以看到函数调用栈:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
Thread 0:
0 libsystem_kernel.dylib 0x00000001874cb42c fsync + 8
1 libsqlite3.dylib 0x00000001893549ec sqlite3_randomness + 2520
2 libsqlite3.dylib 0x000000018934a780 sqlite3_free_table + 62008
3 libsqlite3.dylib 0x0000000189335d0c sqlite3_value_text + 23504
4 libsqlite3.dylib 0x0000000189301f44 sqlite3_finalize + 3608
5 libsqlite3.dylib 0x000000018932e774 sqlite3_step + 60828
6 libsqlite3.dylib 0x000000018931fb5c sqlite3_step + 388
7 FMDB 0x00000001051b39d0 -[FMDatabase executeUpdate:error:withArgumentsInArray:orDictionary:orVAList:] + 3152
8 FMDB 0x00000001051b4038 -[FMDatabase executeUpdate:] + 96
9 FMDB 0x00000001051b4ab8 -[FMDatabase commit] + 48
10 FMDB 0x00000001051bb270 __46-[FMDatabaseQueue beginTransaction:withBlock:]_block_invoke + 592
11 libdispatch.dylib 0x00000001053d2bd8 _dispatch_client_callout + 16
12 libdispatch.dylib 0x00000001053e1858 _dispatch_lane_barrier_sync_invoke_and_complete + 124
13 FMDB 0x00000001051bafd8 -[FMDatabaseQueue beginTransaction:withBlock:] + 176
14 FMDB 0x00000001051bb2dc -[FMDatabaseQueue inTransaction:] + 80
15 Splash 0x0000000104cb0be4 +[YAFeedPhotoDBManager savePhotoListToDataBase:] + 388
16 Splash 0x0000000104c9f380 __65-[YAFeedAPIManager parseAPIManager:successResult:isFromDataBase:]_block_invoke + 80
17 Splash 0x0000000104cb09d8 __73+[YAFeedPhotoDBManager loadPhotoListWithStartTime:limitCount:completion:]_block_invoke + 60
18 libdispatch.dylib 0x00000001053d17fc _dispatch_call_block_and_release + 24
19 libdispatch.dylib 0x00000001053d2bd8 _dispatch_client_callout + 16
20 libdispatch.dylib 0x00000001053e0c34 _dispatch_main_queue_callback_4CF + 1316
21 CoreFoundation 0x000000018764f3a8 <redacted> + 12
22 CoreFoundation 0x000000018764a39c <redacted> + 2004
23 CoreFoundation 0x00000001876498a0 CFRunLoopRunSpecific + 464
24 GraphicsServices 0x00000001915a1328 GSEventRunModal + 104
25 UIKitCore 0x000000018b73a740 UIApplicationMain + 1936
26 Splash 0x0000000104cac944 main + 120
27 libdyld.dylib 0x00000001874d4360 <redacted> + 4

可以看到,问题就在+[YAFeedPhotoDBManager savePhotoListToDataBase:]这行代码了。

方案二

第二种方式主要是对检测粒度的改进。比如,卡顿阈值T=500ms、卡顿次数N=1,可以判定为单次耗时较长的一次有效卡顿;而卡顿阈值T=50ms、卡顿次数N=5,可以判定为频次较快的一次有效卡顿。这样就需要对每次Runloop时间段做处理。minjing_linlv同学有给出Demo,作者没有使用定时器来定时监测卡顿状况,而是“实时计算两个状态区域之间的耗时是否到达某个阀值”。

把代码down下来试了一试,发现作者是把卡顿耗时和卡顿次数两个条件综合起来判断的,耗时的实现是利用dispatch_semaphore_wait函数,设置了超时参数。

不过代码这块有几个地方值得商榷:
1,子线程卡顿监控最好开一个独立的常驻线程,而作者却用了一个全局队列。
2,“发生一次有效的卡顿回调函数”,设置这种接口而且暴露给外界不知道意义何在,发生卡顿难道不是这个单例自己处理并上报吗?
3,在setter方法中手动调用willChangeValueForKey和didChangeValueForKey,貌似是为了手动触发KVO,但是却又不重写automaticallyNotifiesObserversForName方法,那这么做图啥?

只看作者思路,这种方式较方案一显得细腻,还是挺棒的。

方案三

其实最棒的还是《微信iOS卡顿监控系统》这篇文章介绍的。

总结一下key point:
1,CPU 占用超过了100%和主线程 Runloop 执行了超过2秒综合起来判断是一次卡顿发生了(需要双核iPhone)。
2,遇到相同的卡顿堆栈,按照斐波那契数列将检查时间递增,避免同一个卡顿写入多个文件,也避免检测线程围着同一个卡顿空转。
3,主要根据堆栈的最内层归类,这样能够将同一原因的卡顿归类起来。
4,抽样上报,减小后台压力。

4,应用起死回生

App出现异常后,手动创建一个循环,在这个循环里面跑Runloop的所有mode。主要就是下面这段代码:

1
2
3
4
5
6
7
8
// 取出runLoop所有运行的mode
NSArray *allModes = CFBridgingRelease(CFRunLoopCopyAllModes(CFRunLoopGetCurrent()));
while (1) {
for (NSString *mode in allModes) {
//在每个 mode 中轮流运行至少0.001 秒
CFRunLoopRunInMode((CFStringRef)mode, 0.001, false);
}
}

但是,如果在自己手动创建的循环里面又一次出现crash,那神仙也没办法了。

整理一下,是这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
@interface YACrashHandlerManager ()
@property (nonatomic, assign) BOOL shouldIgnore;
@end
@implementation YACrashHandlerManager

- (void)handleException:(NSException *)exception {
NSString *message = [NSString stringWithFormat:@"崩溃原因如下:\n%@\n%@",
[exception reason],
[[exception userInfo]
objectForKey:kCaughtExceptionStackInfoKey]];
NSLog(@"%@",message);
// 弹出弹窗, 设置shouldIgnore的值
// ....

NSArray *allModes = CFBridgingRelease(CFRunLoopCopyAllModes(CFRunLoopGetCurrent()));
while (!self.shouldIgnore) {
for (NSString *mode in allModes) {
CFRunLoopRunInMode((CFStringRef)mode, 0.001, false);
}
}
configCatchExceptionHandler(YES);
if ([[exception name] isEqual:kSignalExceptionName]) {
kill(getpid(), [[[exception userInfo] objectForKey:kSignalKey] intValue]);
} else {
[exception raise];
}
}
@end

static NSArray *getBacktrace() {
void *callstack[128];
int frames = backtrace(callstack, 128);
char **strs = backtrace_symbols(callstack, frames);
NSMutableArray *backtrace = [NSMutableArray arrayWithCapacity:frames];
for (int i = 0; i < frames; i++) {
[backtrace addObject:[NSString stringWithUTF8String:strs[i]]];
}
free(strs);
return backtrace;
}

static void configCatchExceptionHandler(BOOL isNil) {
void (*handler)(int) = SIG_DFL;
NSUncaughtExceptionHandler *exc = NULL;
if (!isNil) {
handler = signalHandler;
exc = &handleException;
}
NSSetUncaughtExceptionHandler(exc);
signal(SIGABRT, handler);
signal(SIGILL, handler);
signal(SIGSEGV, handler);
signal(SIGFPE, handler);
signal(SIGBUS, handler);
signal(SIGPIPE, handler);
}

static void handleException(NSException *exception) {
NSException *customException = [NSException exceptionWithName:[exception name] reason:[exception reason] userInfo:@{kCaughtExceptionStackInfoKey: [exception callStackSymbols]}];
[[YACrashHandlerManager sharedManager] performSelectorOnMainThread:@selector(handleException:) withObject:customException waitUntilDone:YES];
}

static void signalHandler(int signal) {
NSString *stack = [NSString stringWithFormat:@"%@", getBacktrace()];
NSException *customException = [NSException exceptionWithName:kSignalExceptionName
reason:[NSString stringWithFormat:NSLocalizedString(@"Signal %d was raised.", nil), signal]
userInfo:@{kSignalKey:[NSNumber numberWithInt:signal], kCaughtExceptionStackInfoKey: stack}];
[[YACrashHandlerManager sharedManager] performSelectorOnMainThread:@selector(handleException:) withObject:customException waitUntilDone:YES];
}

自实现

这篇文章有空写一点,结果发出来都12月份了。太忙了,hold不住。。。立个flag,2020年Q1自实现一个Runloop。

网上搜了几篇很棒的文章:
iOS 并发编程之 Operation Queues
iOS 多线程编程知识整理
iOS 多线编程之线程安全
谈 iOS 的锁
@synchronized,这儿比你想知道的还要多
Threading Programming Guide(2)
RunLoop 源码阅读
RunLoop深入学习笔记