go 语言条件变量和信号量

互斥锁并非唯一的同步工具:条件变量则提供了额外的控制功能,可以与互斥锁相辅相成。使用条件变量,我们可以等待某个条件满足后再继续执行代码。而信号量则比互斥锁更加强大,因为它能让我们控制同时有多少个协程可以执行某段代码。此外,信号量还可以用来存储某个事件的信号,以便后续的代码能够获取到这些信息。

除了在我们的并发应用程序中发挥作用外,条件变量和信号量还是我们可以用来构建更复杂工具和抽象结构的宝贵工具。

条件变量

条件变量在锁的基础上提供了更多功能。当某个协程需要等待某个特定条件满足时,就可以使用条件变量。让我们通过一个例子来了解其用法。

将互斥锁与条件变量结合使用

Stingy 和 Spendy 共享同一个银行账户。Stingy 和 Spendy 分别会不断地赚取 10 美元和花费 10 美元。那么,如果我们让 Spendy 的花费速度快于 Stingy 的赚取速度会怎么样呢?之前,总的收入和支出是相等的,都是 1000 万美元。在这个例子中,我们仍然保持总金额不变,仍然是 1000 万美元,但我们将 Spendy 的花费速度提高到 50 美元/次,同时将总迭代次数减少到 200,000 次。这样一来,由于花费的速度远远快于赚取的速度,银行账户的余额很快就会变成负数。此外,当账户余额为负数时,银行可能会收取额外的费用。理想情况下,我们需要找到一种方法来减缓支出的速度,从而避免余额跌破零。

展示了一个经过修改的"Spendy"函数,用于模拟这种情景。在该函数中,当银行账户余额变为负数时,程序会输出一条消息并终止运行。需要注意的是,在这两个函数中,收入和支出的金额是相同的。只不过在开始时,"Spendy"函数的支出速度比"Stingy"函数的收入速度快。如果我们不使用 os.Exit() 函数,那么"Spendy"函数会更快地执行完毕,之后"Stingy"函数就会把银行账户的余额恢复到原来的水平。

go 复制代码
package main

import (
    "fmt"
    "os"
    "sync"
    "time"
)

func stingy(money *int, mutex *sync.Mutex) {
    for i := 0; i < 1000000; i++ {
        mutex.Lock()
        *money += 10
        mutex.Unlock()
    }
    fmt.Println("Stingy Done")
}

func spendy(money *int, mutex *sync.Mutex) {
    for i := 0; i < 200000; i++ {
        mutex.Lock()
        *money -= 50
        if *money < 0 {
            fmt.Println("Money is negative!")
            os.Exit(1)
        }
        mutex.Unlock()
    }
    fmt.Println("Spendy Done")
}

func main() {
    money := 100
    mutex := sync.Mutex{}
    go stingy(&money, &mutex)
    go spendy(&money, &mutex)
    time.Sleep(2 * time.Second)
    mutex.Lock()
    fmt.Println("Money in bank account: ", money)
    mutex.Unlock()
}

执行之后,消费的速度完全大于存钱的速度,会很快进入 `*money < 0`

bash 复制代码
go  run stingyspendynegative.go 
Money is negative!
exit status 1

我们有什么办法可以防止余额变为负数吗?理想的情况是,我们要有一个不会动用我们没有的钱的系统。我们可以让"花钱"功能在执行支出操作之前,先检查是否有足够的资金。如果资金不足,那么可以让相应的 goroutine 暂停一段时间后再进行再次检查。关于"花钱"功能的实现方式,具体细节请参见下面的代码示例。

go 复制代码
package main

import (
    "fmt"
    "os"
    "sync"
    "time"
)

func stingy(money *int, mutex *sync.Mutex) {
    for i := 0; i < 1000000; i++ {
        mutex.Lock()
        *money += 10
        mutex.Unlock()
    }
    fmt.Println("Stingy Done")
}

