全方位剖析iOS高级技术问题(六)之多线程相关问题

1,614 阅读11分钟

本文主要内容

一.GCD相关
二.NSOperation相关
三.NSThread相关
四.多线程与锁

截屏2022-08-22 09.25.02.png

一.GCD相关

  • 同步/异步和串行/并发
  • dispatch_barrier_async()
  • dispatch_group_async()

1.1、同步/异步和串行/并发

  • dispatch_sync(serial_queue,^{ // 任务}); // 同步串行
  • dispatch_async(serial_queue,^{ // 任务}); // 异步串行
  • dispatch_sync(concurrent_queue,^{ // 任务}); // 同步并行
  • dispatch_async(concurrent_queue,^{ // 任务}); // 异步并行

同步串行

问题一

- (void)viewDidLoad { // 任务1
    // 同步主队列中
    dispatch_sync(dispatch_get_main_queue(),^{ // 任务2
        [self doSomething];
    });
}
分析:以上代码会产生死锁,同步分派到主队列的任务会产生由队列引起的循环等待。
iOS中默认会有一个主队列、主线程,主队列是一个串行队列,viewDidLoad方法在主队列当中,可以看成任务1,在主线程中运行。Block相当于在主队列中添加的任务2dispatch_sync即同步说明任务2也在主线程中运行,,任务1完成后才能执行任务2,但是任务1在执行过程中的某个时刻就要开始执行任务2,任务2依赖于任务1的完成,任务1也依赖于任务2的完成,由此产生了相互等待,导致死锁!

截屏2022-08-22 09.45.16.png

  • 主列队提交的任务,不论通过同步方式还是异步方式,都要在主线程中处理和执行;
  • iOS中默认有一个主队列和主线程,主队列是一个串行队列;
  • viewDidLoad在主队列中并且在主线程中运行。

问题二

- (void)viewDidLoad { // 任务1
    // 同步串行队列中
    dispatch_sync(serialQueue,^{ // 任务2
        [self doSomething];
    });
}
分析:以上代码没有问题。
iOS默认有一个主队列和主线程,主队列是一个串行队列。viewDidLoad在主队列中可以看成任务1,在主线程中运行。Block是在另一个串行队列中,可以看成任务2dispatch_sync即同步说明任务2也在主线程运行,由于二者不在同一个队列,不会产生死锁,但是任务2会延迟任务1的执行。

截屏2022-08-22 14.19.52.png

同步并发

问题

- (void)viewDidLoad { // 任务1
    NSLog(@"1"); 
    // 同步全局并发队列
    dispatch_sync(global_queue,^{ // 任务2
        NSLog(@"2");
        dispatch_sync(global_queue,^{ // 任务3
            NSLog(@"3");
        });
        NSLog(@"4");
    });
    NSLog(@"5");
}
分析:输出12345。
iOS默认存在一个主队列和主线程,主队列是一个串行队列。viewDidLoad在主队列中,可以看成任务1(打印1),在主线程中运行。global_queue是全局并发队列,里面有任务2和任务3。dispatch_sync说明global_queue中的任务也在主线程运行(会阻断线程,强制执行自己的)。由于global_queue和主线程队列不是同一个队列,不会造成死锁。因为global_queue是全局并发队列,一个任务不用管前面的任务是否执行完毕,所以任务2未完成时(打印2)就可以执行任务3(打印3),然后继续执行任务2(打印4、打印5)、任务1,并且都是在主线程执行。
  
  • 只要是同步提交的任务,无论提交到串行队列还是并发队列,都是在当前线程执行;
  • 并发队列:提交到并发队列的所有任务或Block可以并发执行;

异步串行

- (void)viewDidLoad {
    // 异步主队列
    dispatch_async(dispatch_get_main_queue(),^{
        [self doSomething];
    });
}
分析:iOS中默认有一个主队列和主线程,主队列是一个串行队列。viewDidLoad方法在主队列中,可以看成任务1,在主线程运行。Block相当于在主队列中添加任务2dispatch_async说明任务2在子线程运行,也就是不会阻挡任务1的运行,任务1完成后才能执行任务2.
  • 异步会开启新的线程;

问题

- (void)viewDidLoad {
    // 异步主队列
    dispatch_async(global_queue,^{
        NSLog(@"1");
        [self performSelector: @selector(printLog) withObject: nil, afterDelay: 0];
        NSLog(@"3");
    });
}

- (void)printLog { NSLog(@"2");}
分析:输出结果为13global_queue是全局队列,采用dispatch_async,会开辟一个子线程,实际上任务会在GCD底层所维护的线程池当中某个线程中执行处理。子线程的runloop默认是不开启的,而
通过异步方式分派任务到全局并发队列后,会在GCD底层所维护的线程池当中某个线程中执行处理,在GCD底层所维护的线程池中的线程默认不会开启对应的runloop,而performSelector:withObject:afterDelay是在没有runloop的情况下会失效,所以此方法不执行。

1.2、dispatch_barrier_async()

问题

怎样利用GCD实现多读单写?
分析:需要满足读者和读者并发、读者和写者互斥、写者和写者互斥。
1.读处理之间需要并发的,用到并发队列,因为读取操作,往往需要立刻返回结果,故采用同步。这些读处理允许在对个子线程。2.写处理时其它操作都不能执行。利用栅栏函数异步操作,原因是栅栏函数同步操作会阻塞当前线程,如果当前线程还有其它操作,就会影响用户体验。
多读单写方案:利用GCD提供的栅栏函数。
dispatch_barrier_async(concurrent_queue,^{ //写操作 });

关键代码

// 定义一个用户类UserCenter

#import "UserCenter.h"

@interface UserCenter() {
    // 定义一个并发队列
    dispatch_queue_t concurrent_queue;
    
    // 用户数据中心,可能多个线程需要数据访问
    NSMutableDictionary *userCenterDic;
}
@end

// 多读单写模型
@implementation UserCenter

- (id)init {
    self = [super init];
    if (self) {
        // 通过宏定义 DISPATCH_QUEUE_CONCURRENT 创建一个并发队列
        concurrent_queue = dispatch_queue_create("read_write_queue",DISPATCH_QUEUE_CONCURRENT);
        // 创建数据容器
        userCenterDic = [NSMutableDictionary dictionary];
    }
    return self;
}

// 读取
- (id)objectForKey:(NSString *)key {
    __block id obj;
    // 同步读取指定数据
    dispatch_sync(concurrent_queue,^{
        obj = [userCenterDic objectForKey: key];
    });
    return obj;
}

// 写入
- (void)setObject:(id)obj forKey:(NSString *)key {
    // 异步栅栏调用设置数据
    dispatch_barrier_async(concurrent_queue, ^{
        [userCenterDic setObject: obj forKey: key];
    });
}
@end

截屏2022-08-22 15.49.05.png

dispatch_barrier_sync和dispatch_barrier_async区别
共同点

  • 它们前面的任务先执行完,它们的任务执行完,再执行后面的任务;

不同点

  • dispatch_barrier_sync会阻塞当前线程,等它的任务执行完毕才能往下进行;
  • dispatch_barrier_async不会阻塞当前线程,允许其他非当前队列的任务继续执行。
  • 注意⚠️:使用栅栏函数时,使用自定义队列才有意义,如果使用串行队列或系统的全局并发队列,栅栏函数就相当于一个同步函数。

1.3、dispatch_group_async()

问题

使用GCD实现如下需求:AB、C三个任务并发,完成后执行任务D

截屏2022-08-22 16.42.06.png

使用GCD中的dispatch_group_async()函数

关键代码

#import "GroupObject.h"

@interface GroupObject() {
    dispatch_queue_t concurrent_queue;
    NSMutableArray <NSURL *> *arrayURLs;
}

@end

@implementation GroupObject 

- (id)init {
    self = [super init];
    if (self) {
        // 创建并发队列
        concurrent_queue = dispatch_queue_create("concurrent_queue",DISPATCH_QUEUE_CONCURRENT);
        arrayURLs = [NSMutableArray array];
    }
    return self;
}

- (void)handle {

    // 创建一个group
    dispatch_group_t group = dispatch_group_create();
    
    // for循环遍历各个元素执行操作
    for (NSURL *url in arrayURLs) {
        // 异步组分派到并发队列当中
        dispatch_group_async(group, concurrent_queue, ^{
            // 根据url去下载图片
            NSLog(@"url is %@",url);
        });
    }
    
    dispatch_group_notify(group, dispatch_get_main_queue(), ^{
        // 当添加到组中的所有任务执行完成之后会调用该Block
        NSLog(@"所有图片已经全部下载完成");
    });
}

@end

此示例实际中的应用场景为:同步并发下载多张图片,将这些图片合成为一张图片,需要确保多张图片下载完成后才能合成

二.NSOperation相关

需要和NSOperationQueue配合使用来实现多线程方案。

  • 添加任务依赖
  • 任务执行状态控制
  • 最大并发量

2.1、控制任务的状态类型

  • isReady:当前任务是否处于就绪状态;
  • isExecuting:当前任务是否正在执行;
  • isFinished:当前任务是否执行完成;
  • isCancelled:当前任务是否被标记为取消(不是判断是否被取消,是标记)
  • 通过KVO进行控制的。

2.2、状态控制

问题1:怎样控制NSOperation的状态?

  • 如果重写了main方法,底层控制任务执行状态以及任务推出;
  • 如果重写了start方法,自行控制任务状态,在合适的时机修改对应的状态。

问题2:系统是怎样移除一个isFinished=YES的NSOperation的?

  • 通过KVO

小结 NSOperation:主队列默认在主线程执行,自定义队列默认在后台执行,会开辟子线程。

三.NSThread相关

3.1、启动流程

  • 1.调用start()方法,启动线程;
  • 2.在start()内部会创建一个pthread线程,指定pthread线程的启动函数;
  • 3.在启动函数中会调用NSThread定义的main()函数;
  • 4.在main()函数中会调用performSelector:函数,来执行我们创建的函数; // 常驻线程
  • 5.指定函数运行完成,会调用exit()函数,退出线程。

截屏2022-08-23 09.31.57.png

四.对线程与锁相关

iOS当中有哪些锁?

  • NSRecursiveLock
  • NSLock
  • dispatch_semaphore_t

4.1 @synchronized互斥锁

  • 一般在创建单例对象的时候使用,保证在多线程环境下,创建的单例对象是唯一的。

4.2 @atomic自旋锁

  • 属性关键字;
  • 对被修饰对象进行原子操作,不负责使用。

原子操作:不会被线程调度打断的操作。这种操作一旦开始,就一直运行到结束,中间不会切换到另一个线程;
不负责使用:属性赋值时,能够保证线程安全;对属性进行操作,不能保证线程安全。

示例

@property(atomic) NSMutableArray *array;
self.array = [NSMutableArray array]; // 属性赋值,线程安全
[self.array addObject: obj];  // 属性操作,线程不安全

4.3 OSSpinLock自旋锁

  • 循环等待访问,不释放当前资源。
  • 用于轻量级数据访问,简单的int值+1/-1操作

使用场景

  • 内存引用计数加1或减1;
  • runtime也有使用到。

4.4 NSLock互斥锁和NSRecursiveLock递归锁

示例

- (void)methodA {
    [lock lock];
    [self methodB];
    [lock unlock];
}

- (void)methodB {
    [lock lock];
    
    // 操作逻辑
    
    [lock unlock];
}
`分析`:如上代码会产生死锁。对同一把锁调用两次,由于重入的原因造成死锁。
`解决方案`:使用递归锁,可以重入。
- (void)methodA {
    [recursiveLock lock];
    [self methodB];
    [recursiveLock unlock];
}

- (void)methodB {
    [recursiveLock lock];
    
    // 操作逻辑
    
    [recursiveLock unlock];
}

4.5 dispatch_semaphore_t信号量

涉及的函数

  • dispatch_semaphore_create(5)
    创建信号量,指定最大并发数
struct semaphore {
    int value;
    List <thread>;
}
  • dispatch_semaphore_wait(semaphore,DISPATCH_TIME_FOREVER)
    等待信号量>=1;如果当前信号量>=1,信号量减1,程序继续执行;如果信号量<=0,原地等待,不允许程序继续执行。
dispatch_semaphore_wait() {
    S.value = S.value - 1;
    // 阻塞是一个主动行为
    if S.value < 0 then Block(S.list); 
}
  • dispatch_semaphore_signal(semaphore)
    程序执行完毕,发送信号,释放资源,信号量加1。
dispatch_semaphore_singal() {
    S.value = S.value + 1;
    // 唤醒是一个被动行为
    if S.value <= 0 then wakeup(S.list); 
}    
小结
1.锁分为互斥锁和自旋锁;
2.互斥锁和自旋锁的区别:
    自旋锁:忙等待,即在访问被锁资源时,调用者线程不会休眠,而是不停循环试图访问,直到被锁资源释放。
    互斥锁:会休眠,即在访问被锁资源时,调用者线程会休眠,此时CPU可以调度其他线程工作,直到被锁资源释放,此时会唤醒休眠线程。

本文总结

基础小结

  • 队列概念:队列是任务的容器;

  • 串行队列、并发队列的区别
    串行队列
    1.一次仅仅调度一个任务,队列中的任务一个个执行(一个任务完成后,再运行下一个任务);遵循FIFO原则:先进先出,后进后出。
    2.串行队列任务之间相互包含,容易造成(队列)死锁。
    并发队列
    1.不需要把一个任务完成后,再运行下一个任务;遵循FIFO原则,知识不需要等待任务完成;
    2.并发队列不会造成死锁; 3.只有并发队列+异步,才会有多线程的效果。只有当前一条线程可以利用,并发队列中任务虽然可以快速取出分派,但是只有一条线程(主线程),也只能一个个排队执行。

  • 并行、并发的区别
    并行:同一时刻,多条指令在多个处理器同时执行;
    并发:同一时刻,只能处理一条指令,但是多个指令被快速的轮换执行,达到了具有同时执行的效果。

  • 异步和同步的区别
    异步:可以开启新的线程;
    同步:不可以开启新的线程,在当前线程运行。

  • 同步异步、串行并行形象理解
    串行队列
    任务一个个执行,后面任务需要等待前面任务完成才能执行! 截屏2022-08-23 10.55.35.png

并行队列
任务一个个执行,不用等待前面是否完成! 截屏2022-08-23 10.57.47.png

  • 队列中执行任务的工作场景形象描述
    1.从容器(队列)中取出任务(执行代码),放到传送带(线程)上。如果容器是串行队列,则完成一个,取出一个;如果容器是并发队列,则一直不停的投放。
    2.任务(执行代码)放到传送带(线程)的一刹那,CPU(操作工)看了一眼上面的标签(同步或异步)。如果标签是同步,就将它放到当前传送带;如果标签是异步,就新增加一条传送带,然后把任务放上去。

问题1:怎样用GCD实现多读单写?

即对dispatch_barrier_async()的使用,详见上文。

问题2:iOS系统为我们提供了几种多线程技术?各自的特点是什么?

  • GCD 用于简单的线程同步,子线程分派,解决如多读单写的问题,

  • NSOperation和NS OperationQueue
    方便控制任务状态、控制添加依赖和移除依赖。可用于复杂线程控制,如第三方AFNetWorking和SDWebImage。

  • NSThread
    用于实现常驻线程(通过在main函数中调用performSelector:函数创建自己的函数)。

问题3:NSOperation对象在Finished之后是怎样从queue当中移除掉的?

NSOperation对象在Finished之后,会在内部以KVO的方式通知对应的NSOperationQueue,达到对NSOperation对象进行移除的目的。

问题4:使用过哪些锁?如何使用的?

@synchronized互斥锁 @atomic自旋锁 OSSpinLock自旋锁 NSLock互斥锁和NSRecursiveLock递归锁

有任何问题,欢迎👏各位评论指出!觉得博主写的还不错的麻烦点个赞喽👍