什么是锁?
在程序中,当多个任务(或线程)同时访问同一个资源时,比如多个操作同时修改一份数据,可能会导致数据不一致。这时候,我们需要“锁”来确保同一时间只有一个任务能够操作这个数据,避免“抢占”问题。简单来说,锁就是一种机制,它能帮助你控制多个任务按顺序来操作资源。
按照锁的功能来进行分类,iOS常见的锁:自旋、互斥、递归、条件。。。
一、自旋锁
自旋锁的意思就是当资源被占有时,自旋锁不会引起其他调用者休眠,而是让其他调用者自旋,不停的循环访问自旋锁导致调用者处于busy-wait(忙等状态),直到自旋锁的保持者释放锁。自旋锁是为了实现保护共享资源一种锁机制,在任何时刻只能有一个保持者,也就是说在任何时刻只能有一个可执行单元获得锁。也正是因为其他调用者会保持自旋状态,使得在锁的保持者释放锁时能够即刻获得锁,效率非常高。但我们说调用者时刻自旋也是消耗CPU资源的,所以如果自旋锁的使用者保持锁的时间比较短的话,使用自旋锁是非常合适的,因为在锁释放之后省去了唤醒调用者的时间。
1.OSSpinLock(已弃用)
OSSpinLock
是一种轻量级的锁。当一个线程获取不到锁时,它不会进入睡眠状态,而是一直循环检查锁是否可用,这叫“自旋”。- 缺点:
OSSpinLock
已经被弃用,因为它容易导致“优先级反转”(低优先级线程获取锁,高优先级线程等待锁释放,造成高优先级线程无法执行)。
2.os_unfair_lock
-
os_unfair_lock
是OSSpinLock
的替代品。它解决了优先级反转问题,当一个线程无法获取锁时,会立即休眠而不是自旋。 -
适用场景:用于短时间的锁定操作,轻量、快速。
var unfairLock = os_unfair_lock_s()
func safeMethod() {
os_unfair_lock_lock(&unfairLock)
// 执行共享资源的操作
os_unfair_lock_unlock(&unfairLock)
}
用GCD模拟多线程,看是否输出是否按顺序输出:
import Foundation
// 初始化不公平锁
var unfairLock = os_unfair_lock_s()
// 共享资源(例子:计数器)
var sharedCounter = 0
// 线程安全的方法,增量计数器
func safeIncrement() {
// 加锁,确保只有一个线程可以访问共享资源
os_unfair_lock_lock(&unfairLock)
// 临界区:操作共享资源
sharedCounter += 1
print("计数器增加: \(sharedCounter)") // 输出当前计数器的值
// 解锁,允许其他线程访问共享资源
os_unfair_lock_unlock(&unfairLock)
}
// 使用DispatchQueue模拟多线程
let queue = DispatchQueue.global()
// 测试线程安全的方法
for _ in 1...10 {
queue.async {
safeIncrement() // 多个线程同时操作计数器
}
}
输出:
二、互斥锁
互斥锁和自旋锁类似,都是为了解决对某项资源的互斥使用,并且在任意时刻最多只能有一个执行单元获得锁,与自旋锁不同的是,互斥锁在被持有的状态下,其他资源申请者只能进入休眠状态,当锁被释放后,CPU会唤醒资源申请者,然后获得锁并访问资源。
1.pthread_mutex_t
pthread_mutex_t
是 POSIX 线程库提供的底层互斥锁,能确保同一时刻只有一个线程访问共享资源。- 适用场景:高效多线程编程,适合对性能要求高的场景。
var mutex = pthread_mutex_t()
pthread_mutex_init(&mutex, nil)
func safeMethod() {
pthread_mutex_lock(&mutex)
// 执行共享资源的操作
pthread_mutex_unlock(&mutex)
}
同样的GCD模拟多线程,看是否输出是否按顺序输出:
import Foundation
//初始化互斥锁(Mutex)
var mutex = pthread_mutex_t()
pthread_mutex_init(&mutex, nil)
// 共享资源(例子:计数器)
var sharedCounter = 0
// 线程安全的方法,增量计数器
func safeIncrement() {
// 加锁,确保只有一个线程可以访问共享资源
pthread_mutex_lock(&mutex)
// 临界区,操作共享资源
sharedCounter += 1
print("计数器增加: \(sharedCounter)") // 输出当前计数器的值
// 解锁,允许其他线程访问共享资源
pthread_mutex_unlock(&mutex)
}
// 使用DispatchQueue模拟多线程
let queue = DispatchQueue.global()
// 测试线程安全的方法
for _ in 1...10 {
queue.async {
safeIncrement() // 多个线程同时操作计数器
}
}
输出:
2.NSLock
- NSLock 是 Cocoa 提供的更高级的互斥锁,它比 pthread_mutex_t 更易于使用。
let lock = NSLock()
func safeMethod() {
lock.lock()
// 执行代码
lock.unlock()
}
GCD模拟多线程:
import Foundation
//初始化互斥锁(NSLock)
var lock = NSLock()
// 共享资源(例子:计数器)
var sharedCounter = 0
// 线程安全的方法,增量计数器
func safeIncrement() {
// 加锁,确保只有一个线程可以访问共享资源
lock.lock()
// 临界区,操作共享资源
sharedCounter += 1
print("计数器增加: \(sharedCounter)") // 输出当前计数器的值
// 解锁,允许其他线程访问共享资源
lock.unlock()
}
// 使用DispatchQueue模拟多线程
let queue = DispatchQueue.global()
// 测试线程安全的方法
for _ in 1...10 {
queue.async {
safeIncrement() // 多个线程同时操作计数器
}
}
3.@synchronized(仅支持 Objective-C)
- 这是 Objective-C 中提供的自动锁机制,是OC的语法糖,Swift 中无法直接使用。它可以帮助你简化锁定的逻辑。
@synchronized(self) {
// 执行共享资源操作
}
三、递归锁
递归锁可以被同一线程多次请求,而不会引起死锁,即在多次被同一个线程进行加锁时,不会造成死锁。这主要是用在循环或递归操作中。
递归锁也是通过 pthread_mutex_lock
函数来实现,在函数内部会判断锁的类型,如果显示是递归锁,就允许递归调用,仅仅将一个计数器加一,等到递归完毕之后,所有锁都会释放。
1.NSRecursiveLock
NSRecursiveLock 是一种递归锁,允许同一个线程多次获取同一把锁而不会导致死锁。这是 NSLock 无法做到的。
- 适用于需要多次锁定同一资源的场景。
let recursiveLock = NSRecursiveLock()
func recursiveFunction(count: Int) {
recursiveLock.lock()
if count > 0 {
print("Count: \(count)")
recursiveFunction(count: count - 1)
}
recursiveLock.unlock()
}
2.pthread_mutex_t (递归锁)
- pthread_mutex_t 也可以被设置为递归模式,用法类似 NSRecursiveLock。
四、条件锁
条件是信号量的另一种类型,当某个条件为true时,它允许线程相互发信号。条件通常用于指示资源的可用性或确保任务以特定顺序执行。当线程测试条件时,除非该条件已经为真,否则它将阻塞。它保持阻塞状态,直到其他线程显式更改并发出条件信号为止。条件和互斥锁之间的区别在于,可以允许多个线程同时访问该条件。
1.pthread_cond_t
pthread_cond_t
是一种条件锁,常与pthread_mutex_t
搭配使用,允许线程在满足特定条件时进行等待或唤醒。- 适用场景:用于需要等待某个条件满足的多线程场景。
2.NSCondition
- NSCondition 是一个高级条件锁,可以让线程根据某些条件来等待或唤醒。
- NSCondition 的底层是通过条件变量(condition variable) pthread_cond_t 来实现的。条件变量有点像信号量,提供了线程阻塞与信号机制,因此可以用来阻塞某个线程,并等待某个数据就绪,随后唤醒线程,比如常见的生产者-消费者模式。生产者-消费者模式可以查看我的另一篇博客:iOS--生产者-消费者模式理解(附GCD信号量代码实现)-CSDN博客
let condition = NSCondition()
var isReady = false
func producer() {
condition.lock()
isReady = true
condition.signal() // 唤醒等待中的线程
condition.unlock()
}
func consumer() {
condition.lock()
while !isReady {
condition.wait() // 等待条件满足
}
// 执行消费操作
condition.unlock()
}
模拟:
import Foundation
let condition = NSCondition()
var isReady = false
// 生产者方法
func producer() {
condition.lock()
print("生产者正在准备资源...")
isReady = true
print("资源准备完成,通知消费者")
condition.signal() // 唤醒等待的消费者线程
condition.unlock()
}
// 消费者方法
func consumer() {
condition.lock()
print("消费者等待资源...")
while !isReady {
condition.wait() // 等待条件满足
}
print("资源已准备好,开始消费资源")
// 执行消费操作
condition.unlock()
}
// 模拟并发:使用DispatchQueue进行生产者和消费者的交互
let queue = DispatchQueue.global()
// 消费者等待资源
queue.async {
consumer()
}
// 模拟生产者延迟生产资源
queue.asyncAfter(deadline: .now() + 2) {
producer()
}
3.NSConditionLock
- NSConditionLock 是 NSCondition 的一种变体,基于条件值进行锁定和解锁,适用于更复杂的线程同步场景。
五、信号量
信号量(Semaphore),有时被称为信号灯,是在多线程环境下使用的一种设施,是可以用来保证两个或多个关键代码段不被并发调用。在进入一个关键代码段之前,线程必须获取一个信号量;一旦该关键代码段完成了,那么该线程必须释放信号量。其它想进入该关键代码段的线程必须等待直到第一个线程释放信号量。为了完成这个过程,需要创建一个信号量VI,然后将Acquire Semaphore VI以及Release Semaphore VI分别放置在每个关键代码段的首末端。确认这些信号量VI引用的是初始创建的信号。
其实本质上,它通过维护一个计数值来控制同时可以访问某一资源的线程数量。
1.dispatch_semaphore_t
dispatch_semaphore_t
是 GCD 提供的信号量机制,用于控制同时访问某一资源的线程数量。- 适用场景:适合控制并发任务的数量。
let semaphore = DispatchSemaphore(value: 1)
func safeMethod() {
semaphore.wait() // 请求资源
// 执行共享资源操作
semaphore.signal() // 释放资源
}
2.pthread_mutex_t(作为信号量使用)
- 可以通过将
pthread_mutex_t
和pthread_cond_t
配合使用,达到类似信号量的效果。
六、读写锁
读写锁实际是一种特殊的自旋锁,它把对共享资源的访问者划分成读者和写者,读者只对共享资源进行读访问,写者则需要对共享资源进行写操作。这种锁相对于自旋锁而言,能提高并发性,因为在多处理器系统中,它允许同时有多个读者来访问共享资源,最大可能的读者数为实际的逻辑CPU数。写者是排他性的,一个读写锁同时只能有一个写者或多个读者(与CPU数相关),但不能同时既有读者又有写者。
读写锁适合于对数据结构的读次数比写次数多得多的情况. 因为, 读模式锁定时可以共享, 以写模式锁住时意味着独占, 所以读写锁又叫共享-独占锁。
1.pthread_rwlock_t
pthread_rwlock_t
是一种读写锁,它允许多个线程同时读取资源,但在写入时会排他性地锁定。- 适用场景:适合读多写少的场景。
var rwlock = pthread_rwlock_t()
pthread_rwlock_init(&rwlock, nil)
func readResource() {
pthread_rwlock_rdlock(&rwlock) // 加读锁
// 读取资源
pthread_rwlock_unlock(&rwlock) // 解锁
}
func writeResource() {
pthread_rwlock_wrlock(&rwlock) // 加写锁
// 写入资源
pthread_rwlock_unlock(&rwlock) // 解锁
}
七、栅栏
栅栏函数在GCD中常用来控制线程同步,在队列中它总是等栅栏之前的任务执行完,然后执行栅栏自己的任务,执行完自己的任务后,再继续执行栅栏后的任务。常用函数有同步栅栏函数(dispatch_barrier_sync)和异步栅栏函数(dispatch_barrier_async)。
import Foundation
// 创建一个并发队列
let queue = DispatchQueue(label: "com.example.concurrentQueue", attributes: .concurrent)
// 异步执行任务1
queue.async {
print("Task 1")
}
// 异步执行任务2
queue.async {
print("Task 2")
}
// 使用 barrier 标志的任务,确保在这个任务期间,队列中不会有其他任务执行
queue.async(flags: .barrier) {
print("Barrier task")
print("Barrier task2") // 在屏障任务中执行第二个打印
}
// 异步执行任务3,等待 barrier 任务执行完毕后继续
queue.async {
print("Task 3")
}
输出顺序:
• Task 1 和 Task 2 可能无序输出,因为它们是并发执行的。
• 屏障任务会在之前的任务完成后执行。
• Task 3 将在屏障任务结束后执行。
总结:
线程安全
上述学习的各种常见的线程锁,都是为了更好地管理和调配线程,而在开发中我们往往不可避免地采用多线程并发,这虽然很便利但是存在的巨大的安全隐患。
什么是线程安全?
线程安全指的是多个线程可能同时操作同一块内存,从而导致的异常情况,先举个例子:
class User {
private(set) var name: String = ""
func setName(_ name: String) {
self.name = name
}
}
let user = User()
let queue1 = DispatchQueue(label: "q1")
let queue2 = DispatchQueue(label: "q2")
queue1.async {
user.setName("1")
print(user.name)
}
queue2.async {
user.setName("2")
print(user.name)
}
这段代码因为多线程并发同时修改同一个变量,导致可能的结果是测试下来可能会是打印了两个 2,这就不符合我们的预期了,明明第一个 user.setName
传入的是 “1”,打印结果却为 2。
这种情况称为资源竞争,两个线程可能同时操作 user
对象,实际上,除了结果不符合预期外,还可能出现一个经典的崩溃 EXC_BAD_ACCESS
,这是因为让两个线程尝试同时操作同一个内存地址导致的。
如何解决资源竞争问题?
很简单,用我们刚学完的锁就可以。这里就不举例子了。。。
其他并发问题
除了上边提到的资源竞争问题,在使用并发的时候还可能导致一些其他问题,也需要注意,比如:
- 条件竞争:无法同步执行两个或多个线程,导致事件以错误的顺序执行
- 死锁:两个线程相互等待,这意味着两者都无法继续,线程会卡死
- 优先级倒置:低优先级任务持有高优先级任务所需的资源,导致执行延迟
- 线程爆炸:程序中申请的线程数量过多,导致资源耗尽和系统性能下降
- 线程匮乏:因为其他线程正在占用这个资源,导致其他线程无法访问,从而导致执行延迟
解决方法:
1. 条件竞争
- 使用锁:使用互斥锁(如
NSLock
)或信号量(如DispatchSemaphore
)确保对共享资源的安全访问。 - 使用队列:使用串行队列或
DispatchQueue
的同步方法来控制对共享数据的访问顺序。
2. 死锁
- 避免嵌套锁:尽量避免一个线程在持有锁时请求另一个锁。
- 设置锁的顺序:确保所有线程按照相同的顺序获取锁。
- 使用超时:设置锁的获取超时,防止无限等待。
3. 优先级倒置
- 优先级提升:在需要时提升低优先级线程的优先级,让所需资源尽快释放。
- 资源管理:确保高优先级线程能及时访问所需资源,比如使用锁机制。
4. 线程爆炸
- 限制线程数量:使用线程池管理线程数量,避免创建过多线程。
- 使用异步任务:采用GCD的队列,减少线程的创建。
5. 线程匮乏
- 优化资源使用:检查资源访问和锁的使用,确保高效利用资源。
- 调整线程设计:使用更灵活的线程模型,比如异步编程,减少对共享资源的依赖。
这里重点讲解一下什么是死锁。。。。。
死锁
在 Swift 中,当两个线程都在等待对方释放资源时,就会发生deadlock
死锁。这会导致线程都处于永久等待状态,当主线程死锁,应用的表现上就是崩溃,其他子线程死锁可能导致卡死。
举个例子:
class ViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
print(1)
DispatchQueue.main.sync {
print(2)
}
print(3)
}
}
上述代码会输出1后程序崩溃。
原因:主线程是处理用户界面和交互的线程。在viewDidLoad
中打印1后,想要在主线程上执行另一个任务(打印2)。使用sync
意味着希望这个任务立刻完成,而主线程正在执行viewDidLoad
方法。因为主线程正在等着这个新任务完成(打印2),但这个任务又在主线程上运行,所以主线程无法继续,导致死锁。
再举一个因为互斥锁而引发死锁的例子:
import Foundation
// 创建两个锁
let lock1 = NSLock()
let lock2 = NSLock()
// 线程1任务
func thread1() {
print("线程1 尝试获取 lock1")
lock1.lock()
print("线程1 获取了 lock1")
// 模拟处理一些操作
sleep(1)
print("线程1 尝试获取 lock2")
lock2.lock() // 死锁发生在这里,因为线程2已经持有了 lock2
print("线程1 获取了 lock2") // 这行永远不会被执行
lock2.unlock()
lock1.unlock()
}
// 线程2任务
func thread2() {
print("线程2 尝试获取 lock2")
lock2.lock()
print("线程2 获取了 lock2")
// 模拟处理一些操作
sleep(1)
print("线程2 尝试获取 lock1")
lock1.lock() // 死锁发生在这里,因为线程1已经持有了 lock1
print("线程2 获取了 lock1") // 这行永远不会被执行
lock1.unlock()
lock2.unlock()
}
// 并发队列
let queue = DispatchQueue.global()
// 启动线程
queue.async {
thread1()
}
queue.async {
thread2()
}
- 线程1 首先获取 lock1,然后等待获取 lock2。
- 线程2 首先获取 lock2,然后等待获取 lock1。
- 由于两个线程互相等待对方释放锁,导致程序进入死锁,两个线程都无法继续执行。
造成死锁的四个条件:
- 互斥条件:某个资源一次只能被一个线程占用。
- 占有且等待:一个线程占有一个资源,同时等待其他资源。
- 不可剥夺:线程所持有的资源不能被强制剥夺。
- 循环等待:两个或多个线程形成一种循环等待关系。
解决死锁的策略
- 避免锁的循环等待:通过统一的锁顺序,确保线程不会互相等待对方的资源。
- 使用超时机制:锁请求可以设置超时时间,防止无限等待。
- 使用NSRecursiveLock(递归锁):允许同一线程多次获取同一把锁,避免递归调用中的死锁问题。
参考:
iOS - 线程中常见的几种锁_unlock tryluck-CSDN博客
谈谈 swift 中的线程安全 - 知乎 (zhihu.com)
讲讲 iOS 中的死锁 (qq.com)