聊聊iOS中的多线程和锁

524 阅读7分钟

1. 提个问题, 什么是线程安全?

  • 多线程同时访问一段代码, 不会造成数据混乱的情况.

1.1 哪些场景运用到了线程安全?

  • 举例12306同一列火车的车票, 同一时间段多人抢票, 如何解决互斥锁使用格式?
synchronized(锁对象) {
    // 需要锁定的代码
    // 锁定一份代码只用一把锁, 用多把锁是无效的
}
  • 互斥锁的优缺点
    • 优点: 能有效防止因多线程抢夺资源造成的数据安全问题
    • 缺点: 需要消耗大量的CPU资源
  • 互斥锁的使用前提: 多条线程抢夺同一块资源
  • 线程同步是指: 多条线程按顺序地执行任务
  • 互斥锁就是使用了线程同步技术

1.2 OC中的原子和非原子属性

  • OC在定义属性时有nonatomic和atomic两种选择
  • atomic(默认): 用于保证属性setter、getter的原子性操作, 相当于在getter和setter内部加了线程同步的锁
    • 线程安全, 需要消耗大量的资源
    • 不一定能保证使用属性的过程是线程安全的
      • 一个线程在连续多次读取某条属性值的时候, 同时别的线程在改值, 这样还是会读取到不同的属性值.
      • 一个线程在获取当前属性的值, 另一个线程把这个属性释放了, 可能会造成崩溃
  • nonatomic: 非原子属性, 不加锁
    • 非线程安全, 适合内存小的移动设备
// atomic加锁原理:
@property (atomic, assign) int age;
- (void)setAge:(int)age {
    @synchronized(self) {
        _age = age;
    }
}
  • 开发中建议所有属性都声明为nonatomic, 尽量避免多线程抢夺同一块资源,
  • 将加锁、资源抢夺的业务逻辑交给服务端处理

1.3 @synchronized

  • @synchronized是对mutex递归锁的封装
  • 源码查看: objc4中的objc-sync.mm文件
  • @synchronized(obj)内部会生成obj对应的递归锁, 然后进行加锁、解锁操作
@synchronized(obj) {
    // 任务
}

2. 各种锁

2.1 OSSpinLok

  • 操作系统多线程轮转算法 image.png

2.2 os_unfair_lock

image.png

2.3 pthread_mutex

image.png

2.4 pthread_mutex - 递归锁

image.png

2.5 pthread_mutex - 条件

image.png

2.6 NSLock、NSRecursiveLock、NSCondition

  • NSLock是对mutex普通锁的封装

image.png

  • NSRecursiveLock也是对mutex递归锁的封装, API跟NSLock基本一致

  • NSCondition是对mutexcond的封装

    • 对锁和条件的封装
    • 把C语言的一些数据结构屏蔽了 image.png
  • NSConditionLock是对NSCondition的进一步封装, 可以设置具体的条件值

image.png

3. 自旋锁、互斥锁比较

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

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

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

3.1 iOS线程同步方案加锁性能比较

  • 性能从高到低排序

image.png

4. iOS中的多线程

4.1 iOS的多线程方案有哪几种?

image.png

  • GCD
  • 只要是同步或主队列, 都是串行执行

image.png

  • 死锁的产生, 出现了相互等待产生死锁
  • 使用sync函数往当前串行队列中添加任务, 会卡主当前的串行队列产生死锁
  • sync执行完旧任务才能执行新任务
- (void)testDeadLock {
    NSLog(@"执行任务1");
    dispatch_queue_t queue = dispatch_queue_create("myqueue", DISPATCH_QUEUE_SERIAL);
    dispatch_queue_t queue2 = dispatch_queue_create("myqueue", DISPATCH_QUEUE_CONCURRENT);
    dispatch_queue_t queue3 = dispatch_queue_create("myqueue", DISPATCH_QUEUE_SERIAL);

    dispatch_async(queue, ^{// block0
        NSLog(@"执行任务2");
        
        // sync + queue, 同步串行 同一个队列, 死锁
        // sync + queu2, 同步并发 不同的队列, 不死锁
        // sync + queue3, 同步串行 不同的队列, 不死锁
        // async + queue, 异步串行, 不死锁
        dispatch_sync(queue3, ^{// block1
            NSLog(@"执行任务3 - %@", [NSThread currentThread]);
        });
        NSLog(@"执行任务4");
    });
    NSLog(@"执行任务5");
}

image.png

  • 全局并发队列只有一份, 打印出来的内存地址一样

4.2 队列组的使用

image.png

