多线程

发布于 2022-03-15  134 次阅读


多线程

test

  • PThread
  • NSThread
  • GCD
  • NSOperation
  • 线程池、autoreleasepool
  • 锁的类型

四种线程技术比较

知识点

  1. CPU时间片,每个获取CPU时间片的任务,只能运行一个时间片所规定的时间。
  2. 线程就是一段代码以及运行这段代码时的数据。
  3. 每个应用程序都是一个进程。
  4. 一个进程的所有任务都是在线程中进行的,每个进程都至少有一个线程,既 主线程。
  5. 主线程的作用:处理UI、所有更新UI的操作必须在主线程上执行。不要把耗时的操作放到主线程,会卡死界面。
  6. 多线程:在同一个时刻,一个CPU只能执行一条线程,但是CPU可以在多个线程之间快速切换,只要切换的足够快,就造成了同一时间多线程执行的假象。
  7. 运用多线程的主要目的,就是把耗时的操作放在后台执行

线程的生命周期

  1. 新建:实例化线程对象
  2. 就绪:想线程对象发送 start 消息,线程对象被加入可调度线程池等待CPU调度。
  3. 运行:被CPU执行。在执行完成钱,状态可能会在就绪和运行之间来回切换,由CPU负责。
  4. 阻塞:当满足某些条件时,可以使用休眠sleep或者锁来阻塞线程的执行。
  5. 死亡:线程执行完毕既正常死亡。而当满足某个条件后,在线程内部终止执行/在主线程终止线程对象,都会导致线程死亡。
  6. 线程的 exit 和 cancel:一旦执行 exit ,那么后续所有代码都不会执行。而调用 cancel ,它就是给 pthread 设置取消标志,pthread 线程在很多时候都会查看自己是否有取消的请求。

线程技术简介

  1. PThread: POSIX 线程,是线程的 POSIX 标准。运用 C 语言,是一套通用的 API,可以跨平台 Unix/Linux/Windows。 线程的生命周期由程序员管理。
  2. NSThread: 面向对象,可以直接操作线程对象,线程的生命周期由程序员管理。
  3. GCD:替代 NSThread ,可以充分利用设备的多核,自动管理生命周期。
  4. NSOperation:底层是GCD,比GCD多了一些方法。更加的面向对象,自动管理线程的生命周期。

线程安全问题的原因

当多个线程同时访问一块资源时,很容易引发数据错乱和数据安全问题。

NSThread

面向对象的线程操作技术,优点就是比其他两个更轻量级,是对于 thread 的封装,比较偏向于底层,简单方便,可以直接操作线程对象,但是使用频率较少。缺点是需要自己管理线程的生命周期、线程同步、加锁、睡眠及唤醒等。线程同步对数据的加锁有一定的系统开销。
创建方法: init (需要手动启动)、detachNewXXXX (自动启动)、perfromSelectorXXX(可以从主线程启动)。
属性:
thread.isExecuting 线程是否执行
thread.isCancelled 线程是否被取消
thread.isFinished 线程是否完成
thread.isMainThread 是否是主线程
thread.isMultiThread 是否是多线程
thread.threadPriority 线程优先级。取值范围为 0.0-1.0.默认优先级为0.5,1.0表示最高,优先级越高,CPU调度的频率越高
类方法:
currentThread 获取当前线程
sleep... 阻塞线程
exit 退出线程
mainThread 获取主线程

GCD

GCD 全称是 Grand Central Dispatch ,是由苹果开发的一个多核编程的解决方案。是替代 NSThread的高效和强大的技术。 GCD 是基于C语言的。
GCD 会自动利用更多的CPU内核。
GCD 会自动管理线程的生命周期。(创建线程,调度任务、阻塞线程)
GCD 程序员只需要告诉GCD如何执行任务,不需要编写任何线程管理的代码。

