type
status
date
slug
summary
tags
category
icon
password
在上篇介绍了线程安全隐患后,也说明了同步的工具:
- 原子操作;
- 内存屏障和volatile变量;
- 锁和信号量
这篇文章会着重介绍各种各样的锁,以及它们之间的区别与联系。
本文介绍的锁以及其他的同步方案,如下图所示:
在介绍以上同步方案的时候,示例仍然以上篇文章中提出的示例——存钱取钱和卖票为例,进行各种同步方案的演示,本文代码——线程同步方案。
一、pthread
在前面,我们了解
pthread
是一套基于C语言通用的线程API。使用
pthread
需要注意资源的销毁,防止内存泄漏。1.1 互斥锁
互斥锁,获得锁的线程对资源拥有访问权,在其他线程想继续访问该资源,必须等待。多线程之间体现的是一种竞争,我离开了,通知你进来。用于防止资源读写竞争关系。
1.2 递归锁
pthread_mutex
可以指定锁的类型,当如下指定为递归,即可获得递归锁。递归锁只是互斥锁的一个变体,同样只能有一个线程访问该对象,但允许同一个线程在未释放其拥有的锁时反复对该锁进行加锁操作。
递归锁,常用于递归调用中,如下:
或者是在加锁代码中,又调用其他方法,而其他方法也进行了加锁:
1.3 条件锁
条件锁,是互斥锁的另一个变体。
条件锁,体现的是一种协作,我准备好了,通知你开始吧,一般用于线程同步,只共同完成一个任务。这种协作关系,最经典的就是"生产者-消费者"。
具体在实际应用中,允许开发者在代码中指定一个"条件",线程A等待某个条件才能加锁。线程B在访问临界资源后释放锁,通过"条件"来唤醒线程A。
如下是"生产者消费者"的一个示例:
二、GCD
在介绍为
pthread
方案后,GCD
也为我们提供了一些解决方案。2.1 dispatch_queue
串行队列的特性,为我们线程同步提供了一种方案。
由于串行队列所有的任务都是依序一个接着一个的执行,所以也就直接从源头避免了多线程,当然就不存在线程同步的问题。
2.2 dispatch_semaphore
dispatch_semaphore
就是信号量,关于信号量我们要重新梳理下这个概念。信号量是一个特殊的变量,程序对其访问都是原子操作,且只允许对它进行等待(即P(信号变量))和发送(即V(信号变量))信息操作。最简单的信号量是只能取0和1的变量,这也是信号量最常见的一种形式,叫做二进制信号量。而可以取多个正整数的信号量被称为通用信号量。这里主要讨论二进制信号量。
2.2.1 信号量工作原理
信号量只有两种操作:等待和发送,即P(s)和V(s),其中s就是可用资源的数目:
P(s):若s>0,就s=s-1;
若s=0,就挂起该进程;
V(s):若有其他进程因等待s而被挂起,就让它恢复运行;
若没有进程因等待sv而挂起,s=s+1。
- s>0 表示有临界资源可供使用,为什么不唤醒进程? s>0 表示当前有临界资源可用,没有进程会被阻塞在这个资源上,所以不需要唤醒。
- s<0 表示无可用的临界资源,为什么还要唤醒进程? V原语操作的本质在于:一个进程使用完临界资源后,释放临界资源,使s加1,以通知其它的进程,这个时候如果s<0,表明有进程阻塞在该类资源上,因此要从阻塞队列里唤醒一个进程来“转手”该类资源。 比如,有两个某类资源,四个进程A、B、C、D要用该类资源,最开始s=2,当A进入,s=1,当B进入s=0,表明该类资源刚好用完, 当C进入时s=-1,表明有一个进程被阻塞了,D进入,s=-2。当A用完该类资源时,进行V操作,s=-1,释放该类资源,因为s<0,表明有进程阻塞在该类资源上,于是唤醒一个。
- s的绝对值表示等待的进程数,同时又表示临界资源,这到底是怎么回事?当s<0时,其绝对值表示系统中因请求该类资源而被阻塞的进程数目;当s>0时,表示可用的临界资源数。注意在不同情况下所表达的含义不一样。当s=0时,表示刚好用完。
2.2.2 API
2.3 dispatch_barrier_
另外一种方式采用GCD栅栏函数,即
dispatch_barrier_async
或dispatch_barrier_sync
,这个在后面读写安全中会详述。三、Cocoa
在macOS和iOS中,将
pthread
进行了面向对象的封装。3.1 OSSpinLock
OSSpinLock
,是一种自旋锁,其性能非常好,但是会出现优先级反转的文档。所以iOS10之后,苹果已经废弃该锁,并推出了新的锁os_unfair_lock
。3.2 os_unfair_lock
os_unfair_lock
取代的是OSSpinLock
,那么它是如何解决优先级反转的呢。os_unfair_lock
锁中存储了线程所有者的相关信息,系统利用这些信息用来尝试解决优先级反转。另外需要注意的是,
os_unfair_lock
解锁需要和加锁的处于相同线程中,假如不相同,可能会导致线程中止。3.3 NSLock、NSRecursiveLock
3.4 NSCondition
3.5 NSConditionLock
3.6 @synchronized
四、读写安全
4.1 读写安全的场景
占位:补上图。参考面试部分的图。
4.2 pthread_rwlock
4.3 dispatch_barrier_async
五、锁的分类与对比
5.1 分类
我们从上篇文章摘取如下,注意锁的使用:
- 使用锁时需要考虑死锁、活锁和优先级反转等问题。
- 锁的使用应该尽量小范围和时间短,避免影响程序性能。
- 在iOS开发中,推荐优先使用GCD和
NSOperation
等高级抽象,因为它们内部已经处理好了同步问题。
每种锁都有其适用场景和限制,开发者应根据具体需求和上下文选择最合适的锁类型。
锁 | 概念 | 使用场景 | 实例 |
互斥锁
Mutex Locks | 1.互斥锁是最基本的锁类型,只允许一个线程持有锁。
2.当一个线程尝试获取已被其他线程持有的互斥锁时,该线程会被阻塞,直到锁被释放。 | 适用于大多数需要对共享资源进行保护的场景。例如,当修改全局变量或共享数据结构时。 | NSLock :基本的互斥锁,提供简单的锁定和解锁功能。
pthread_mutex_t :POSIX标准的互斥锁,提供底层锁定机制,需要手动初始化和销毁。
os_unfair_lock :Apple提供的低级别互斥锁,比 pthread_mutex 更高效 (自iOS 10起推荐替代OSSpinLock)
@synchronized :Objective-C中的语法糖,一种简便的互斥锁. |
递归锁
Recursive Locks | 1.允许同一个线程多次获取同一把锁,而不会导致死锁。
2.每次获取锁都需要相应的释放次数才能完全解锁。 | 适用于递归函数或深度不确定的递归结构。 | NSRecursiveLock :允许同一个线程多次加锁的锁。
pthread_mutex with PTHREAD_MUTEX_RECURSIVE :需要高性能递归锁的场景。 |
读写锁
Read-Write Locks | 读写锁允许多个读取者同时访问资源,但只允许一个写入者。当有写入请求时,所有读取者必须等待。 | 适用于读多写少的场景。例如,在数据库缓存或文档阅读器中。 | NSLock (通过策略实现读写锁):NSLock本身不支持读写锁,但可以通过策略实现类似功能。
pthread_rwlock_t :POSIX线程库提供的读写锁。
NSReadWriteLock :Apple提供的读写锁。 |
条件锁
Condition Locks | 条件锁允许线程在满足特定条件之前等待,同时允许其他线程改变这个条件来唤醒等待的线程。 | 适用于线程间通信的同步,特别是在某些条件满足前需要挂起线程,如生产者消费者模型。
| NSCondition :基于条件变量的锁,用于线程同步。
NSConditionLock :结合了互斥锁和条件变量,支持带条件的锁定操作。 |
自旋锁
Spinlocks | 自旋锁会反复轮询其锁定条件,让试图获取锁的线程在原地循环(“自旋”),直到锁变为可用状态。
避免了线程上下文切换的开销,但若锁被长时间持有,会消耗CPU资源。 | 适用于锁竞争不激烈且锁保护的代码执行时间非常短,线程不希望在等待时放弃CPU的场景,以减少上下文切换的开销。 | OSSpinLock :自旋锁,其性能非常好,但是会出现优先级反转。 |
信号量
Semaphores | 信号量是一种计数的信号,不仅可以实现互斥,还可以控制对资源的访问数量。 | 适用于需要控制同时访问资源的线程数量的场景,如限制并发下载的数量、连接池。 | dispatch_semaphore_t :GCD提供的信号量机制,用于同步和资源计数控制。
|
队列锁
Queue-Based Locks | 严格说来不是锁,但是串行队列中,基于队列先进先出的规则来管理对资源的访问。 | 适用于需要按顺序执行任务或保护资源的场景,同时提供更高级别的抽象,使得代码更易于理解和维护。 | NSOperationQueue :通过操作队列来管理并发任务的执行。
dispatch_queue_t (特别是DISPATCH_QUEUE_SERIAL)
dispatch_sync 或dispatch_barrier_async 实现同步或屏障效果。 |
原子操作
Atomic Operations | 不直接涉及锁,但通过硬件指令保证操作的原子性,防止数据竞争。 | 适用于简单类型的读写操作,如布尔值或整型的更新,提供较低级别的并发保护,性能较高。 | atom 关键字 |
5.2 对比
5.2.1 自旋锁与互斥锁
针对以上的锁的分类,我们仍然需要重点说明自旋锁与互斥锁:
- 互斥锁:当一个全局资源被多个线程访问且面临竞争时,需要互斥锁来解决这一竞争问题来保证访问这一全局资源的原子性即数据独立性不被破坏,也就是说并不是有多个线程访问同一全局资源就需要使用互斥锁。如果一个线程无法获取互斥量,该线程会被直接挂起,不再消耗CPU时间,当其他线程释放互斥量后,操作系统会激活被挂起的线程。
- 自旋锁:是一种用于保护多线程共享资源的锁,与一般互斥锁不同之处在于当自旋锁尝试获取锁时以忙等待(busy waiting)的形式不断地循环检查锁是否可用(可以通过汇编分析忙等)。在多CPU的环境中,对持有锁较短的程序来说,使用自旋锁代替一般的互斥锁往往能够提高程序的性能。使用自旋锁时要注意:
- 由于自旋时不释放CPU,因而持有自旋锁的线程应该尽快释放自旋锁,否则等待该自旋锁的线程会一直在哪里自旋,这就会浪费CPU时间。
- 持有自旋锁的线程在sleep之前应该释放自旋锁以便其他线程可以获得该自旋锁。内核编程中,如果持有自旋锁的代码sleep了就可能导致整个系统挂起。
整理如下图:
5.2.2 性能对比
我们通过线程方案性能对比测试了不同同步方案之间的性能,如下图所示:
测试中发现,性能明显分为三个等级:
- 第一梯队:OSSpinLock及其替代者os_unfair_lock,信号量
- 第二梯队:pthread_mutex及其封装后的NSLock、NSRecursiveLock、NSCondition
- 第三梯队:串行队列、
@synchronized
以及NSConditionLock。
并且在测试中发现,同梯队内可能会有浮动,但是梯队之间泾渭分明。
面对这么多同步方案,了解性能的差异,我们还需要给出一些小建议,如何使用它们。
5.3 使用
- 多核处理器:如果预计线程等待锁的时间比较短,短到比线程两次切换上下文的时间还要少的情况下,自旋锁是更好的选择。如果时间比较长,则互斥锁是比较好的选择。
- 单核处理器:一般不建议使用自旋锁。因为在同一时间片内只有一个线程处在运行状态,如果线程a已经获取到锁,但是此时时间片轮到线程b执行,但是线程b获取锁,只能等待解锁,但是因为自己不挂起,所以线程a无法进入运行状态,只能等到线程b的时间片消耗完,线程a才有机会被OS调度执行。此时使用自旋锁的代价很高。
- 如果加锁的代码经常被调用,但是竞争发生的比较少时,应该优先考虑使用自旋锁,毕竟互斥锁的切换上下文的开销比较大。推荐方案:信号量、
pthread
系列锁,或者其面向对象的NSLock
系列锁。
参考
链接
示例代码