相信只要作为一个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 来进行比较。

  1. 使用GCD
    GCD 是一个强大的库,用于优化应用程序以便使用多核处理器以及管理多线程。GCD 提供了队列(Queue)的概念,主要有两种队列:串行队列和并行队列。对于处理多线程读写操作,通常使用串行队列或者并行队列配合栅栏函数(barrier)。

串行队列:保证了任务按顺序执行,自然地避免了多线程冲突,但在处理大量或复杂的任务时可能会影响性能。
并行队列 + 栅栏函数:并行队列允许多个读操作同时进行,但通过栅栏函数可以控制写操作在没有其他读写操作执行时才进行,这样可以有效地提高性能,同时保证线程安全。
使用GCD的性能损耗主要来自于线程的创建和上下文切换,但由于GCD背后的优化(如线程池管理),这种损耗相对较小。

  1. 使用 @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问题时,

建议相关的修改都加上实验,通过实验搞清楚对核心数据的影响。