相信只要作为一个iOS开发者,基本都接触过synchronized\NSLock来加锁视图解决容器的多线程读写问题,本篇记录一个老iOSer面对线上多线程问题的处理方式和思考。
前言
众所周知,加锁本身就是个超级高危的操作,加锁可能导致死锁、效率降低等问题,
偶现的多线程容器读写crash问题是小,但加锁带来的问题是深远的。
所以面对多线程读写crash问题,我们不能一刀切,不能教条化,要AB测试看影响面。
一、容器多线程读写crash的本质
作为iOS新手,可能会把这个知识点给背下来,但作为老iOSer,我们要能搞清楚多线程读写crash的本质是什么。
之前我分享的时候也说过,在计算机世界,所有的本质归根结底是两个字:内存。
所以这里我们也是从内存的角度出发,聊聊为什么容器(NSMutableDictionary/NSMutableSet)在多线程读写时会导致crash。
(一) 看汇编代码
Debug->Debug WorkFlow-> Always Show Disassembly
在要查看汇编代码的代码中放置断点.然后,当代码到达该断点时,您可以查看汇编代码.
以我们最常用的 NSMutableDictionary 的 setKeyValue 的代码来举例,执行:
[self.redDotDic setObject:@(1) forKey:@"hello"];
断点执行到折行代码时,汇编堆栈如下:
BNTestMultiThreadDemo`-[ViewController viewDidLoad]:
0x104e20000 <+0>: sub sp, sp, #0x50
0x104e20004 <+4>: stp x29, x30, [sp, #0x40]
0x104e20008 <+8>: add x29, sp, #0x40
0x104e2000c <+12>: stur x0, [x29, #-0x8]
0x104e20010 <+16>: stur x1, [x29, #-0x10]
0x104e20014 <+20>: ldur x8, [x29, #-0x8]
0x104e20018 <+24>: add x0, sp, #0x20
0x104e2001c <+28>: str x8, [sp, #0x20]
0x104e20020 <+32>: adrp x8, 9
0x104e20024 <+36>: ldr x8, [x8, #0x18]
0x104e20028 <+40>: str x8, [sp, #0x28]
0x104e2002c <+44>: adrp x8, 8
0x104e20030 <+48>: ldr x1, [x8, #0xf80]
0x104e20034 <+52>: bl 0x104e206c8 ; symbol stub for: objc_msgSendSuper2
0x104e20038 <+56>: ldr x1, [sp, #0x10]
0x104e2003c <+60>: adrp x8, 4
0x104e20040 <+64>: ldr x0, [x8, #0x108]
0x104e20044 <+68>: bl 0x104e206f8 ; objc_msgSend$dictionary
0x104e20048 <+72>: mov x29, x29
0x104e2004c <+76>: bl 0x104e206bc ; symbol stub for: objc_retainAutoreleasedReturnValue
0x104e20050 <+80>: ldr x1, [sp, #0x10]
0x104e20054 <+84>: mov x2, x0
0x104e20058 <+88>: str x2, [sp, #0x8]
0x104e2005c <+92>: ldur x0, [x29, #-0x8]
0x104e20060 <+96>: bl 0x104e20798 ; objc_msgSend$setRedDotDic:
0x104e20064 <+100>: ldr x0, [sp, #0x8]
0x104e20068 <+104>: bl 0x104e20698 ; symbol stub for: objc_release
0x104e2006c <+108>: ldr x1, [sp, #0x10]
0x104e20070 <+112>: ldur x0, [x29, #-0x8]
0x104e20074 <+116>: bl 0x104e20738 ; objc_msgSend$redDotDic
0x104e20078 <+120>: mov x29, x29
0x104e2007c <+124>: bl 0x104e206bc ; symbol stub for: objc_retainAutoreleasedReturnValue
0x104e20080 <+128>: ldr x1, [sp, #0x10]
0x104e20084 <+132>: str x0, [sp, #0x18]
0x104e20088 <+136>: adrp x2, 4
0x104e2008c <+140>: add x2, x2, #0x120
0x104e20090 <+144>: adrp x3, 4
0x104e20094 <+148>: add x3, x3, #0x68 ; @"hello"
0x104e20098 <+152>: bl 0x104e20778 ; objc_msgSend$setObject:forKey:
0x104e2009c <+156>: ldr x0, [sp, #0x18]
0x104e200a0 <+160>: bl 0x104e20698 ; symbol stub for: objc_release
0x104e200a4 <+164>: adrp x0, 4
0x104e200a8 <+168>: add x0, x0, #0x88 ; @"hello world"
0x104e200ac <+172>: bl 0x104e20668 ; symbol stub for: NSLog
0x104e200b0 <+176>: ldp x29, x30, [sp, #0x40]
0x104e200b4 <+180>: add sp, sp, #0x50
0x104e200b8 <+184>: ret
其中和 [self.redDotDic setObject:@(1) forKey:@"hello"];
相关的汇编代码段如下:
(1)ldr x1, [sp, #0x10]
这条指令从栈上加载数据到寄存器 x1。在Objective-C的消息发送机制中,x1 通常用于存储方法的第一个参数,这里可能是之前准备的 setObject: 方法的选择器(selector)。
(2)str x0, [sp, #0x18]
这条指令将寄存器 x0 的内容存储到栈上的某个位置。在这个上下文中,x0 通常包含调用方法的对象,即 self.redDotDic 的值。
(3)adrp x2, 4 和 add x2, x2, #0x120
这两条指令结合起来用于将一个地址加载到寄存器 x2。adrp 加载一个页面的地址,add 则在此基础上添加偏移量。这里 x2 被用来存储 @(1) 的地址,即方法的第二个参数。
(4)adrp x3, 4 和 add x3, x3, #0x68
类似于上面的指令,这两条指令用于将字符串 “hello” 的地址加载到寄存器 x3。在Objective-C的消息发送机制中,x3 用于存储方法的第三个参数,这里是键名 “hello”。
(5)bl 0x104e20778 ; objc_msgSend$setObject:forKey:
这条指令是一个分支链接(Branch and Link),用于调用 objc_msgSend 函数,这是Objective-C用于动态调用方法的核心函数。这里它用于发送 setObject:forKey: 消息到 self.redDotDic 对象。bl 指令同时保存返回地址,以便方法执行完毕后能够返回到调用点继续执行。
———>
看汇编代码的方式我们感知到了,这里我们要获取到几个有用的信息:
- (1)一行OC代码背后对应的是多行汇编代码 —— 没用的屁话
- (2)setKey-Value 操作的参数,被加载到寄存器之后,由setMsg调用setKV从寄存器中取出数据。
(二) 多线程读写crash
有了上面看汇编代码的方式,我们接着从内存的角度聊一下多线程读写crash的原因。
我们首先要明白一个道理:为什么操作系统会crash?
操作系统的目的是保证正确性,当连它都觉得执行某些操作时,给出的结论可能是有问题的,这时它就会crash。
比如呢?
比如以 NSMutableDictionary 为例:有多个线程在操作它,一个线程在removeKey,一个线程在执行mutableCopy。
removeKey 可以被粗略的拆成两个步骤:
- 取址,明确remove的地址B
- remove 数据
mutableCopy 也可以被粗略的拆成两个步骤:
- 取址,明确拷贝的地址范围,[A,C]
- copy 内存值
想一下,执行mutableCopy时,是在对一整块内存进行拷贝,拷贝前就已经规划好的范围[A,C],结果在拷贝过程中,
[A,C]之间突然有个B区域说不再是 NSMutableDictionary 的元素了,那 mutableCopy这个行为直接就懵逼了,系统就直接报错crash了。
以此类推,我们可以感知到:
- 并发读 + 写 -> 导致crash
- 并发写 -> 导致crash
二、处理多线程读写crash的最佳实践
很多时候面对多线程读写crash问题,在量非常少的情况,有的业务宁愿让它在线上跑着,也不会想着统一来加锁(synchronized/NSLock)进行处理。
因为加锁的后果更严重,相当于捡了芝麻丢了西瓜。
所以我们这里要摸索的,就是怎么样,才能轻巧的捡起来芝麻,而且不会丢西瓜。
(一) 规避多线程crash的性能统计
我们有 3 种有效的保证多线程读写安全的方法,那么应该如何选择呢?
- @synchronized(token) 使用简单,可读性强,但是性能消耗大
- NSLock 性能消耗小于 @synchronized(token) ,但使用不当,可能引起死锁
- GCD 性能最优,且在复杂代码逻辑下的多线程读写时,可有效保障所需代码的串行执行,避免逻辑出错。
(二) 使用GCD规避多线程读写crash问题
关于GCD的基础,这里就不过多赘述了,在使用GCD之前,需要你对 同步/异步 和 多线程 有个比较清晰的认识。
处理多线程读写crash的核心目标是: 保证同一容器的操作按顺序执行,在此基础上希望获取最高的执行效率。
如果都在一个线程,确实没有多线程读写的问题了,也就是不会crash了。
至于效率的问题,有两个可以优化的角度:
- 串行 和 并行 : 决定了代码执行顺序,是有序还是无序。
- 同步 和 异步 : 决定是否要loading,是否要等任务完成(要处理的就是同步和异步带来的值为nil的问题)
示例1: 读写「子线程串行执行」
写方法使用 dispatch_async ,读方法使用 dispatch_sync,例如:
_ioQueue = dispatch_queue_create("ioQueue", DISPATCH_QUEUE_SERIAL);
- (void)setSafeObject:(id)object forKey:(NSString *)key {
key = [key copy];
dispatch_async(self.ioQueue, ^{
if (key && object) {
[_dic setObject:object forKey:key];
}
});
}
- (id)getSafeObjectForKey:(NSString *)key {
__block id result = nil;
dispatch_sync(self.ioQueue, ^{
result = [_dic objectForKey:key];
});
return result;
}
示例2: 读子线程执行,写串行执行
如果想进一步提升读写效率,可考虑只一个线程写,但允许多线程读:
_ioQueue = dispatch_queue_create("ioQueue", DISPATCH_QUEUE_CONCURRENT);
- (void)setSafeObject:(id)object forKey:(NSString *)key {
key = [key copy];
// 会等待barrier之前的block执行完成后才执行
dispatch_barrier_async(self.ioQueue, ^{
if (key && object) {
[_dic setObject:object forKey:key];
}
});
}
- (id)getSafeObjectForKey:(NSString *)key {
__block id result = nil;
dispatch_sync(self.ioQueue, ^{
result = [_dic objectForKey:key];
});
return result;
}
3. 关乎写数据 同步sync/异步async 的思考
有一些朋友可能对写数据同步和异步搞不太清楚,不太清楚什么时候应该同步什么时候应该异步。
我这里也说一下:
数据从0到1的
- 不会立马有同步代码使用,可以使用异步
- 因为本地本来就没有数据,晚个时钟周期也问题不大。
- 立马会有同步代码使用,不可以使用异步
- 会导致读不到数据
- 不会立马有同步代码使用,可以使用异步
数据从0到1的
- 之前也说过,无论是后台清数据,还是客户端清数据,都要慎重,慎重的代驾就是一定要同步,一定要loading。
4. 关乎 串行/并行 的思考
DISPATCH_QUEUE_SERIAL 保证串行, DISPATCH_QUEUE_CONCURRENT 允许并行,
对于客户端而言,再并发量也不会像后台一样如此高量级,
DISPATCH_QUEUE_CONCURRENT 的并行机制的无序化会让代码debug也比较麻烦。
所以对于客户端而言,如无必要,不要使用 DISPATCH_QUEUE_CONCURRENT ,一般情况下 DISPATCH_QUEUE_SERIAL 已经很够用了。
当然使用 并行队列 + 栅栏函数 也是一种可行的方式。
5. 在iOS中解决多线程读写crash问题时,是使用GCD切线程带来的性能损耗大? 还是使用 synchronized 加锁带来的损耗大?
在iOS开发中,处理多线程读写安全的问题时,确实需要权衡性能和线程安全之间的关系。我们可以从两个常用的方法:使用Grand Central Dispatch (GCD) 和使用 @synchronized 来进行比较。
- 使用GCD
GCD 是一个强大的库,用于优化应用程序以便使用多核处理器以及管理多线程。GCD 提供了队列(Queue)的概念,主要有两种队列:串行队列和并行队列。对于处理多线程读写操作,通常使用串行队列或者并行队列配合栅栏函数(barrier)。
串行队列:保证了任务按顺序执行,自然地避免了多线程冲突,但在处理大量或复杂的任务时可能会影响性能。
并行队列 + 栅栏函数:并行队列允许多个读操作同时进行,但通过栅栏函数可以控制写操作在没有其他读写操作执行时才进行,这样可以有效地提高性能,同时保证线程安全。
使用GCD的性能损耗主要来自于线程的创建和上下文切换,但由于GCD背后的优化(如线程池管理),这种损耗相对较小。
- 使用 @synchronized
@synchronized 是 Objective-C 提供的一个简单易用的锁机制,它可以保证被它包围的代码块在同一时间只有一个线程可以执行。这种方式的实现简单,但可能带来较大的性能开销,因为每次进入锁定状态时,都会有一定的等待和上下文切换的开销。
性能损耗:当多个线程尝试访问同一资源时,@synchronized 会造成线程阻塞,等待锁的释放。在高并发的情况下,这种等待可能会显著影响性能。
总结
总的来说,使用GCD(特别是并行队列配合栅栏函数)通常会提供更好的性能,尤其是在需要频繁读写操作且读操作远多于写操作的场景中。这是因为它允许多个读操作同时进行,只在写操作时才进行同步。
而使用 @synchronized 虽然编码简单,但在高并发场景下可能会成为性能瓶颈。因此,如果考虑到应用的性能,推荐使用GCD来处理复杂的多线程同步问题。
6. 栅栏函数
在GCD中,栅栏函数(Barrier)是一种特殊类型的任务,可以在并行队列中创建一个同步点。当栅栏任务执行时,它会等待之前提交到并行队列的所有任务完成后才开始执行。栅栏任务完成后,队列才会继续执行后续的任务。这使得栅栏函数非常适合用来处理读写安全问题,尤其是当你需要在多个并发读取操作和写入操作之间同步数据时。
dispatch_queue_t queue = dispatch_queue_create("com.example.myqueue", DISPATCH_QUEUE_CONCURRENT);
// 添加读任务
dispatch_async(queue, ^{
NSLog(@"Read task 1");
});
dispatch_async(queue, ^{
NSLog(@"Read task 2");
});
// 添加栅栏函数进行写操作
dispatch_barrier_async(queue, ^{
NSLog(@"Write task");
});
// 添加更多的读任务
dispatch_async(queue, ^{
NSLog(@"Read task 3");
});
在这个例子中,所有的读任务(1和2)会在写任务之前执行,写任务会等待前面的任务完成后才执行,而写任务之后的读任务(3)会等待写任务完成后才执行。
7. GCD的线程切换开销
GCD管理着一个线程池,这意味着它可以重用已经创建的线程。当你使用GCD时,大部分时间你是在将任务分派到已存在的线程上,而不是每次都创建新线程。这种重用机制大大减少了线程创建和销毁的开销,同时也减少了线程上下文切换的频率。
8. @synchronized 的线程切换开销
当你使用 @synchronized 时,你实际上是在创建一个互斥锁(mutex)。这个锁保证了同一时间内只有一个线程可以执行被锁定的代码块。如果多个线程尝试访问同一个被 @synchronized 保护的代码块,那么除了第一个获得锁的线程外,其他线程都将被阻塞,直到锁被释放。
这种阻塞涉及到线程的挂起和唤醒,这些操作是由操作系统管理的,相对较重。每次这样的阻塞和唤醒都会导致线程上下文切换,这在多线程频繁争用同一资源的情况下尤其消耗性能。
9. GCD vs. @synchronized
同步任务:当你使用 dispatch_sync 向 GCD 队列提交任务时,调用线程会阻塞,直到任务在目标队列上执行完成。如果目标队列是串行队列,那么这种同步调用确实会导致调用线程等待,但这种等待通常是因为任务执行的需要,而不是线程间的争用和上下文切换。
异步任务与栅栏:使用 dispatch_async 提交任务,然后在需要的时候使用栅栏(dispatch_barrier_async)来同步数据,可以有效地在并发执行多个读任务和单个写任务之间进行协调,而不需要阻塞读任务。
可以这么理解 :
synchronized 使用的底层技术是操作系统级别的锁能力,具备让线程休眠和唤醒的能力。
而GCD使用的能力只是方法调用的能力,A方法调用B方法的常规等待。
虽然达到的效果都一样,但底层的成本是不一样的。
10. A线程如果因为调用B队列的sync方法导致阻塞了,会导致线程频繁切换导致的性能问题吗? 如果不会的话请告诉我为什么? GCD底层代码是怎么实现的?
同步执行没有开启新线程的能力!! 所有的任务都只能在当前线程执行。
异步执行有开启新线程的能力, 但是, 有开启新线程的能力, 也不一定会利用这种能力, 也就是说, 异步执行是否开启新线程
11. 使用GCD的时候,我创建了一个任务队列,GCD一定会创建一个任务队列对应的线程吗?
在使用 GCD (Grand Central Dispatch) 时,创建一个任务队列并不一定意味着 GCD 会立即为该队列创建一个新的线程。GCD 的工作方式更加灵活和高效,它使用了线程池来管理和优化线程的使用。这里是一些关键点来帮助你理解这个过程:
(1)线程池管理
GCD 维护了一个线程池,这个池子中的线程可以被任何队列共享。当你创建一个新的队列并提交任务时,GCD 会根据需要从线程池中分配一个可用的线程来执行这些任务。这种方式减少了线程创建和销毁的开销,提高了系统的整体性能和响应速度。
(2)队列类型:
串行队列:当你创建一个串行队列并提交任务时,这些任务将会按照它们被添加到队列中的顺序一个接一个地执行。但这并不意味着 GCD 会为每个串行队列创建一个独立的线程。相反,这些任务可能在不同时间由线程池中的不同线程执行。
并行队列:对于并行队列,多个任务可以同时执行。GCD 会根据系统的当前负载和可用资源决定同时运行多少任务。这意味着,并行队列的任务可以在多个线程上并发运行,但这些线程是从共享的线程池中调配的。
(3)动态线程创建
如果线程池中的线程都在忙碌,且系统资源允许,GCD 可能会创建额外的线程来处理增加的负载。同样,如果线程长时间空闲,GCD 可能会决定销毁这些线程以释放资源。
三、总结
通过对多线程的原理,各种处理方式的总结,我们得出了使用 并行队列+栅栏函数 的最佳解决方案,
但无论GCD相比加锁性能再好,它终归是会影响性能的,所以在处理多线程读写crash问题时,
建议相关的修改都加上实验,通过实验搞清楚对核心数据的影响。