func spendy(money *int, mutex *sync.Mutex) {
    for i := 0; i < 200000; i++ {
        mutex.Lock()
        for *money < 50 {
            mutex.Unlock()
            time.Sleep(10 * time.Millisecond)
            mutex.Lock()
        }
        *money -= 50
        if *money < 0 {
            fmt.Println("Money is negative!")
            os.Exit(1)
        }
        mutex.Unlock()
    }
    fmt.Println("Spendy Done")
}

func main() {
    money := 100
    mutex := sync.Mutex{}
    go stingy(&money, &mutex)
    go spendy(&money, &mutex)
    time.Sleep(2 * time.Second)
    mutex.Lock()
    fmt.Println("Money in bank account: ", money)
    mutex.Unlock()
}

这个解决方案对我们的使用场景来说是可以用的,但并不理想。在我们的例子中,我们随意将休眠时间定为 10 毫秒,但究竟什么样的数值才是最理想的呢?一种极端情况是让 goroutine 完全不进行休眠。不过这样一来,CPU 资源就会被浪费掉,因为 CPU 会不断地检查"money"变量的值,而实际上该变量并没有发生变化。另一种极端情况是,如果 goroutine 的休眠时间过长,那么我们就会白白浪费时间去等待"money"变量发生其实已经发生的变动。

这就是条件变量发挥作用的地方。条件变量与互斥量配合使用,使我们能够暂停当前的执行流程,直到有信号表明某个条件已经发生变化为止。下图展示了一种常见的利用条件变量和互斥量的使用方式。

  1. 在持有互斥锁的情况下,goroutine A 会检查某些共享状态的特定条件。在我们的例子中,这个条件就是"共享银行账户中的资金是否足够?"
  2. 如果条件不满足,goroutine A 会调用条件变量上的 wait()函数。
  3. wait()函数会以原子方式执行两项操作(具体操作内容请参见后面的说明)。
    1. 它会放了互斥锁。
    2. 它会阻止当前的执行过程,从而让该协程进入休眠状态。
  4. 既然互斥锁现在已经可用,另一个协程(协程 B)就可以获取它来更新共享状态。例如,协程 B 可以增加共享银行账户中的资金数额。
  5. 在更新了共享状态之后,goroutine B 会调用条件变量上的 signal()或 Broadcast()函数,然后解锁互斥锁。
  6. 当接收到信号()或广播消息时,goroutine A 会恢复活跃状态,并自动重新获取互斥锁。此时,goroutine A 可以再次检查共享状态的值,比如在支出资金之前,确认共享银行账户中有足够的资金。步骤 2 到步骤 6 会不断重复执行,直到满足预定条件为止。
  7. 最终,这一条件得到了满足。
  8. 该协程会继续执行其指定的操作,比如使用银行账户中现有的资金。

请注意:理解条件变量的关键在于明白,wait() 函数会以原子方式释放互斥锁,并暂停当前执行的进程。这意味着,在调用 wait() 的进程被暂停之前,其他进程无法介入,夺取锁并调用 signal() 函数。

在 Go 语言中,条件变量的实现方式体现在 sync.Cond 类型中。如果我们查看该类型所提供的函数,会发现以下这些功能:

go 复制代码
type Cond
func NewCond(l Locker) *Cond
func (c *Cond) Broadcast()
func (c *Cond) Signal()
func (c *Cond) Wait()

要使用 Go 语言中的条件变量,我们需要一个能够实现这两种功能的对象,而互斥锁正是这样的对象之一。下面的代码示例展示了一个 main() 函数:该函数首先创建了一个互斥锁,然后在其条件变量中使用它。之后,将该条件变量传递给 stingy()spendy() 这两个协程使用。

接下来,我们可以修改我们的 spendy()函数,使其在"money"变量中的金额足够时才继续执行操作。我们可以通过循环来实现这一判断:每当"money"的金额低于 50 美元时,就调用 wait()函数。我们使用了 for 循环,只要"money"的金额小于 50 美元,循环就会持续执行。在每次循环中,都会调用 wait()函数。此外,该函数还利用了条件变量类型所包含的互斥锁机制。

go 复制代码
package main

import (
    "fmt"
    "os"
    "sync"
    "time"
)

