死锁是所有并发进程都在彼此等待的状态。 在这种情况下,如果没有外部干预,程序将永远不会恢复。
如果这听起来很严峻,那是因为它确实很严峻! 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

    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()
  1. 这里我们试图访问带锁的部分
  2. 这里我们试图调用defer关键字释放锁
  3. 这里我们添加休眠时间 以造成死锁

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

fatal error: all goroutines are asleep - deadlock!

为什么? 如果仔细观察,你将在此代码中看到计时问题。下面的时序图能清晰的展现问题所在:

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

为了保持这个例子简单,我使用time.Sleep来触发死锁。 但是,这引入了竞争条件! 你能找到它吗?
一个逻辑上“完美”的死锁将需要正确的同步。

这似乎很明显,为什么当我们以这种方式绘制图表时出现这种僵局,但我们会从更严格的定义中受益。事实证明,出现僵局时必定存在一些条件,1971年,埃德加科夫曼在一篇论文中列举了这些条件。这些条件现在称为科夫曼条件,是帮助检测,防止和纠正死锁的技术基础。

科夫曼条件如下:

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

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

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

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

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

  1. printSum函数确实需要a和b的独占权,所以它满足了这个条件。
  2. 因为printSum保持a或b并等待另一个,所以它满足这个条件。
  3. 我们没有任何办法让我们的goroutine被抢占。
  4. 我们第一次调用printSum正在等待我们的第二次调用,反之亦然。

很好,我们亲手实现了死锁。

科夫曼条件同样有助于我们规避死锁。如果我们确保至少有一个条件不成立,就可以防止发生死锁。不幸的是,实际上这些条件很难推理,因此难以预防。网上大量充斥着被死锁困扰的开发人员的求助,一旦有人指出它就很明显,但通常需要另一双眼睛。

最后编辑: kuteng  文档更新时间: 2021-01-02 17:30   作者:kuteng