死锁(Deadlock) 是多线程编程中常见的问题,指的是两个或多个线程相互等待对方释放资源,导致所有线程都无法继续执行的情况。死锁会导致程序卡死,无法响应,严重影响系统的稳定性和性能。
1. 死锁产生的条件
死锁的产生需要同时满足以下四个条件(称为 死锁的四个必要条件):
-
互斥条件(Mutual Exclusion):
- 资源一次只能被一个线程占用,其他线程必须等待。
-
占有并等待(Hold and Wait):
- 线程已经占有了至少一个资源,同时又在等待其他资源。
-
非抢占条件(No Preemption):
- 线程占有的资源不能被其他线程强行抢占,只能由线程主动释放。
-
循环等待条件(Circular Wait):
- 存在一个线程等待的循环链,每个线程都在等待下一个线程占有的资源。
2. 死锁的示例
以下是一个典型的死锁示例:
let queue1 = DispatchQueue(label: "com.example.queue1")
let queue2 = DispatchQueue(label: "com.example.queue2")
queue1.async {
queue2.sync {
print("任务 1")
}
}
queue2.async {
queue1.sync {
print("任务 2")
}
}
-
queue1
和queue2
相互等待对方释放资源,导致死锁。
3. 如何解除死锁
一旦发生死锁,通常需要手动干预来解除。以下是一些常见的解除方法:
-
终止线程:
- 强制终止其中一个或多个线程,打破循环等待链。
- 这种方法可能会导致数据不一致或资源泄漏。
-
回滚操作:
- 回滚线程的操作,释放已占有的资源,恢复到死锁前的状态。
- 需要实现事务机制来支持回滚。
-
资源抢占:
- 强行抢占某个线程占有的资源,分配给其他线程。
- 这种方法需要操作系统的支持。
4. 如何预防死锁
预防死锁的核心是 破坏死锁的四个必要条件。以下是一些常见的预防方法:
(1)破坏互斥条件
- 尽量避免使用独占资源,或使用共享资源。
- 例如,使用无锁数据结构(如原子操作)来替代锁。
(2)破坏占有并等待条件
- 要求线程一次性申请所有需要的资源,如果无法满足,则释放已占有的资源。
- 示例:
func acquireResources(resource1: Resource, resource2: Resource) { while true { if resource1.lock() && resource2.lock() { break } else { resource1.unlock() resource2.unlock() } } }
(3)破坏非抢占条件
- 允许系统强行抢占线程占有的资源。
- 例如,设置锁的超时机制,超时后自动释放资源。
(4)破坏循环等待条件
- 对所有资源进行排序,要求线程按顺序申请资源。
- 示例:
let resource1 = Resource() let resource2 = Resource() func acquireResources() { if resource1.id < resource2.id { resource1.lock() resource2.lock() } else { resource2.lock() resource1.lock() } }
5. 死锁的检测与恢复
如果无法完全预防死锁,可以通过检测和恢复机制来处理死锁:
-
死锁检测:
- 定期检查系统中是否存在循环等待链。
- 可以使用资源分配图(Resource Allocation Graph)来检测死锁。
-
死锁恢复:
- 检测到死锁后,终止部分线程或回滚操作,解除死锁。
6. 实际开发中的建议
-
避免嵌套锁:
- 尽量减少锁的嵌套使用,避免复杂的锁依赖关系。
-
使用超时机制:
- 在获取锁时设置超时时间,避免无限等待。
-
使用高阶工具:
- 使用 GCD、OperationQueue 等高级工具,避免直接操作锁。
-
代码审查:
- 通过代码审查发现潜在的死锁风险。
总结
死锁是由于多个线程相互等待资源而导致的程序卡死现象。要预防死锁,可以通过破坏死锁的四个必要条件来实现,例如避免嵌套锁、按顺序申请资源、设置超时机制等。在实际开发中,合理设计多线程程序,使用高阶工具,并通过代码审查和测试来发现潜在的死锁风险,是避免死锁的关键。