常见并发问题
非死锁缺陷
违反原子性
- 即代码段本意是原子的,但是在执行中并没有强制实现原子性
- 解决方法:给有需要的代码加锁
违反顺序缺陷
- 两个内存访问的预期顺序被打破了(即A应该在B之前执行,但是实际运行中却不是这个顺序)
- 解决方法:通过加锁、条件变量以及状态变量配合来强制顺序执行
死锁缺陷
为什么发生死锁
- 大型代码库里,组件之间会有复杂的依赖关系,必须仔细地避免循环依赖导致的死锁
- 模块化封装,使得某些看起来没有关系的接口可能会导致死锁
产生死锁的条件
- 互斥:线程对于需要的资源的进行互斥的访问
- 持有并等待:线程持有了资源,同时又在等待其他资源
- 非抢占:线程获得的资源,不能被抢占
- 循环等待:线程之间存在一个环路,环路上每个线程都额外持有一个资源,而这个资源又是下一个线程要申请的
预防死锁
-
循环等待
- 最使用的预防技术就是让代码不会产生循环等待,最直接的方法就是获取锁时提供一个“全序”,即针对锁A、B,每次加锁都严格先申请A、后申请B的顺序进行。
- 复杂系统锁很多时,全序可能很难做到,“偏序”可能是一种有用的方法,安排一部分锁的加锁顺序
- 缺点:全序和偏序都需要细致的锁策略的设计和实现,并且顺序只是一种约定,很容易被忽略而导致死锁
- tips:通过锁的地址来强制锁的顺序,按地址从高到低或者从低到高的顺序来加锁
-
持有并等待
-
死锁的持有并等待条件,可以通过原子地枪锁来避免。即先抢占全局锁,由它来保证枪锁过程中不会有不合时宜的线程切换
-
lock(prevention); lock(L1); lock(L2); ... unlock(prevention);
-
-
缺点:它不适用于封装,因为这个方案需要我们准确地知道要抢哪些锁,并且提前抢到这些锁。因为要提前抢到所有锁(同时),而不是在真正需要的时候,所以可能降低了并发
-
-
非抢占
-
使用trylock()函数尝试获得锁,或者返回-1表示锁已经被占有
-
top: lock(L1); if (trylock(L2) == -1) { unlock(L1); goto top; }
-
-
新问题,可能会导致“活锁”,两个线程重复trylock()但又无法枪锁成功,解决办法是循环结束时随即等待一个时间后再重复整个动作
-
缺点:封装不友好,如果某个锁封装在函数内部,那么跳回开始处很难实现;如果枪锁失败返回top重新枪锁,需要释放资源
-
-
互斥
- 最后的预防方法是完全避免互斥,通常来说,代码都会存在临界区,因此很难避免互斥。主要想法是:通过强大的硬件指令,可以构造出不需要锁的数据结构,如“比较并交换”指令等
通过调度避免死锁
- 除了死锁预防,某些场景更适合死锁避免,我们需要了解全局的信息,包括不同线程在运行中对锁的需求情况,从而使得后续的调度能够避免产生死锁
- 缺点:应用场景很局限,条件严苛;会限制并发,影响性能
检查和恢复
- 常用策略是允许死锁偶尔发生,检查到死锁时再采取行动。如果死锁很少见,这会是很实用的方法
- TOM WEAT定律:不要总是完美,不是所有值得做的事情都值得做好,如果坏事很少发生,并且造成的影响很小,那么我们不应该去花费大量的精力去预防它。