在软件系统中,随着一个程序被打开,意味着一个进程的启动和调度的开始。对于我们程序员来说,相对于进程,我们更关注粒度更小的线程,它是我们都需要与之打交道的用来实现多任务并发执行的利器。
什么是线程
老生常谈,但还是先说一下概念:
线程,又称轻量级进程,是程序执行流的最小单元。一个标准的线程由线程ID、当前指令指针、寄存器集合和堆栈组成。通常意义上,一个进程由一个到多个线程组成,各个线程之间共享程序的内存空间(包括代码段、数据段、堆等)及一些进程级的资源。
有两点需要注意:
- 线程有私有的数据单元:仅仅可以让当前线程访问的寄存器和栈
- 共享进程的内存空间:多个线程同时访问一个数据段或同时执行一块代码,就会有线程安全的问题出现。
数据是否私有,如下表所示
线程私有 | 线程间共享 |
---|---|
局部变量 函数参数 |
全局变量 堆上的数据 函数内的静态变量 代码 |
线程调度
如果线程数量小于等于处理器数量时,线程是真正意义的并发,不同的线程运行在不同的处理器上。这很理想。
但更经常地,我们可以看到类似这样的图:
上图中并发的情况是针对线程数量大于处理器数量(多CPU或多核时代,处理器数量不止一个)的情况。学过计算机原理,我们都知道,这种“并发”是一种模拟出来的状态,快速的时间片切换让用户觉得像是同时在执行。这种时间片切换,称为线程调度。
在线程调度中,线程有三种状态:
- 运行:拥有时间片,可以执行代码
- 就绪:在等待队列中,随时可以被执行
- 等待:等待 IO 或者因为其他原因被阻塞,等待结束后进入就绪状态
三者的状态切换如下图所示:
线程有状态了,需要等待调度的发生,自然就涉及到轮换调度和优先级。
轮换调度让各个线程轮流执行一小段时间后进入就绪状态或等待状态,而优先级则决定了 CPU 让处于就绪状态的哪个线程先执行。优先级改变有以下三种方式:
- 在我们的编程中,我们可以手动设置优先级,最常见的,如 iOS 中 GCD 的
global queue
:
// 创建一个优先级为HIGH的全局队列
dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_HIGH, 0);
- 操作系统也是会根据场景来适当提高线程的优先级,让更多的线程可以执行。通常情况下,频繁等待的线程( IO 密集型线程)通常只占用很少的时间,这种线程比用完时间片的线程( CPU 密集型线程)更受欢迎,在操作系统的调度中优先级更容易被提高。
- 在调度过程中,还存在线程饿死( Starvation ) 的现象,就是优先级很低的线程一直得不到时间片。为了解决这一问题,操作系统会逐步提高这些线程的优先级。
线程安全
Q : 为什么会有线程安全问题?
A : 如上所述,线程间有共享的数据:全局变量、堆上的数据、函数内的静态变量、代码等。两个线程同时修改一个全局变量,会导致修改后的结果不确定。如,两个线程同时对一个变量 i
执行 i++
,线程 A 和线程 B 都各自拥有自己的寄存器,对i++
这种操作,线程 A 先将i
的值存放在线程的寄存器中,执行计算后再赋值回去给i
这个变量,中间如果线程 B 在 A 赋值之前先获取到i
的值,那么最后赋值的结果肯定不正确。这就是线程不安全。
Q : 怎么保证线程安全?
A : 保证数据或代码段在一段时间内只有一个线程在访问,方法有:原子操作、同步。
- 原子操作( Atomic )
原子操作,简单点来说就是单条指令的执行。但仅适用于特定的场合,在复杂的场合下,比如我们要保证一个复杂的数据结构更改的原子性,原子操作指令就力不从心了。 - 同步与锁
同步也是一种保证线程安全的方式,是指在一个线程访问数据未结束的时候,其他线程不能对同一个数据进行访问。同步常见的有锁、信号量、临界区等。
锁
同步最常见的操作就是锁,线程再访问数据之前视图获取锁,访问结束后释放锁。在获取锁时,如果锁被占用了,则线程会等待,直到锁重新可用。
最简单的(关于锁的详细内容,不在本文的讨论范围内):
NSLock *theLock = [[NSLock alloc] init];
if ([theLock lock]) {
// 需要同步的代码
[theLock unlock];
}
信号量
信号量也可以用在同步方面,但是这里所说的是二元信号量,即只表示占用与非占用。例如 GCD 中:
// 创建一个二元信号量,dispatch_semaphore_create的入参为0
dispatch_semaphore_t semaphore = dispatch_semaphore_create(0);
// 使用wait让semaphore减1
dispatch_semaphore_wait(semaphore, DISPATCH_TIME_FOREVER);
// 使用signal让semaphore加1
dispatch_semaphore_signal(semaphore);
当信号量semaphore
小于 0 时,线程会暂停执行,直到semaphore
重新变成 0 。
临界区
临界区更多的是指一个访问共用资源的代码块,而这些共用资源又无法同时被多个线程访问的特性。
信号量,我们知道,在本线程创建,可以在其他线程被获取到并调用signal
或wait
来操作信号量。相对来说,临界区显得更加严格。本线程进入临界区,只有当退出临界区了,其他线程才能访问临界区的代码块。如下代码,相信大家肯定不陌生:
@synchronized(self) {
// 临界区代码
}
可重入( Reentrant )
可重入的概念,适用于函数。
首先了解一下什么是 函数被重入 :一个函数被重入,表示这个函数没有执行完成,由于外部因素(多线程同时调用)或内部调用(递归函数),又一次进入了该函数。
可重入函数也保证线程安全,因为该种函数被重入之后不会产生任何不良的后果,比如:
- (int)sqr:(int) x {
return x * x;
}
保证可重入,需要保证以下几个方面:
- 不使用静态或全局非 const 的变量
- 不返还静态或全局非 const 变量的指针
- 仅依赖入参
- 不调用不可重入的函数
线程和队列的关系
线程和队列的关系,一直都纠缠不清。
在开发中,我们会接触到两种队列,串行队列和并行队列,二者的区别是:串行队列中的任务是有序被执行的,并行队列中的任务是利用多线程并发执行,不能保证执行顺序。
举个可能不太恰当的例子:线程是消费者,队列是传输纽带,任务是面包,线程执行任务,比作将面包吃掉。对于面包呢,我(当前线程)可以自己享用,也可以通过传输带给其他消费者享用(任务可以自己完成,也可以交由其他线程去完成)。
串行队列:它是有序的传输带,编号是 SQ ,我的面包吃不完,所以我往上面放了一个面包,给其他人享用,此时有消费者 A 拿了,并开始吃。同时消费者B也想分享他的面包,他也把面包放到 SQ 上,那就要等待A吃完面包了,B 分享的面包才会被消费。此时传输带 SQ 就被暂停了。
并行队列:任务的执行时无需等待前面的任务执行完,编号是 CQ ,同样,我的面包吃不完,放到 CQ 上, A 正在享用,此时 B 也将面包放在 CQ 上,但是 A 无法享用(因为正在享用我的),因此 CQ 就发出警鸣,叫来了消费者 C 来将 B 的面包吃掉,以维持 CQ 的继续运转,当 A 吃完后,还可以继续享用其他人分享的面包。所以,并行队列并不是一定会产生新的线程,如果有空闲的线程还是会被继续叫来执行任务,只是平时线程执行完就被销毁,所以队列在需要执行新任务的时候就给我们造成一个创建新线程的假象。
死锁
首先,概念:
死锁 是指两个或两个以上的线程在执行过程中,由于竞争资源或者由于彼此通信而造成的一种阻塞的现象。
死锁不需要多说,相信学过计算机理论的同学都知道。多线程改善了系统资源的利用率和提高了系统的处理能力,多线程并发执行很有可能造成死锁,无外力作用的情况下,整个进程就无法继续推进。用户对程序内部的运作不知情,但看到界面毫无响应,怎么点击都没用,此时就会责怪应用的不完善。
上述关于死锁 的概念来自百科,有一点我觉得要更正的是,死锁也有可能是由单一线程造成。作为一个 iOS 应用开发者,我使用 GCD 来说明一下:
// 主线程中调用
dispatch_sync(dispatch_get_main_queue(), ^{
// do something
})
上述代码必将造成死锁,但是此例只涉及一个线程,那就是 iOS App 的主线程(亦称 UI 线程)。我们知道,在 iOS 中,main queue
是串行队列,其任务只能由主线程去完成。而dispatch_sync
会阻塞当前线程,因此,当上述代码用在主线程中调用时,主线程被会阻塞,然后将block
放在main queue
中,等主线程执行完block
中的代码后才释放主线程,然而主线程已被阻塞,无法执行main queue
中的block
,所以造成死锁。
总结
本文对线程做了一个简单的描述,在每个 App 都涉及多线程开发的现在,需要注意线程安全的问题,通过原子操作、同步等方式可以保证。还讲述了串行队列和并行队列,最后浅浅地点了一下死锁的概念,算是一篇写给线程小白看的入门级 iOS 文章,如有写得不好的地方,请各位斧正。