前言
在iOS
开发过程中,线程的处理是我们不可绕开的技术话题。比如pthread
、NSThread
、GCD
、NSOperation
,其中iOS
开发中GCD
、NSOperation
是我们最常用。在研究这些之前,我们先来了解一些多线程方面的概念。
1.线程和进程
1.1定义
-
线程的定义
- 线程是进程的
基本执⾏单元
,⼀个进程的所有任务都在线程中执⾏ - 进程要想执⾏任务,必须得有线程,进程
⾄少要有⼀条线程
- 程序启动会默认开启⼀条线程,这条线程被称为
主线程
或UI
线程
- 线程是进程的
-
进程的定义
- 进程是指在系统中正在运⾏的
⼀个应⽤程序
- 每个进程之间是
独⽴的
,每个进程均运⾏在其专⽤的
且受保护的内存空间内 - 通过
活动监视器
可以查看Mac
系统中所开启的进程
如上图Mac活动监视器
中,罗列出了当前运行的进程,各个进程之间相互独立
运行,每个进程内会有多个
线程在运行。
- 进程是指在系统中正在运⾏的
1.2进程与线程的关系
-
地址空间
:同⼀进程的线程共享本进程的地址空间,⽽进程之间则是独⽴的地址空间。 -
资源归属
:同⼀进程内的线程共享本进程的资源
,如内存
、I/O
、cpu
等,但是进程之间的资源是相互独⽴
的。 -
进程和线程的关系
:- ⼀个进程崩溃后,在
保护模式
下不会对其他进程产⽣影响,但是⼀个线程崩溃整个进程都死掉。所以多进程要⽐多线程健壮
。 - 进程
切换时
,消耗的资源⼤
,效率低
。所以涉及到频繁的切换时,使⽤线程要好于进程。同样如果要求同时进⾏并且⼜要共享某些变量的并发操作
,只能⽤线程不能⽤进程。 - 执⾏过程中每个独⽴的进程有⼀个程序运⾏的
⼊⼝
、顺序执⾏序列
和程序⼊⼝
。但是线程不能独⽴执⾏
,必须依存在应⽤程序中,由应⽤程序提供多个线程执⾏控制。 - 线程是处理器调度的
基本单位
,但是进程不是。 - 线程
没有地址空间
,线程包含在进程地址空间中。
- ⼀个进程崩溃后,在
1.3 多线程的意义
在开发过程中,多线程我们一直在面对着,但是我们明白多线程能给开发带来什么好处吗?通过以下的案例分析看看:
上面的案例循环十万次创建内容,因为
没有进行异步
的处理,所以这个操作一直在主线程
进行。这个过程一共耗时20
秒左右,这样子给APP
带来卡顿
,极大地影响了用户体验。
为了解决以上的问题,我们需要对循环中的任务进行异步处理
。如果一个事务很复杂,比较耗时,可以将一个大的事务拆分成多个小的事务进行并发处理
,这样可以节省时间,并且不会影响用户的体验。
多线程的优缺点
:
-
优点:
1.能适当提⾼程序的执⾏效率
2.能适当提⾼资源的利⽤率
(如CPU
,内存
)- 线程上的任务执⾏完成后,线程会
⾃动销毁
- 线程上的任务执⾏完成后,线程会
缺点
1.开启线程需要占⽤⼀定的内存空间
(默认情况下,每⼀个线程都占512KB
)
2.如果开启⼤量的线程,会占⽤⼤量的内存空间
,降低程序的性能
3.线程越多,CPU
在调⽤线程上的开销就越⼤
4.程序设计更加复杂
,⽐如线程间的通信
、多线程的数据共享
1.4时间片概念
开启过多的线程也会导致性能的下降
,这里涉及到时间片
的概念。多线程的执行是CPU
快速的在多个线程之间进行切换
。线程数过多,CPU
会在多个线程之间切换,销毁大量的CPU
资源,反而导致执行效率的下降
。
-
时间片的概念
:CPU
在多个任务直接进⾏快速的切换
,这个时间间隔就是时间⽚
。(单核CPU
)同⼀时间,CPU
只能处理1
个线程,换⾔之,同⼀时间只有1
个线程在执⾏ -
多线程同时执⾏
:是CPU
快速的在多个线程之间的切换
,CPU
调度线程的时间⾜够快
,就造成了多线程的同时执⾏的效果
-
线程数⾮常多的情况
:CPU
会在N
个线程之间切换,消耗⼤量的CPU
资源,每个线程被调度的次数会降低
,线程的执⾏效率降低
2.iOS内存五大区
我们知道内存主要分为五大区,分别是:栈区
、堆区
、全局区
、常量区
、代码区
,详细见下图:
2.1栈区(stack
)
-
特点
- 栈是
系统数据结构
,其对应的进程或者线程是唯一
的 - 栈是
向低地址扩展
的数据结构 - 栈是一块
连续的内存区域
,遵循先进后出
(FILO
)原则 - 栈的地址空间在
iOS
中是以0X7
开头 - 栈区一般在
运行时分配
- 栈是
-
存储内容
- 栈区是由编译器
自动分配并释放的
,主要用来存储局部变量
-
函数的参数
,例如函数的隐藏参数
(id self
,SEL _cmd
)
- 栈区是由编译器
-
优缺点
- 优点:因为栈是由编译器自动分配并释放的,
不会产生内存碎片
,所以快速高效
- 缺点:栈的
内存大小有限制
,数据不灵活
iOS主线程栈大小是1MB
,其他主线程是512KB
,MAC
只有8M
- 优点:因为栈是由编译器自动分配并释放的,
注意:传入函数的参数值、函数体内声明的局部变量等,由编译器自动分配释放,通常在函数执行结束后就释放了
。(不包括static
修饰的变量,static
意味该变量存放在全局/静态区
)。
2.2堆区(heap
)
-
特点
- 堆是
向高地址扩展
的数据结构 - 堆是
不连续的内存区域
,类似于链表结构
(便于增删,不便于查询
),遵循先进先出
(FIFO
)原则 - 堆的地址空间在
iOS
中是以0x6
开头,其空间的分配总是动态
的 - 堆区的分配一般是在
运行时
分配
- 堆是
-
存储内容
- 堆区是由
程序员动态分配和释放
的,如果程序员不释放,程序结束后,可能由操作系统回收
-
OC
中使用alloc
或者使用new
开辟空间创建对象 -
C
语言中使用malloc
、calloc
、realloc
分配的空间,需要free
释放
- 堆区是由
-
优缺点
- 优点:
灵活方便
,数据适应面广泛
- 缺点:
需手动管理
,速度慢
、容易产生内存碎片
- 优点:
注意:当访问堆中内存时,一般需要先通过对象读取到栈区
的指针地址
,然后通过指针地址访问堆区
。因为现在iOS
基本都使用ARC
来管理对象,所以也不需要手动释放
。
2.3全局区(静态区
)(BSS段
)
BSS段(bss segment)
:通常是指用来存放程序中未初始化
的或者初始值为0
的全局变量的一块内存区域
。BSS
是英文Block Started by Symbol
的简称。BSS段
属于静态内存分配
。数据段
:数据段(data segment
)通常是指用来存放程序中已初始化
的全局变量
的一块内存区域,数据段属于静态内存分配
。-
全局区是
编译时
分配的内存空间,在iOS中一般以0x1
开头,在程序运行过程中,此内存中的数据一直存在
,程序结束后由系统释放,主要存放
未初始化的全局变量和静态变量,即BSS区(.bss)
已初始化的全局变量和静态变量,即数据区(.data)
- 由
static
修饰的变量会成为静态变量
,该变量的内存由全局/静态区
在编译阶段
完成分配,且仅分配一次
。 -
static
可以修饰局部变量
也可以修饰全局变量
。
2.4常量区(数据段
)
- 常量区是
编译时
分配的内存空间,在iOS
中一般以0x1
开头,在程序结束后由系统释放
- 通常是指用来存放程序中
已经初始化的全局变量和静态变量
的一块内存区域
。数据段属于静态内存分配
,可以分为只读数据段
和读写数据段
。字符串常量
等,是放在只读数据段中,结束程序时才会被收回
。
2.5代码区(text段
)
- 代码区是
编译时
分配主要用于存放程序运行时的代码
,代码会被编译成二进制存进内存
- 代码区需要防止在运行时
被非法修改
,所以只准许读取操作
,而不允许写入(修改
)操作。
3.线程的生命周期
线程的生命周期包含5
个阶段,包括:新建
、就绪
、运行
、阻塞
、销毁
。见下图:
-
新建
:就是刚通过alloc
,创建出来的线程; -
就绪
:就是调用的线程的start
方法后,这时候线程处于等待CPU分配资
源阶段,谁先抢的CPU
资源,谁开始执行; -
运行
:当就绪的线程被调度并获得CPU资源
时,便进入运行状态,run
方法定义了线程的操作和功能; -
阻塞
:在运行状态的时候,可能因为某些原因导致运行状态的线程变成了阻塞状态,比如sleep
、等待同步锁
,线程就从可调度线程池移出
,处于了阻塞状态,这个时候sleep
到来、获取同步锁
,此时会重新添加到可调度线程池
。唤醒的线程不会立刻执行run
方法,它们要再次等待CPU分配资源
进入就绪状态
; -
销毁
:如果线程正常执行完毕
后或线程被提前强制性的终止
或出现异常
导致结束,那么线程就要被销毁,释放资源
。
4.线程池的运行策略
线程池的运行策略,见下图:
由上图可知:
队列满
且正在运行的线程数量小于最大线程数
,则新进入的任务,会直接创建非核心线程
工作。
-
线程池刚创建时
,里面没有一个线程。任务队列是作为参数传进来
的。不过,就算队列里面有任务,线程池也不会马上执行
它们。 -
当有任务时
,线程池会做如下判断:- 如果正在运行的线程数量小于
corePoolSize
(核心线程数
),那么马上创建核心线程运行这个任务; - 如果正在运行的线程数量
大于
或等于corePoolSize
,那么将这个任务放入队列
; - 如果这时候队列满了,而且正在运行的线程数量小于
maximumPoolSize
(最大线程数),那么还是要创建非核心线程立刻运行这个任务; - 如果队列满了,而且正在运行的线程数量
大于
或等于maximumPoolSize
,那么线程池饱和策略将进行处理。
- 如果正在运行的线程数量小于
- 当一个
线程完成任务时
,它会从队列中取下一个任务
来执行。 - 当一个
线程无事可做
,超过一定的时间(超时)时,线程池会判断,如果当前运行的线程数大于corePoolSize
,那么这个线程就被停
掉。所以线程池的所有任务完成后,它最终会收缩到corePoolSize
的大小。
线程池的饱和策略
如果线程池中的队列满
了,并且正在运行的线程数量已经大于等于
当前线程池的最大线程数
,则进行饱和策略
的处理。
-
AbortPolicy
直接抛出RejectedExecutionExeception
异常来阻⽌系统正常运⾏ -
CallerRunsPolicy
将任务回退到调⽤者
-
DisOldestPolicy
丢掉等待最久的任务
-
DisCardPolicy
直接丢弃任务
5.自旋锁和互斥锁
5.1.自旋锁
一种用于保护多线程共享资源
的锁,与一般互斥锁(mutex
)不同之处在于当自旋锁尝试获取锁时以忙等待(busy waiting)
的形式不断地循环检查锁
是否可用。当上一个线程的任务没有执行完毕的时候(被锁住),那么下一个线程会一直等待(不会睡眠),当上一个线程的任务执行完毕,下一个线程会立即执行。
注意:在多CPU
的环境中,对持有锁较短
的程序来说,使用自旋锁
代替一般的互斥锁
往往能够提高程序的性能。
自旋锁:OSSpinLock
、dispatch_semaphore_t
5.2互斥锁
当上一个线程的任务没有执行完毕的时候(被锁住
),那么下一个线程会进入睡眠状态
等待任务执行完毕,当上一个线程的任务执行完毕,下一个线程会自动唤醒
然后执行任务
,该任务也不会立刻执行,而是成为可执行状态
(就绪
)。
互斥锁:pthread_mutex
、@synchronized
、NSLock
、NSConditionLock
、NSCondition
、NSRecursiveLock
5.3自旋锁和互斥锁的特点
-
自旋锁会忙等
,所谓忙等,即在访问被锁资源时,调用者线程不会休眠
,而是不停循环
访问是否已经解锁,直到被锁资源释放锁。 -
互斥锁会休眠
,所谓休眠,即在访问被锁资源时,调用者线程会休眠,此时cpu
可以调度其他线程工作,直到被锁资源释放锁。此时会唤醒休眠线程
。 -
自旋锁优缺点
-
优点
:因为自旋锁不会引起调用者睡眠
,所以不会进行线程调度
,CPU
时间片轮转等耗时操作。所有如果能在很短的时间内获得锁
,自旋锁的效率远高于
互斥锁。 -
缺点
:自旋锁一直占用CPU
,他在未获得锁的情况下,一直运行自旋,所以占用着CPU
,如果不能在很短的时间内获得锁,这无疑会使CPU
效率降低。自旋锁不能实现递归调用
。
-
5.4原子属性和非原子属性
-
OC
在定义属性时有nonatomic
和atomic
两种选择,默认为atomic
属性-
atomic
:原子属性
,为setter
方法加自旋锁(即为单写多读
) -
nonatomic
:非原子属性
,不会为setter
方法加锁
-
-
nonatomic
和atomic
的对比-
atomic
:线程安全
,需要消耗大量的资源
; -
nonatomic
:非线程安全
,适合内存小
的移动设备。
-
注意:
- 如非需抢占资源的属性(如购票,充值),所有属性都声明为
nonatomic
。 - 尽量避免多线程抢夺同一块资源。
- 尽量将加锁、资源抢夺的业务逻辑交给服务器端处理,减小移动客户端的压力。
5.5原子属性和非原子属性的原理
在探索类的本质时,对于类的属性的setter
方法,系统会有一层objc_setProperty
的封装(libobjc.dylib
源码)。见下图:
底层会调用
reallySetProperty
方法,在该方法的实现中,针对原子属性
,添加了spinlock
锁,见下图:Spinlock
是Linux
内核中提供的一种比较常见的锁机制
,自旋锁是原地等待的方式解决资源冲突
的,即,一个线程获取了一个自旋锁后,另外一个线程期望获取该自旋锁,获取不到,只能够原地打转(忙等待)。由于自旋锁的这个忙等待的特性,注定了它使用场景上的限制 —— 自旋锁不应该被长时间的持
有(消耗CPU资源
)。注意:
atomic
只是原子属性,一个标识符,所以atomic
并不是自旋锁,底层是通过Spinlock
实现自旋锁。
6.iOS技术方案
补充:锁的详细剖析会在下一篇文章中