GCD 的基本概念

  1. 任务:
    就是执行操作的意思,换句话说就是在线程中执行的那部分代码。在GCD中是放在 block 中执行的。
  • 执行方式: 同步执行 (sync)和异步执行(async)
  • 两者的主要区别是:是否等待对联的任务执行结束,以及是否有开辟新线程的能力。
  • 同步执行:
    • 同步添加任务到制定的队列中,在添加的任务执行结束前,当前线程会一直等待,直到队列里的任务完成之后再继续执行。
    • 只能在当前线程中执行任务,不具备开启新线程的能力。
  • 异步执行:
    • 异步添加任务到指定的队列中,当前线程不会做任何等待,可以继续执行任务。
    • 可以在新的线程中执行任务,具备开启新线程的能力。
    • 异步是多线程的代名词。
    • 虽然异步有开辟新线程的能力,但是它不一定会开启新的线程。这个跟任务所指定的队列类型有关。
  1. 队列
    队列(Dispatch Queue):这里的队列指的是执行任务的等待队列,既用来存放任务的队列。队列是一种特殊的线性表,遵循FIFO原则,既新任务总是被插入到队列的末尾,而读取任务的时候总是从队列的开头开始读取。读取一个任务,则从队列中释放一个任务。

    • 有两种队列:串行队列,并发队列。两者都符合 FIFO 原则。
    • 主要区别是:执行顺序不同,以及开启线程数不同。
  • 串行队列(Serial Dispatch Queue):
    • 每次只有一个任务被执行,让任务一个接着一个去执行(只开启一个线程)。
    • GCD 提供了一种特殊的队列:主队列 (Main Dispatch Queue),它也是串行队列。
    • 主队列复制在主线程上的任务,如果主线程上已经有任务正在执行,主队列会等到主线程空闲以后再调度。
    • 所有放到主队列的任务都会在主线程执行。通常是用来更新UI
    • 可以使用dispatch_get_main_queue()来获取主队列。提交到主队列的任务在主线程执行
  • 并发队列(Concurrent Dispatch Queue)
    • 可以让多个任务并发(同时执行)。(可以开启多个线程,并同时执行任务)。
    • GCD 提供了全局的并发队列 Dispatch_get_global_queue
      并发队列,只有在异步任务,既 dispatch_async 函数下才会有效

dispatch_sync(dispatch_get_main_queue(),^{
xxxx
这里会造成死锁:

  1. dispatch_sync 在等待 block 的执行完毕,但是 block 是在主线程执行的,所以如果 dispatch_sync如果是在主线程调用的,则会造成死锁
  2. 而且 dispatch_sync 是同步的,本身就会阻塞当前线程,既主线程。而又要往主线程里塞进去一个 block ,所以会发生死锁.
    });
  3. 但是似乎也可以理解为,主线程下,同步线程(既主线程本身)往主线程内添加任务x,那么主线程就会等待这个操作完成,而任务x如果要执行,必须等待线程本身完成当前的任务,既把任务x加到主队列中(串行队列),所以造成了主线程等任务,任务等主线程。
  4. 而其放到其他线程则不会发生这个问题,因为其他线程不用等待主线程结束,直接往主队列里加就可以了。
  5. 另外,异步+主队列:虽然异步有开启线程的能力,但是由于所有任务都是在当前线程,既主线程内串行执行的,所以没有开启新的线程。
  • dispatch_apply 快速迭代,相当于线程安全的 for 循环

dispatch_apply 如果放在主队列中,也会死锁,即便外面套了 dispatch_async ;它本身的队列不能是主队列,外面套的队列是主队列没关系。
而且 dispatch_apply 会等待所有任务执行完毕,才会往下走。如下

- (void)apply {
    dispatch_queue_t queue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0);
    NSLog(@"apply---begin");
    dispatch_apply(10, queue, ^(size_t index) {
        NSLog(@"%zd---%@",index, [NSThread currentThread]);
    });
    NSLog(@"apply---end");
}

apply---end 一定是最后打印的。

  • 栅栏函数

dispatch_barrier_async : 将自己的任务(barrier)插入到queue之后,不会等待自己的任务结束,它会继续把后面的任务(4、5、6)插入到queue,然后执行任务
dispatch_barrier_sync:需要等待自己的任务(barrier)结束之后,才会继续添加并执行写在barrier后面的任务(4、5、6),然后执行后面的任务

