GosyncCond,最被忽视的同步机制

这是帖子的摘录;完整的帖子可以在这里找到:https://victoriametrics.com/blog/go-sync-cond/

这篇文章是有关 go 中处理并发的系列文章的一部分:

  • gosync.mutex:正常和饥饿模式
  • gosync.waitgroup 和对齐问题
  • gosync.pool 及其背后的机制
  • 使用sync.cond,最被忽视的同步机制(我们来了)
  • gosync.map:适合正确工作的正确工具
  • go singleflight 融入您的代码,而不是您的数据库

在go中,sync.cond是一个同步原语,尽管它不像sync.mutex或sync.waitgroup那样常用。您很少会在大多数项目中甚至在标准库中看到它,而其他同步机制往往会取代它。

也就是说,作为一名 go 工程师,你不会真的希望自己在阅读使用sync.cond 的代码时却不知道发生了什么,因为毕竟它是标准库的一部分。

因此,本次讨论将帮助您缩小这一差距,更好的是,它会让您更清楚地了解它在实践中的实际运作方式。

什么是sync.cond?

那么,让我们来分析一下sync.cond 的意义。

当 goroutine 需要等待特定事情发生时,例如某些共享数据更改,它可以“阻塞”,这意味着它只是暂停其工作,直到获得继续的许可。最基本的方法是使用循环,甚至可能添加一个 time.sleep 来防止 cpu 因忙等待而疯狂。

这可能是这样的:

// wait until condition is true
for !condition {  
}

// or 
for !condition {
    time.sleep(100 * time.millisecond)
}

现在,这并不是真正有效,因为该循环仍在后台运行,消耗 cpu 周期,即使没有任何更改。

这就是sync.cond 发挥作用的地方,它是让 goroutine 协调工作的更好方法。从技术上讲,如果您来自更学术的背景,那么它是一个“条件变量”。

  • 当一个goroutine正在等待某件事发生时(等待某个条件成立),它可以调用wait()。
  • 另一个 goroutine,一旦知道条件可能满足,就可以调用 signal() 或 broadcast() 来唤醒等待的 goroutine,并让它们知道是时候继续前进了。

这是sync.cond的基本接口:

// suspends the calling goroutine until the condition is met
func (c *cond) wait() {}

// wakes up one waiting goroutine, if there is one
func (c *cond) signal() {}

// wakes up all waiting goroutines
func (c *cond) broadcast() {}

overview of sync.cond

sync.cond 概述

好吧,让我们看一个快速的伪示例。这次,我们有一个 pokémon 主题,假设我们正在等待一个特定的 pokémon,并且我们希望在它出现时通知其他 goroutines。

var pokemonlist = []string{"pikachu", "charmander", "squirtle", "bulbasaur", "jigglypuff"}
var cond = sync.newcond(&sync.mutex{})
var pokemon = ""

