【iOS】多线程原理
目录
前言
基本概念及原理
线程、进程与队列
线程的定义:
进程的定义:
线程与进程之间的联系与区别:
线程和runloop的关系
影响任务执行速度的因素
多线程
多线程生命周期
线程池的原理
iOS中多线程的实现方式
线程安全问题
互斥锁
自旋锁
对比GCD和NSOperation
NSThread
GCD
函数
队列
函数与队列的不同组合
串行队列 + 同步派发
编辑串行队列 + 异步派发
编辑
并发队列 + 同步派发
编辑
并发队列 + 异步派发
主队列 + 同步函数
主队列 + 异步派发
dispatch_after
dispatch_once
dispatch_apply
dispatch_group_t
dispatch_group_async + dispatch_group_notify
dispatch_group_enter + dispatch_group_leave + dispatch_group_notify
dispatch_barrier_sync & dispatch_barrier_async
dispatch_semaphore_t
dispatch_source
NSOperation
NSInvocationOperation
NSBlockOperation
NSOperationQueue
设置优先级
设置并发数
设置依赖
前言
多线程在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
中添加依赖能很好的控制任务执行的先后顺序