目录
dispatch_group_async + dispatch_group_notify
dispatch_group_enter + dispatch_group_leave + dispatch_group_notify
dispatch_barrier_sync & dispatch_barrier_async
前言
多线程在iOS的开发中起到了非常重要的作用,笔者在之前已经有学习过关于GCD的知识了,但是当时学得迷迷糊糊,正好借学习多线程底层原理的机会,来在学习多线程的同时对之前的知识做一个复习
基本概念及原理
线程、进程与队列
线程的定义:
线程是进程的基本执行单元,一个进程的所有任务都在线程中执行
进程想要执行任务,必须得有线程,
进程至少要有一条线程
程序启动会默认开启一条线程
,这条线程被成为主线程
或UI线程
进程的定义:
进程
是指在系统中正在运行的一个应用程序,如微信、支付宝app都是一个进程每个
进程
之间是独立的,每个进程均运行在其专用的且受保护的内存空间内
通俗地说,可以理解为:进程是线程的容器,而线程用来执行任务。在iOS
中是单进程开发,一个进程就是一个app
,进程之间是相互独立的,如支付宝、微信、qq等,这些都是属于不同的进程。
线程与进程之间的联系与区别:
地址空间:同一
进程
的线程
共享本进程的地址空间,而进程之间则是独立的地址空间资源拥有:同一
进程
内的线程
共享本进程的资源如内存、I/O、cpu等,但是进程
之间的资源是独立的一个
进程
崩溃后,在保护模式下不会对其他进程
产生影响,但是一个线程
崩溃整个进程
都死掉,所以多进程
要比多线程
健壮进程
切换时,消耗的资源大、效率高.所以设计到频繁的切换时,使用线程
要好于进程
。同样如果要求同时进行并且又要共享某些变量的并发操作,只能用线程
而不能用进程
线程
是处理器调度的基本单位,但进程
不是线程没有地址空间,线程包含在进程地址空间中
线程和runloop的关系
runloop与线程是一一对应的
—— 一个runloop
对应一个核心的线程
,为什么说是核心的,是因为runloop
是可以嵌套的,但是核心的只能有一个,他们的关系保存在一个全局的字典里runloop是来管理线程的
—— 当线程的runloop
被开启后,线程会在执行完任务后进入休眠状态,有了任务就会被唤醒去执行任务runloop
在第一次获取时被创建,在线程结束时被销毁对于主线程来说,
runloop
在程序一启动就默认创建好了对于子线程来说,
runloop
是懒加载的 —— 只有当我们使用的时候才会创建,所以在子线程用定时器要注意:确保子线程的runloop被创建,不然定时器不会回调
影响任务执行速度的因素
以下因素都会对任务的执行速度造成影响:
cpu
的调度线程的执行速率
队列情况
任务执行的复杂度
任务的优先级
多线程
多线程生命周期
多线程的生命周期主要分为5部分:新建 - 就绪 - 运行 - 阻塞 - 死亡
新建:实例化线程对象
就绪:线程对象调用
start
方法,将线程对象加入可调度线程池
,等待CPU的调用
,即调用start
方法,并不会立即执行,进入就绪状态
,需要等待一段时间,经CPU
调度后才执行,也就是从就绪状态进入运行状态
运行:
CPU
负责调度可调度线程池中线程的执行。在线程执行完成之前,其状态可能会在就绪和运行之间来回切换.就绪和运行之间的状态变化由CPU负责,程序员不能干预阻塞:当满足某个预定条件时,可以
使用休眠或锁
,阻塞线程执行。sleepForTimeInterval
(休眠指定时长),sleepUntilDate
(休眠到指定日期),@synchronized(self)
:(互斥锁)死亡:正常死亡,即线程执行完毕。非正常死亡,即当满足某个条件后,在线程内部(或者主线程中)终止执行(调用exit方法等退出)
处于运行中的线程
拥有一段可以执行的时间(称为时间片):
如果
时间片用尽
,线程就会进入就绪状态队列
如果
时间片没有用尽
,且需要开始等待某事件
,就会进入阻塞状态队列
等待事件发生后,线程又会重新进入
就绪状态队列
每当一个
线程离开运行
,即执行完毕或者强制退出后,会重新从就绪状态队列
中选择一个线程继续执行
关于线程的exit和cancel方法:
exit
:一旦强行终止线程,后续的所有代码都不会执行cancel
:取消当前线程,但是不能取消正在执行的线程
线程池的原理
可以看到主要分为以下四步:
判断核心线程池是否都正在执行任务:
返回NO,创建新的工作线程去执行
返回YES,进行第二步
判断线程池工作队列是否已经饱满:
返回NO,将任务存储到工作队列,等待CPU调度
返回YES,进入第三步
判断线程池中的线程是否都处于执行状态
返回NO,安排可调度线程池中空闲的线程去执行任务
返回YES,进入第四步
交给饱和策略去执行,主要有以下四种:
AbortPolicy
:直接抛出RejectedExecutionExeception
异常来阻止系统正常运行CallerRunsPolicy
:将任务回退到调用者DisOldestPolicy
:丢掉等待最久的任务DisCardPolicy
:直接丢弃任务
iOS中多线程的实现方式
iOS中多线程的实现方式主要有四种:pthread、NSThread、GCD、NSOperation
线程安全问题
当多个线程同时访问一块内存,容易引发数据错乱和数据安全问题,有以下两种解决方案:
互斥锁(即同步锁):
@synchronized
自旋锁
互斥锁
保证锁内的代码,同一时间,只有一条线程能够执行!
互斥锁的锁定范围,应该尽量小,锁定范围越大,效率越差!
加了互斥锁的代码,当新线程访问时,如果发现其他线程正在执行锁定的代码,新线程就会进入休眠
能够加锁的是任意 NSObject 对象,但必须是 NSObject 对象
锁对象必须保证所有线程都能访问
单点加锁时推荐使用 self
自旋锁
自旋锁与互斥锁类似,但它不是通过休眠使线程阻塞,而是在获取锁之前一直处于
忙等
(即原地打转,称为自旋)阻塞状态使用场景:锁持有的时间短,且线程不希望在重新调度上花太多成本时,就需要使用自旋锁,属性修饰符
atomic
,本身就有一把自旋锁
加入了自旋锁,当新线程访问代码时,如果发现有其他线程正在锁定代码,新线程会用
死循环
的方法,一直等待锁定的代码执行完成,即不停的尝试执行代码,比较消耗性能
、atomic
本身就有一把锁(自旋锁
)
iOS开发的建议:
所有属性都声明为
nonatomic
尽量避免多线程抢夺同一块资源 尽量将加锁、资源抢夺的业务逻辑交给服务器端处理,减小移动客户端的压力
对比GCD和NSOperation
GCD
和NSOperation
的关系如下:
GCD
是面向底层的C
语言的API
NSOperation
是用GCD
封装构建的,是GCD
的高级抽象
GCD和NSOperation的对比如下:
GCD
执行效率更高,而且由于队列中执行的是由block
构成的任务,这是一个轻量级的数据结构 —— 写起来更加方便GCD
只支持FIFO
的队列,而NSOpration
可以设置最大并发数、设置优先级、添加依赖关系等调整执行顺序NSOpration
甚至可以跨队列设置依赖关系,但是GCD
只能通过设置串行队列,或者在队列内添加barrier
任务才能控制执行顺序,较为复杂NSOperation
支持KVO
(面向对象)可以检测operation
是否正在执行、是否结束、是否取消(如果是自定义的NSOperation 子类,需要手动触发KVO通知)
NSThread
NSthread
是苹果官方提供面向对象的线程操作技术,是对thread
的上层封装,比较偏向于底层。
通过NSThread创建线程的方式主要有以下三种方式:
通过
init
初始化方式创建通过
detachNewThreadSelector
构造器方式创建通过
performSelector...
方法创建,主要是用于获取主线程
,以及后台线程
NSThread常用的类方法有以下:
currentThread
:获取当前线程sleep...
:阻塞线程exit
:退出线程mainThread
:获取主线程
GCD
GCD就是Grand Central Dispatch
,它是纯 C
语言。关于GCD,笔者之前已经有博客详细介绍过它的概念和接口以及用法了。这里对于GCD的简单概念就不重复赘述了,详情可以点击笔者这篇博客——OC高级编程之GCD。这里就直接对GCD的函数与队列来进行一个再梳理和复习吧
函数
在GCD
中执行任务的方式有两种,同步执行
和异步执行
,分别对应同步函数dispatch_sync
和 异步函数dispatch_async
。
同步执行,对应同步函数
dispatch_sync
必须等待当前语句执行完毕,才会执行下一条语句
不会开启线程
,即不具备开启新线程的能力在当前线程中执行
block
任务
异步执行,对应异步函数
dispatch_async
不用等待当前语句执行完毕,就可以执行下一条语句
会开启线程
执行block
任务,即具备开启新线程的能力(但并不一定开启新线程,这个与任务所指定的队列类型有关)异步是多线程的代名词
综上所述,两种执行方式的主要区别
有两点:
是否等待
队列的任务执行完毕是否具备开启新线程
的能力
队列
多线程中所说的队列
(Dispatch Queue
)是指执行任务的等待队列
,即用来存放任务的队列.队列是一种特殊的线性表
,遵循先进先出(FIFO)
原则,即新任务总是被插入到队尾,而任务的读取从队首开始读取.每读取一个任务,则动队列中释放一个任务。而队列又分为串行队列和并发队列
串行队列:每次只有一个任务被执行
,等待上一个任务执行完毕再执行下一个,即只开启一个线程
并发队列:一次可以并发执行多个任务
,即开启多个线程
,并同时执行任务
函数与队列的不同组合
串行队列 + 同步派发
任务一个接一个地在当前线程执行,不会开辟新线程
串行队列 + 异步派发
任务一个接一个地执行,但是会开辟新线程
并发队列 + 同步派发
任务一个接一个地执行,不开辟线程
并发队列 + 异步派发
任务乱序进行并且会开辟新线程
主队列 + 同步函数
任务互相等待,造成死锁
为什么这样会造成死锁,这里分析一下原因:
主队列在执行任务执行到同步block时,会将block的任务加入到主队列,但由于主队列是串行队列,因此block的任务要等主线程执行完block才可以执行(因为当前主线程中任务还没有执行完,任务应该是进行到执行block了),而执行block其实就是执行block里的任务(即NSLog),主线程等着block里这个任务执行完才执行完,这样就使得任务之间互相等待,从而造成了死锁崩溃
死锁:
主线程
因为同步函数
的原因等着先执行任务主队列
等着主线程的任务执行完毕再执行自己的任务主队列和主线程
相互等待会造成死锁
主队列 + 异步派发
主队列是一个特殊的串行队列,它虽然是串行队列,但是其异步派发不会开辟新线程,而是将任务安排到主线程的下一个运行循环(Run Loop)周期执行
dispatch_after
dispatch_after表示在队列中的block延迟执行,确切地说是延迟将block加入到队列
dispatch_once
dispatch_once可以保证在app运行期间,block中的代码只执行一次,可以用来创建单例
dispatch_apply
dispatch_apply将指定的block追加到指定的队列中重复执行,并等到全部的处理执行结束(相当于线程安全的for循环)
应用场景:在拉取网络数据后提前计算出各个控件的大小,防止绘制时计算,提高表单滑动流畅性
dispatch_group_t
dispatch_group_t:调度组将任务分组执行,能监听任务组完成,并设置等待时间
应用场景:多个接口请求之后刷新页面
dispatch_group_async + dispatch_group_notify
dispatch_group_notify
在dispatch_group_async
执行结束之后会受收到通知
dispatch_group_enter + dispatch_group_leave + dispatch_group_notify
dispatch_group_enter
和dispatch_group_leave
成对出现,使进出组的逻辑更加清晰
在此基础上还可以使用 dispatch_group_wait
这里dispatch_group_wait这个函数第一个参数表示要等待的调度组,第二个参数表示要等多久(如果设置为DISPATCH_TIME_NOW表示不等待直接判定是否执行完毕,如果设置为DISPATCH_TIME_FOREVER表示阻塞当前调度组直到调度组执行完毕)
这个函数的返回值为long类型,如果返回值为0,表示在指定时间内调度组完成了任务;如果不为0,表示在指定时间内调度组没有按时完成任务
dispatch_barrier_sync & dispatch_barrier_async
栅栏函数,主要使用在并发队列,串行队列使用栅栏函数没什么意义。
栅栏函数即:等栅栏前追加到队列中的任务执行完毕后,再将栅栏后的任务追加到队列中。 简而言之,就是先执行栅栏前任务,再执行栅栏任务,最后执行栅栏后任务
可以看到如果没有栅栏,按照主线程的派发顺序,任务2延迟1s,任务1延迟2s,应该是先完成任务2再完成任务1的,但是因为这里有栅栏函数,所以这里任务执行的顺序变为:先执行栅栏前的任务1,再执行栅栏任务,然后执行栅栏后的任务2
dispatch_barrier_sync与dispatch_barrier_async的作用相同,区别在于是否阻塞线程。
注意⚠️:
1.尽量使用自定义的并发队列
:
使用
全局队列
起不到栅栏函数
的作用使用
全局队列
时由于对全局队列造成堵塞,可能致使系统其他调用全局队列的地方也堵塞从而导致崩溃(并不是只有你在使用这个队列)
2.栅栏函数只能控制同一并发队列
:打个比方,平时在使用AFNetworking
做网络请求时为什么不能用栅栏函数起到同步锁堵塞的效果,因为AFNetworking
内部有自己的队列(也就是说栅栏函数不能跨队列作用)
dispatch_semaphore_t
dispatch_semaphore_t
表示信号量,可以用来控制GCD最大并发数:
dispatch_semaphore_create()
:创建信号量dispatch_semaphore_wait()
:等待信号量,信号量减1
。当信号量< 0
时会阻塞当前线程,根据传入的等待时间决定接下来的操作——如果永久等待将等到信号(signal)
才执行下去dispatch_semaphore_signal()
:释放信号量,信号量加1
。当信号量>= 0
会执行wait
之后的代码
比如用信号量来代替栅栏函数使这段代码按序输出:
使用信号量的API来改写的话就是这样的:
如果当创建信号量时传入值为1又会怎么样呢?
i=0
时有可能先打印,也可能会先发出wait
信号量-1,但是wait
之后信号量为0不会阻塞线程,所以进入i=1
i=1
时有可能先打印,也可能会先发出wait
信号量-1,但是wait
之后信号量为-1阻塞线程,等待signal
再执行下去
结论:
创建信号量时传入值为1时,可以通过两次才堵塞
传入值为2时,可以通过三次才堵塞
dispatch_source
dispatch_source
是一种基本的数据类型,可以用来监听一些底层的系统事件
Timer Dispatch Source
:定时器事件源,用来生成周期性的通知或回调Signal Dispatch Source
:监听信号事件源,当有UNIX信号发生时会通知Descriptor Dispatch Source
:监听文件或socket
事件源,当文件或socket
数据发生变化时会通知Process Dispatch Source
:监听进程事件源,与进程相关的事件通知Mach port Dispatch Source
:监听Mach
端口事件源Custom Dispatch Source
:监听自定义事件源
主要使用的API:
dispatch_source_create
: 创建事件源dispatch_source_set_event_handler
: 设置数据源回调dispatch_source_merge_data
: 设置事件源数据dispatch_source_get_data
: 获取事件源数据dispatch_resume
: 继续dispatch_suspend
: 挂起dispatch_cancle
: 取消
比如通过dispatch_source
来实现定时器,在开发中经常使用NSTimer来实现定时逻辑,但是NSTimier是依赖Runloop的,而Runloop可以运行在不同的模式下,如果NSTimer添加在一一种模式下,而Runloop运行在其他模式下,定时器就挂起了;又如果Runloop在阻塞状态,那么NSTimer的触发时间就会推迟到下一个Runloop周。因此NSTimer在计时上会有误差,而GCD计时器不依赖Runloop,计时精度高很多
需要注意⚠️:
GCDTimer
需要强持有
,否则出了作用域立即释放,也就没有了事件回调GCDTimer
默认是挂起状态,需要手动激活GCDTimer
没有repeat
,需要封装来增加标志位控制GCDTimer
如果存在循环引用,使用weak+strong
或者提前调用dispatch_source_cancel
取消timer
dispatch_resume
和dispatch_suspend
调用次数需要平衡source
在挂起状态
下,如果直接设置source = nil
或者重新创建source
都会造成crash
。正确的方式是在激活状态下调用dispatch_source_cancel(source)
释放当前的source
NSOperation
NSOperation
是个抽象类,依赖于子类NSInvocationOperation
、NSBlockOperation
去实现
NSInvocationOperation
也可以直接处理事务,不添加隐性队列
NSBlockOperation
NSInvocationOperation
和NSBlockOperation
两者的区别在于:
前者类似
target
形式后者类似
block
形式——函数式编程,业务逻辑代码可读性更高
NSOperationQueue
是异步执行的,所以任务一
、任务二
的完成顺序不确定
通过addExecutionBlock
这个方法可以让NSBlockOperation
实现多线程
NSBlockOperation创建时block中的任务是在主线程执行,而运用addExecutionBlock加入的任务是在子线程执行的(准确地来说,创建时block中的任务在start调用发生的线程执行)(当Operation没有添加到队列,而是通过start调用时)
NSOperationQueue
NSOperationQueue
有两种队列:主队列、其他队列
主队列:主队列上的任务是在主线程执行的
其他队列(非主队列):加入到
非主队列
中的任务默认就是并发,开启多线程
通过类方法mainQueue可以得到主队列
设置优先级
NSOperation
设置优先级只会让CPU
有更高的几率调用,不是说设置高就一定全部先完成
通过以下是否让高优先级任务休眠的任务执行顺序即可看出这一点:
不使用sleep:
使用sleep:
设置并发数
在NSOperation中不需使用信号量,直接设置maxConcurrentOperationCount就可以控制并发数,来控制单次出队列去执行的任务数
设置依赖
在NSOperation
中添加依赖能很好的控制任务执行的先后顺序