死锁

死锁

线程在执行过程中等待锁释放,如果存在多个线程相互等待已经被加锁的资源,就会造成死锁。大多数语言的锁实现都支持重入的一个重要原因是一个函数体内加锁 的代码段中经常会调用其他函数,而其他函数内部同样加了相同的锁,在不支持重入的情况下,执行线程总是要获取自己尚未释放的锁。也就是说该条线程试图获取 一个自己已经获取而尚未释放的锁。死锁就此产生。还有最经典的哲学家就餐问题。

互斥量的死锁

一个线程需要访问两个或者更多不同的共享资源,而每个资源又有不同的互斥量管理。当超过一个线程加锁同一组互斥量时,就可能发生死锁。死锁就是指多个线程/进程因竞争资源而造成的一种僵局(相互等待),若无外力作用,这些进程都将无法向前推进。

死锁的处理策略:

1、预防死锁:破坏死锁产生的四个条件:互斥条件、不剥夺条件、请求和保持条件以及循环等待条件。

2、避免死锁:在每次进行资源分配前,应该计算此次分配资源的安全性,如果此次资源分配不会导致系统进入不安全状态,那么将资源分配给进程,否则等待。算法:银行家算法。

3、检测死锁:检测到死锁后通过资源剥夺、撤销进程、进程回退等方法解除死锁。

线程饥饿

互斥锁中提到获取不到锁的线程回去睡眠等待下一次竞争锁,如果下一次仍然得不到,就继续睡眠,这种持续得不到锁的情况我们称之为饥饿。一个很有意思的例子是关于小米手机饥饿营销的。将小米手机比作竞争资源,抢手机的用户就是线程,每次开抢都抢不到的用户就是线程饥饿。

和饥饿相对的是公平,操作系统调度程序负责这种公平,使用分片或 nice 或执行比等方式避免得不到调度的线程活活饿死。Java 默认采用非公平的互斥锁,但是公平锁因为要防止饥饿需要根据线程调度策略做调整,所以性能会受到影响,而且一般情况下某条线程饿死的情况鲜有发生(因为调度本来就是不公平的),因此默认都是非公平的。

死锁,活锁与饥饿

死锁,活锁和饥饿这些问题都涉及确保您的程序在任何时候都能够有效执行。如果处理不当,您的程序可能会进入某个状态中,最终停止运行。

死锁

死锁是所有并发进程都在彼此等待的状态。在这种情况下,如果没有外部干预,程序将永远不会恢复。如果这听起来很严峻,那是因为它确实很严峻! Go 运行时会检测到一些死锁(所有的例程必须被阻塞或“休眠”),但这对于帮助你防止死锁产生没有多大帮助。

type value struct {
	mu    sync.Mutex
	value int
}

var wg sync.WaitGroup
printSum := func(v1, v2 *value) {
	defer wg.Done()
	v1.mu.Lock()         // 1,试图访问带锁的部分
	defer v1.mu.Unlock() //2,试图调用 defer 关键字释放锁

	time.Sleep(2 * time.Second) //3,添加休眠时间 以造成死锁
	v2.mu.Lock()
	defer v2.mu.Unlock()

	fmt.Printf("sum=%v\n", v1.value+v2.value)
}

var a, b value
wg.Add(2)
go printSum(&a, &b)
go printSum(&b, &a)
wg.Wait()

如果你试着运行这段程序,应该会看到这样的输出:

fatal error: all goroutines are asleep - deadlock!

下面的时序图能清晰的展现问题所在:

时序图

实质上,我们创建了两个不能一起运转的齿轮:我们的第一个打印总和调用 a 锁定,然后尝试锁定 b,但与此同时,我们打印总和的第二个调用锁定了 b 并尝试锁定 a。两个 goroutine 都无限地等待着彼此。

死锁的条件

1971 年,埃德加科夫曼在一篇论文中列举了这些条件。这些条件现在称为科夫曼条件,是帮助检测,防止和纠正死锁的技术基础。科夫曼条件如下:

  • 相互排斥:并发进程在任何时候都拥有资源的独占权。

  • 等待条件:并发进程必须同时持有资源并等待额外的资源。

  • 没有抢占:并发进程持有的资源只能由该进程释放,因此它满足了这种情况。

  • 循环等待:并发进程(P1)等待并发进程(P2),同时 P2 也在等待 P1,因此也符合"循环等待"这一条件。

让我们来看看我们的设计程序,并确定它是否符合所有四个条件:

  • printSum 函数确实需要 a 和 b 的独占权,所以它满足了这个条件。

  • 因为 printSum 保持 a 或 b 并等待另一个,所以它满足这个条件。

  • 我们没有任何办法让我们的 goroutine 被抢占。

  • 我们第一次调用 printSum 正在等待我们的第二次调用,反之亦然。

上一页