func main() {
    money := 100
    mutex := sync.Mutex{}
    cond := sync.NewCond(&mutex)
    go stingy(&money, cond)
    go spendy(&money, cond)
    time.Sleep(2 * time.Second)
    mutex.Lock()
    fmt.Println("Money in bank account: ", money)
    mutex.Unlock()
}

func stingy(money *int, cond *sync.Cond) {
    for i := 0; i < 1000000; i++ {
        cond.L.Lock()
        *money += 10
        cond.Signal()
        cond.L.Unlock()
    }
    fmt.Println("Stingy Done")
}

func spendy(money *int, cond *sync.Cond) {
    for i := 0; i < 200000; i++ {
        cond.L.Lock()
        for *money < 50 {
            cond.Wait()
        }
        *money -= 50
        if *money < 0 {
            fmt.Println("Money is negative!")
            os.Exit(1)
        }
        cond.L.Unlock()
    }
    fmt.Println("Spendy Done")
}

信号缺失

如果一个协程调用了 signal()Broadcast() 函数,而没有其他协程处于等待状态来接收该信号,那会怎么样呢?这个信号会被丢弃吗?还是会被保存起来,以便下一个需要等待的协程来使用呢?答案如图所示。如果没有协程处于等待状态,那么 signal()Broadcast() 调用就会无效。我们可以利用条件变量来解决另一个问题:如何让协程完成它们的任务后我们才能继续执行接下来的操作。

到目前为止,我们一直在主函数中使用 sleep() 来等待各个 goroutine 的执行完成。不过这种做法并不理想,因为我们只是粗略地估计了各个 goroutine 所需的执行时间。如果在性能较弱的计算机上运行代码,我们就不得不增加等待的时间。

我们可以不使用睡眠机制,而是让 main() 函数等待某个条件变量变为指定值。当子协程准备好时,再由它发出信号。下面的代码示例展示了一种错误的实现方式。

错误的信号传递方式

go 复制代码
package main

import (
  "fmt"
  "sync"
)

/*
Fixed version: Using a flag to prevent signal loss
*/
func doWork(cond *sync.Cond) {
  fmt.Println("Work started")
  fmt.Println("Work finished")
  // 调用signal的时候可能还没有调用wait
  // 如果这里先调用,后面的主函数就可能死锁
  cond.Signal()
}

func main() {
  cond := sync.NewCond(&sync.Mutex{})
  cond.L.Lock()
  for i := 0; i < 50000; i++ {
    go doWork(cond)
    fmt.Println("Waiting for child goroutine")
    cond.Wait()

    fmt.Println("Child goroutine finished")
  }
  cond.L.Unlock()
}

在之前的实现中存在的问题是:当主 goroutine 没有在等待条件变量时,我们仍可能会接收到信号。这样一来,我们就无法及时接收到该信号。Go 的运行时系统会检测到有 goroutine 在徒劳地等待,因为没有其他 goroutine 会调用信号处理函数,于是就会抛出致命错误。

请注意:我们必须确保,在调用信号发送或广播功能时,有另一个协程在等待接收该信号或广播内容。否则,该信号或广播将无法被任何协程接收到,从而被忽略。

为确保不会错过任何信号或广播消息,我们必须将这些功能与互斥锁结合使用。也就是说,只有在我们持有相应的互斥锁时,才能调用这些函数。这样一来,我们就能确定:main()协程一定处于等待状态,因为只有当协程调用 wait()函数时,互斥锁才会被释放。下图展示了两种情况:一种是没有接收到信号的情况,另一种则是通过互斥锁来发送信号的情况。

go 复制代码
package main

import (
    "fmt"
    "sync"
)

func doWork(cond *sync.Cond) {
    fmt.Println("Work started")
    fmt.Println("Work finished")
    cond.L.Lock()
    cond.Signal()
    cond.L.Unlock()
}

func main() {
    cond := sync.NewCond(&sync.Mutex{})
    cond.L.Lock()
    for i := 0; i < 50000; i++ {
        go doWork(cond)
        fmt.Println("Waiting for child goroutine")
        cond.Wait()
        fmt.Println("Child goroutine finished")
    }
    cond.L.Unlock()
}

