饥饿是指并发进程无法获得执行工作所需的任何资源的情况。

当我们讨论活锁时,每个goroutine所缺乏的资源就是一个共享锁。 活锁需要与饥饿分开讨论,因为在活锁过程中,所有并发进程都是平等的,并且没有任何任务可以被完成。 更广泛地说,饥饿通常意味着有一个或多个贪婪的并发进程不公平地阻止一个或多个并发进程尽可能有效地完成工作,或者根本不可能完成工作。

下面这个例子展示了一个贪婪的goroutine和一个知足的goroutine:

var wg sync.WaitGroup
var sharedLock sync.Mutex
const runtime = 1*time.Second

greedyWorker := func() {
    defer wg.Done()

    var count int
    for begin := time.Now(); time.Since(begin) <= runtime; { 
        sharedLock.Lock()
        time.Sleep(3*time.Nanosecond) 
        sharedLock.Unlock()
        count++
    }

    fmt.Printf("Greedy worker was able to execute %v work loops\n", count)
}

politeWorker := func() {
    defer wg.Done()

    var count int
    for begin := time.Now(); time.Since(begin) <= runtime; { 
        sharedLock.Lock()
        time.Sleep(1*time.Nanosecond) 
        sharedLock.Unlock()

        sharedLock.Lock() 
        time.Sleep(1*time.Nanosecond) 
        sharedLock.Unlock()

        sharedLock.Lock() 
        time.Sleep(1*time.Nanosecond) 
        sharedLock.Unlock()

        count++
    }
    fmt.Printf("Polite worker was able to execute %v work loops.\n", count)
}

wg.Add(2)
go greedyWorker()
go politeWorker()

wg.Wait()

这个代码段会输出:

Polite worker was able to execute 289777 work loops. Greedy worker was able to execute 471287 work loops

greedy 贪婪地持有整个工作循环的共享锁,而polite 试图只在需要时才锁定。 二者都进行了相同数量的模拟工作(休眠时间为三纳秒),但正如你在相同的时间内看到的那样,greedy 几乎完成了两倍的工作量!

但在这里我们要清楚的了解到,greedy不必要的扩大了对共享锁的控制,并且(通过饥饿)阻碍了polite有效的执行。

我们在例子中使用计数的方式识别饥饿,在记录和抽样度量指标时这是一个很不错的方法。检测和解决饥饿的方法之一就是就是记录程序完成的时间,然后确定你的程序执行速度是否与预期的一样高。

值得一提的是,前面的代码示例也可以作为进行同步内存访问的性能分支示例。 因为同步访问内存的代价很高,所以扩大我们的锁定范围可能会产生额外的代价。 另一方面,正如我们所看到的那样,我们冒着令其他并发进程挨饿的风险。
如果你利用内存访问同步,你必须在性能粗粒度同步和公平性细粒度同步之间找到平衡点。 当开始调试应用程序时,我强烈建议你将内存访问同步仅限于程序的关键部分; 如果同步成为性能问题,则可以扩大范围。 除此之外,其他的解决方式可能会更难以操作。

因此,饥饿可能会导致程序无效或不正确。 前面的例子表明了执行效率是如何被降低的,如果你有一个非常贪婪的并发进程,以至于完全阻止另一个并发进程完成工作,那么你的问题就大了。

我们还需要考虑来自程序之外导致的饥饿问题。请记住,饥饿还可以产生于于CPU,内存,文件句柄和数据库连接:任何必须共享的资源都是饥饿的候选对象。

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