4.3 NSNotification的多线程

  • 通知中心采用单例的模式,整个系统只有一个通知中心。可以通过[NSNotificationCenter defaultCenter]来获取对象。
  • 在多线程应用中,Notification在哪个线程中post,就在哪个线程中被转发,而不一定是在注册观察者的那个线程中。
    • Notification的发送与接收处理都是在同一个线程中。
    • 虽然我们在主线程中注册了通知的观察者,但在全局队列中post的Notification,并不是在主线程处理的。
    • 如果我们想在回调中处理与UI相关的操作,需要确保是在主线程中执行回调。
  • 那么怎么才能做到一个Notification的post线程与转发线程不是同一个线程呢?
    • 设置[NSOperationQueuemainQueue],就可以实现在主线程中刷新UI的操作。
[[NSNotificationCenter defaultCenter] addObserverForName:@"Test_Notification" object:nil queue [NSOperationQueue mainQueue] usingBlock:^(NSNotification * _Nonnull note) {
        NSLog(@"接收和处理通知的线程%@", [NSThread currentThread]);
    }];
dispatch_async(dispatch_get_global_queue(0, 0), ^{
    [[NSNotificationCenter defaultCenter] postNotificationName:@"Test_Notification" object:nil userInfo:nil];
    NSLog(@"发送通知的线程为%@", [NSThread currentThread]);
});

5. 多线程的坑

  • 写 UIKit、AFNetworking、FMDB 这些库的“大神”们,并不是解决不了多线程技术可能会带来的问题,
  • 而相反正是因为他们非常清楚这些可能存在的问题,所以为避免使用者滥用多线程,亦或是出于性能考虑,
  • 而选择了使用单一线程来保证这些基础库的稳定可用。

5.1 常驻线程

  • 常驻线程
    • 指的就是那些不会停止,一直存在于内存中的线程。
    • AFNetworking 2.0 专门创建了一个线程来接收 NSOperationQueue 的回调,这个线程其实就是一个常驻线程。
+ (void)networkRequestThreadEntryPoint:(id)__unused object {
    @autoreleasepool {
        // 先用 NSThread 创建了一个线程
        [[NSThread currentThread] setName:@"AFNetworking"];
        // 使用 run 方法添加 runloop
        NSRunLoop *runLoop = [NSRunLoop currentRunLoop];
        [runLoop addPort:[NSMachPort port] forMode:NSDefaultRunLoopMode];
        [runLoop run];
    }
}

image.png

  • 虽然说,在一个 App 里网络请求这个动作的占比很高,但也有很多不需要网络的场景,所以线程一直常驻在内存中,也是不合理的。
  • AFNetworking 在 3.0 版本时,使用苹果公司新推出的 NSURLSession 替换了 NSURLConnection,从而避免了常驻线程这个坑。
    • NSURLSession 可以指定回调 NSOperationQueue,这样请求就不需要让线程一直常驻在内存里去等待回调了。
self.operationQueue = [[NSOperationQueue alloc] init];
self.operationQueue.maxConcurrentOperationCount = 1;
self.session = [NSURLSession sessionWithConfiguration:self.sessionConfiguration delegate:self delegateQueue:self.operationQueue];
    • NSURLSession 发起的请求,可以指定回调的 delegateQueue,不再需要在当前线程进行代理方法的回调。所以说,NSURLSession 解决了 NSURLConnection 的线程回调问题。
  • AFNetworking 2.0 使用常驻线程也是无奈之举,一旦有方案能够替代常驻线程,它就会毫不犹豫地废弃常驻线程。

5.2 并发

  • 总结来讲,类似数据库这种需要频繁读写磁盘操作的任务,尽量使用串行队列来管理,避免因为多线程并发而出现内存问题。
  • 创建线程的过程,需要用到物理内存,CPU 也会消耗时间。
    • 而且,新建一个线程,系统还需要为这个进程空间分配一定的内存作为线程堆栈。
    • 堆栈大小是 4KB 的倍数。在 iOS 开发中,主线程堆栈大小是 1MB,新创建的子线程堆栈大小是 512KB。
    • 除了内存开销外,线程创建得多了,CPU 在切换线程上下文时,还会更新寄存器,更新寄存器的时候需要寻址,而寻址的过程还会有较大的 CPU 消耗。
  • 线程过多时内存和 CPU 都会有大量的消耗,从而导致 App 整体性能降低,使得用户体验变成差。

5.3 多线程避坑

  • 一提到多线程技术,我们往往都会联想到死锁等锁的问题,但其实锁的问题是最容易查出来的,
    • 反而是那些藏在背后,会慢慢吃尽你系统资源的问题,才是你在使用多线程技术时需要时刻注意的。
  1. 常驻线程一定不要滥用,最好不用。
  2. 除非是并发数量少且可控,或者必须要在短时间内快速处理数据的情况,否则我们在一般情况下为避免数量不可控的并发处理,都需要把并发队列改成串行队列来处理。

发文不易, 喜欢点赞的人更有好运气👍 :), 定期更新+关注不迷路~

ps:欢迎加入笔者18年建立的研究iOS审核及前沿技术的三千人扣群:662339934,坑位有限,备注“掘金网友”可被群管通过~