利用等待和广播机制来同步多个协程

到目前为止,我们看到的例子都是使用 signal() 而不是 Broadcast() 的情况。当有多个协程因为等待某个条件变量而处于暂停状态时,signal() 会随机唤醒其中一个协程。而 Broadcast() 则会让所有处于等待状态的协程都被唤醒。

请注意:当有多个协程因为调用 wait() 而处于等待状态时,如果我们调用 signal() 函数,那么只会有一个协程被唤醒。我们无法控制系统会选择哪个协程来继续执行,只能假设被唤醒的协程可能是任何一个因等待条件变量而阻塞的协程。而使用 Broadcast() 函数的话,就能确保所有因等待条件变量而处于暂停状态的协程都能被唤醒。

现在,让我们通过一个例子来演示 Broadcast()功能的用法。下图展示了一个游戏场景:玩家需要等待所有其他玩家加入后,游戏才能开始。这种场景在网络多人游戏和游戏机上都很常见。假设我们的程序中有个协程负责与每个玩家进行交互。那么,我们要如何编写代码,让每个协程都暂停执行,直到所有玩家都加入游戏为止呢?

go 复制代码
package main

import (
    "fmt"
    "sync"
    "time"
)

func main() {
    cond := sync.NewCond(&sync.Mutex{})
    playersInGame := 4
    for playerId := 0; playerId < 4; playerId++ {
        go playerHandler(cond, &playersInGame, playerId)
        time.Sleep(1 * time.Second)
    }
}

func playerHandler(cond *sync.Cond, playersRemaining *int, playerId int) {
    cond.L.Lock()
    fmt.Println(playerId, ": Connected")
    *playersRemaining--
    if *playersRemaining == 0 {
        cond.Broadcast()
    }
    for *playersRemaining > 0 {
        fmt.Println(playerId, ": Waiting for more players")
        cond.Wait()
    }
    cond.L.Unlock()
    fmt.Println("All players connected. Ready player", playerId)
    //Game started
}

我们可以利用条件变量来实现多个协程同时等待同一条件的功能。由于每个玩家都由一个协程来处理,因此可以让每个协程等待一个指示所有玩家都已连接的信号。之后,我们可以使用同一个条件变量来检查是否所有玩家都已连接;如果没有,就再次调用 wait() 函数。每当有新的协程处理完某个新玩家的请求后,我们就将该共享变量的值减 1。当该变量的值为 0 时,我们就可以通过调用 Broadcast() 函数来唤醒所有处于暂停状态的协程。

bash 复制代码
0 : Connected
0 : Waiting for more players
1 : Connected
1 : Waiting for more players
2 : Connected
2 : Waiting for more players
3 : Connected
All players connected. Ready player 3
All players connected. Ready player 2
All players connected. Ready player 1
All players connected. Ready player 0

Process finished with the exit code 0

利用条件变量重新审视读-写锁机制

在上一章中,我们使用互斥锁来实现自定义的读写锁机制。这种机制具有"优先读取"特性:只要至少有一个读取者协程持有锁,写入者协程就无法在其临界区域内访问资源。只有当所有读取者都释放了锁之后,写入者协程才能获得锁。如果此时没有可供读取者使用的锁,那么写入者就只能等待。下图展示了一种情况:两个协程轮流持有读锁,从而阻止了写入者获取锁的行为。

用技术术语来说,这种情况被称为"写操作受阻":因为我们无法更新共享数据结构,因为负责读取数据的进程一直在持续访问这些数据结构,从而阻碍了写操作的进行。下面的代码示例模拟了这种情形。

go 复制代码
package main

import (
  "fmt"
  "sync"
  "time"
)

func main() {
  rwMutex := sync.RWMutex{}
  for i := 0; i < 2; i++ {
    go func() {
      for {
        rwMutex.RLock()
        time.Sleep(1 * time.Second)
        fmt.Println("Read done")
        rwMutex.RUnlock()
      }
    }()
  }
  time.Sleep(1 * time.Second)
  rwMutex.Lock()
  fmt.Println("Write finished")
}