栅栏函数注意点

  1. 必须使用【自定义并发】队列,不能使用全局队列。使用栅栏函数的意义就是阻塞队列里的任务,而系统的全局并发队列是并发的,所以会产生冲突导致栅栏函数失效。
  2. 栅栏函数的主要作用就是保证任务的顺序执行,以及线程安全。
区别 并发队列 串行队列 主队列
同步(sync) 不会开起新线程;串行执行任务 不会开启新线程;串行执行任务; 在主线程上使用,会造成死锁。在其他串行队列使用,则不会开启新线程,串行执行任务。
异步(async) 会开启新线程;同时执行任务; 有开辟新线程(1条),串行执行任务。 没有开启线程,串行执行任务。
  • 无论是异步串行(包括主队列),还是异步并发,无论中间几个 block ,都不会等待,直接走到函数的最后,然后才会按照并行还是串行,进行异步的打印。 异步任务内,也就是 block 内的东西还是顺序执行的,只不过不会等待执行结束,比如异步任务内还有个异步的任务。

GCD 间的线程通信

比如其他线程完成了耗时操作,然后要回到主线程更新UI。
其实死锁的主要原因,就是线程信息不同步,比如A在等待B,B也在等待A;

总结

  1. 将任务(要在线程中执行的 block)添加到队列(自己或者全局创建的并发队列),并指定执行任务的方式(同步还是异步)。
  2. 无论是串行还是并行队列,在一个同步或者一个异步任务内都是先进先出。 如果多个异步做比较,那就不知道谁先谁后了。

NSOperation

NSOperation 是基于GCD之上的一层封装,NSOperation 需要配合 NSOperationQueue 来实现多线程。
优点:不需要关心线程管理,数据同步的事情,可以把精力放在自己需要执行的操作上。NSOperation是面向对象的。

NSOperation 实现多线程的步骤

  1. 创建任务:先将需要执行的操作封装到 NSOperation 中。
  2. 创建队列: 创建 NSOperationQueue 队列。
  3. 将任务添加到队列中:将 NSOperation 对象添加到 NSOperationQueue 中。

需要注意的是:NSOperation 是一个抽象类,实际运用的是他的子类,主要有三种方式:

  1. 使用子类 NSInvocationOperation
  2. 使用子类 NSBlockOperation
  3. 定义继承自 NSOperation 的子类,通过实现内部相应的方法来封装任务。
NSOperation 的三种创建方式

就是上面的三种方式

  1. 如果 NSInvocationOperation/NSBlockOperation的初始化任务 不加入到自定义的 NSOperationQueue 队列中,而直接使用 start ,那么则会在当前线程执行任务,比如主线程。否则新开线程执行任务。
  2. NSBlockOperation 本身是在主线程中执行的,通过 addExecutionBlock 添加的任务是在子线程中执行的,而且是并发的。[queue addOperations:@[op1,op2,op3] waitUntilFinished:NO]; 此方法可以选择是否阻塞当前线程等待 operations 完全执行完毕。
  3. NSOperationQueue 有两种,一种是主队列,一种是其他队列。
  • 主队列就是在主线程中执行。
  • 其他队列包含串行和并行两种。而加入到其他队列中的任务,默认就是在并发执行,开启多线程。
  1. 在 for 循环中,添加 addBarrierBlock 会是顺序执行,但是直接添加 addOperationBlock 则不会是顺序执行的,如下所示:
//设置执行顺序
-(void) testOperationSequence{
    NSOperationQueue * queue = [[NSOperationQueue alloc]init];
    for (int i=0; i<5; i++) {
        [queue addOperationWithBlock:^{
                    NSLog(@"顺序:%d,线程:%@",i,[NSThread currentThread]);
        }];
        //其实就是通过栅栏函数使得并发任务产生顺序
        //这里会按顺序,但是上面的不会按顺序走
        [queue addBarrierBlock:^{
            NSLog(@"顺序:%d,线程:%@",i,[NSThread currentThread]);
        }];
    }
}
  1. GCD使用 信号量就是 dispatch_sem 那个来实现最大并发,初始化设置的就是最大并发数。而 NSOperation 则可以直接通过 maxConcurrentOperationCount 来设置最大并发数。
  2. NSOperation 可以添加任务之间的依赖addDependency,比如任务二在任务一结束之后才执行。
  3. NSOperation 的线程通讯和GCD类似,都是在子线程做完任务之后,回到主线程刷新任务等等。