func main() {
    // consumer
    go func() {
        cond.l.lock()
        defer cond.l.unlock()

        // waits until pikachu appears
        for pokemon != "pikachu" {
            cond.wait()
        }
        println("caught" + pokemon)
        pokemon = ""
    }()

    // producer
    go func() {
        // every 1ms, a random pokémon appears
        for i := 0; i 



<p>在此示例中,一个 goroutine 正在等待皮卡丘出现,而另一个 goroutine(生产者)从列表中随机选择一个神奇宝贝,并在新神奇宝贝出现时向消费者发出信号。</p>

<p>当生产者发送信号时,消费者醒来并检查是否出现了正确的神奇宝贝。如果有,我们就捕获神奇宝贝,如果没有,消费者就回去睡觉并等待下一个。</p>

<p>问题是,生产者发送信号和消费者实际醒来之间存在差距。与此同时,pokémon 可能会发生变化,因为消费者 goroutine 可能会晚于 1 毫秒(很少)醒来,或者其他 goroutine 会修改共享的 pokemon。所以sync.cond 基本上是在说:<em>'嘿,有些东西改变了!醒过来看看,但如果太晚了,可能又会变了。'</em> </p>

<p>如果消费者起晚了,pokémon 可能会逃跑,而 goroutine 会重新进入睡眠状态。</p>

<blockquote>
<p><strong><em>“哈,我可以使用一个通道来将 pokemon 名称或信号发送给另一个 goroutine”</em></strong></p>
</blockquote>

<p>当然。事实上,在 go 中,通道通常比sync.cond更受欢迎,因为它们更简单,更惯用,并且为大多数开发人员所熟悉。</p>

<p>在上面的情况下,您可以轻松地通过通道发送 pokémon 名称,或者仅使用空 struct{} 来发出信号而不发送任何数据。但我们的问题不仅仅是通过通道传递消息,而是处理共享状态。 </p>

<p>我们的例子非常简单,但是如果多个 goroutine 访问共享的 pokemon 变量,让我们看看如果我们使用通道会发生什么:</p>

  • 如果我们使用通道发送 pokémon 名称,我们仍然需要一个互斥体来保护共享的 pokemon 变量。
  • 如果我们仅使用通道来发出信号,则仍然需要互斥体来管理对共享状态的访问。
  • 如果我们在生产者中检查皮卡丘,然后通过通道发送它,我们还需要一个互斥锁。最重要的是,我们违反了关注点分离原则,即生产者承担了真正属于消费者的逻辑。

也就是说,当多个 goroutine 修改共享数据时,仍然需要互斥体来保护它。在这些情况下,您经常会看到通道和互斥体的组合,以确保正确的同步和数据安全。

“好的,但是广播信号呢?”

好问题!您确实可以通过简单地关闭通道(close(ch))来使用通道向所有等待的 goroutine 模仿广播信号。当您关闭通道时,从该通道接收的所有 goroutine 都会收到通知。但请记住,关闭的通道无法重复使用,一旦关闭,它就会保持关闭状态。

顺便说一句,实际上有人在讨论在 go 2 中删除sync.cond:提案:sync:删除 cond 类型。

“那么,sync.cond 有什么用呢?”

嗯,在某些情况下,sync.cond 可能比通道更合适。

  1. 使用通道,你可以通过发送值的方式向一个 goroutine 发送信号,也可以通过关闭通道来通知所有 goroutine,但你不能同时执行这两种操作。 sync.cond 为您提供更细粒度的控制。你可以调用 signal() 来唤醒单个 goroutine,或者调用 broadcast() 来唤醒所有 goroutine。
  2. 并且您可以根据需要多次调用 broadcast(),而通道一旦关闭就无法执行此操作(关闭已关闭的通道会引发恐慌)。
  3. 通道不提供保护共享数据的内置方法 - 您需要使用互斥体单独管理它。另一方面,sync.cond 通过将锁定和信号发送到一个包中,为您提供了一种更加集成的方法(以及更好的性能)。

“为什么要在sync.cond中嵌入lock?”

理论上,像sync.cond 这样的条件变量不必绑定到锁即可使其信号正常工作。

您可以让用户在条件变量之外管理自己的锁,这听起来像是提供了更大的灵活性。这并不是真正的技术限制,更多的是人为错误。

手动管理很容易导致错误,因为该模式不太直观,您必须在调用 wait() 之前解锁互斥体,然后在 goroutine 唤醒时再次锁定它。这个过程可能会让人感觉尴尬,而且很容易出错,比如忘记在正确的时间锁定或解锁。

但是为什么图案看起来有点不对劲?

通常,调用 cond.wait() 的 goroutine 需要在循环中检查某些共享状态,如下所示:

for !checksomesharedstate() {
    cond.wait()
}

sync.cond 中嵌入的锁帮助我们处理锁定/解锁过程,使代码更简洁且不易出错,我们将很快详细讨论该模式。

如何使用?

如果仔细观察前面的示例,您会注意到消费者中的一致模式:我们总是在等待 (.wait()) 条件之前锁定互斥体,并在满足条件后解锁它。

另外,我们将等待条件包装在一个循环中,这里复习一下:

// consumer
go func() {
    cond.l.lock()
    defer cond.l.unlock()

    // waits until pikachu appears
    for pokemon != "pikachu" {
        cond.wait()
    }
    println("caught" + pokemon)
}()

条件等待()

当我们在sync.cond 上调用wait() 时,我们是在告诉当前的goroutine 等待,直到满足某些条件。

这是幕后发生的事情:

  1. 该 goroutine 被添加到其他也在等待相同条件的 goroutine 列表中。所有这些 goroutine 都被阻塞,这意味着它们无法继续,直到被 signal() 或 broadcast() 调用“唤醒”为止。
  2. 这里的关键部分是,在调用 wait() 之前必须锁定互斥锁,因为 wait() 做了一些重要的事情,它会在让 goroutine 休眠之前自动释放锁(调用 unlock())。这允许其他 goroutine 在原始 goroutine 等待时获取锁并完成其工作。
  3. 当等待的 goroutine 被唤醒(通过 signal() 或 broadcast())时,它不会立即恢复工作。首先,它必须重新获取锁(lock())。

GosyncCond,最被忽视的同步机制

sync.cond.wait() 方法

以下是 wait() 在底层的工作原理:

func (c *cond) wait() {
    // check if cond has been copied
    c.checker.check()

    // get the ticket number
    t := runtime_notifylistadd(&c.notify)

    // unlock the mutex     
    c.l.unlock()

    // suspend the goroutine until being woken up
    runtime_notifylistwait(&c.notify, t)

    // re-lock the mutex
    c.l.lock()
}

虽然很简单,但我们可以总结出4个要点:

  1. 有一个检查器可以防止复制 cond 实例,如果这样做会出现恐慌。
  2. 调用 cond.wait() 会立即解锁互斥体,因此在调用 cond.wait() 之前必须锁定互斥体,否则会出现恐慌。
  3. 被唤醒后,cond.wait() 会重新锁定互斥体,这意味着您在使用完共享数据后需要再次解锁它。
  4. sync.cond 的大部分功能是在 go 运行时中通过名为 notificationlist 的内部数据结构实现的,该结构使用基于票据的系统进行通知。

由于这种锁定/解锁行为,在使用sync.cond.wait() 时您将遵循一个典型模式以避免常见错误:

c.L.Lock()
for !condition() {
    c.Wait()
}
// ... make use of condition ...
c.L.Unlock()

the typical pattern for using sync.cond.wait()

使用sync.cond.wait()的典型模式

“为什么不直接使用 c.wait() 而不使用循环呢?”


这是帖子的摘录;完整的帖子可以在这里找到:https://victoriametrics.com/blog/go-sync-cond/

以上就是GosyncCond,最被忽视的同步机制的详细内容,更多请关注其它相关文章!