我们需要一种不同的读写锁设计,这种锁不应以读取操作为优先处理方式,从而避免让写入操作被长时间阻塞。这样一来,新的读取请求就不会被拒绝。

一旦有写入操作调用 writeLock()函数,读取锁就会立即被占用。为此,我们可以不用让协程在互斥锁上等待,而是使用条件变量来让它们暂停执行。通过条件变量,我们可以设定不同的条件,从而决定何时让读取操作和写入操作分别等待。要设计出一种以写入操作优先的锁机制,我们需要满足一些条件:

  • 读者计数器------初始值为 0,该数值表明有多少个读者协程正在同时访问共享资源。
  • 写者等待计数器------初始值为 0,该数值表明有多少个写者协程处于等待状态,无法访问共享资源。
  • "写入器活跃指示器":该标志的初始值为"false"。它用来指示当前是否有写入器协程正在对相关资源进行更新操作。
  • 带互斥锁的条件变量------这种机制允许我们对相关属性设置各种条件。当这些条件不满足时,程序的执行就会被暂停。

Go 语言中的 RwMutex

Go 语言中自带的 RwMutex 是一种"写优先"的锁机制。这一点在 Go 的文档中有明确说明(参见 https://pkg.go.dev/sync#RWMutex)。调用 Lock()方法时,会获得锁中用于写入操作的权限。

如果一个协程持有读锁,而另一个协程试图获取锁,那么在最初的读锁被释放之前,没有任何协程能够成功获得读锁。特别是,这种机制禁止了递归式的读锁获取操作。这样做是为了确保锁最终能够被其他协程使用;如果 Lock 调用被阻塞,那么新的读取请求就无法获得锁。

(a)当没有写者正在使用或等待使用时,读者可以访问共享资源。

(b)当有读者或写者在使用共享资源时,其他写者被禁止访问该资源。同样地,当有写者在等待时,新的读者也无法进入共享资源。

计数信号量

互斥锁被用来确保只有一个协程能够访问共享资源。而读写互斥锁则允许多个协程同时进行读取操作,但每次只能有一个协程进行写入操作。信号量则提供了一种不同的并发控制方式:我们可以指定允许的同时执行的操作次数。因此,信号量还可以作为构建更复杂的并发控制机制的基础。

什么是信号量

为了更好地理解信号量,我们可以将其与互斥锁进行比较。互斥锁确保只有单个协程能够独占性地使用某项资源,而信号量则允许最多 N 个协程同时使用该资源。实际上,当 N 的值为 1 时,互斥锁的功能就与信号量相同了。而计数信号量则让我们可以自由选择 N 的值。

定义:只有一个许可权的信号量,有时被称为二进制信号量。

使用信号量绝对不错过任何信号

从另一个角度来看,信号量具有与条件变量的"等待"和"通知"功能类似的作用。此外,它还有一个优点:即使没有 goroutine 处于等待状态,信号量也能记录下相关的信号信息。

相关推荐
平凡但不平庸的码农2 小时前
Go 语言基础语法
开发语言·后端·golang
讲不出 再见3 小时前
go语言-包
golang·go·package··包冲突
ErizJ3 小时前
Go|腾讯面经总结
开发语言·后端·golang
geovindu3 小时前
go: Registry Pattern
开发语言·后端·设计模式·golang·注册模式
源图客1 天前
Go语言goland代码编辑与调试
开发语言·后端·golang
金融小白数据分析之路1 天前
go 查询 sql go-sqlite3 版本
sql·golang·sqlite
sweetheart7-72 天前
go/golang 入门学习笔记(Java/Python/C++转Go快速上手)
笔记·学习·golang·go语言
Vect__2 天前
C++无痛转go第一天,从hello world到切片
开发语言·c++·golang
研究点啥好呢3 天前
字节跳动Go后端开发工程师面试题精选:10道高频考题+答案解析
面试·golang·php·求职招聘