多线程安全问题解决方案(锁类型)

锁类型

互斥锁(又叫同步锁) -- mutex

用于保护临界区,确保同一时间只有一个线程访问数据。如果代码中只有一个地方需要加锁,大多使用 self 作为锁的对象,这样可以避免单独再创建一个锁对象。加了互斥锁的代码,当新线程访问时,如果发现其他代码正在执行锁定的代码,则新线程就会进入休眠。比如 @synchronized(锁对象){}

自旋锁 -- recursive

与互斥锁类似,不过它不是通过新线程的休眠使进程阻塞,而是在获取锁之前一直处于忙等待(自旋)的状态。用在以下情况:锁持有的时间段,而且线程并不希望在重新调度上花太多成本,原地打转。
自旋锁与互斥锁的区别:线程在申请自旋锁的时候,线程不会被挂起,而是处于忙等待的状态。

加了自旋锁,当新线程访问代码的时候,如果发现有其他线程正在锁定代码,新线程就会用死循环的方式,一直等到锁定代码的执行完成。相当于不停的尝试执行代码,比较消耗性能,而同步锁是等待,不会再执行。属性修饰符 atomic 本身就有一把自旋锁。
结论: 除非能保证线程在同一优先级,否则 iOS 开发中不推荐任何形式的自旋锁。

常见的锁

OSSPinlock -- 速度最快,自旋锁(忙等待,一直询问和等待),不安全,已经弃用
os_unfair_lock --为了替代 OSSPinlock ,ios 10 及以后才出现,是互斥锁(休眠)
pthread_mutex -- 互斥锁(等待的线程会休眠),类型为 null 或者 default、PTHREAD_MUTEX_NORMAL 的时候是互斥锁。为 PTHREAD_MUTEX_RECURSIVE 时是递归锁。
NSLock -- 是对 phread_mutex 的递归封装
NSRecursiveLock -- 也是对 pthread_mutex 的封装,只不过封装的是递归的类型。
NSCondition -- 条件锁,是对 mutex 和 cond 的封装
NSConditionLock -- 是对 NSCondition 的进一步封装,可以设置具体的条件值。
dispatch_semaphore -- GCD 信号量
@synchronized -- 是对 mutex 递归锁的封装
pthread-rwlock -- 是一种读写锁,可以多个同时读,但是写的操作是互斥的。
dispatch_barrier_async -- 异步栅栏,队列必须是自定义的并发队列。 如果是串行或者全局并发,则相当于 dispatch_async
dispatch_group_t -- 调度组,也能实现类似于栅栏函数的效果

OSSPinlock

速度最快的自旋锁。 但是不安全了,因为可能会出现线程优先级反转的问题。
大概原因是:

如果一个低优先级的线程获得锁并访问共享资源,这时一个高优先级的线程也尝试获得这个锁,它会处于 spin lock 的忙等状态从而占用大量 CPU。此时低优先级线程无法与高优先级线程争夺 CPU 时间,从而导致任务迟迟完不成、无法释放 lock,这就是优先级反转。这并不只是理论上的问题,开发者已经遇到很多次这个问题,于是苹果工程师停用了 OSSpinLock。

互斥锁和自旋锁的选择

  • 什么情况使用自旋锁比较划算?
atomic、
OSSpinLock、
dispatch_semaphore_t

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

  • 什么情况使用互斥锁比较划算?
@synchronized,
NSLock,
pthread_mutex, 
NSConditionLock, 
NSCondition, 
NSRecursiveLock

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


迷雾尽散 破晓而生