陈大剩博客

Golang 并发编程(六):Golang 中同步工具—锁

  • 陈大剩
  • 2025-02-17 22:03:15
  • 301

golang 标题

golang 除了提供特有的并发编程模型和工具之外,还提供了传统的同步工具,它们都在 golang 的标准库代码包 syncsync/atomic 中。在使用其他语言(比如 CJava)的锁类工具时,可能会犯一些错误:忘记解开已经锁住的锁,从而导致流程异常、线程执行停滞,甚至程序死锁等一系列问题。然而,在 Golang 中,这个错误极低,因为存在 defer 语句:

var mutex sync.Mutex
defer mutex.Unlock()
mutex.Lock()

此类锁将永远不会死锁,golang 中的锁包含 互斥锁读写锁

注意:

  1. golang 中锁不保证顺序。当多个 goroutine 请求同一个锁时,获取锁的顺序是不可预测的。调度器可能会根据许多因素(如 goroutine 的优先级、系统负载等)来决定哪个 goroutine 先获得锁;
  2. golang 中锁可能导致“饥饿”。如果某些 goroutine 持续占用锁,其他请求锁的 goroutine 可能会长时间等待,导致某些 goroutine “饥饿”;
  3. golang 中锁公平性。golang 的标准库并没有实现公平锁(即按照请求顺序分配锁)。如果需要确保 goroutine 按顺序获取锁,您可能需要自定义实现;

互斥锁

互斥锁(Mutex)用于保护共享资源,确保同一时间只有一个 goroutine 可以访问特定的资源,从而防止数据竞争。在操作系统层面我们经常见到的 pv 操作其实就是一种互斥锁,golang 提供了 sync 包中的 Mutex 类型来实现互斥锁,该类型有两个公开的指针方法:LockUnlock,顾名思义前者锁住当前互斥量,后者则用于对当前的互斥量进行解锁。具体互斥锁例子如下:

var mutex sync.Mutex
fmt.Println("锁住这个锁. (主锁)")
mutex.Lock()
fmt.Println("这个锁已锁住. (主锁)")
for i := 0; i < 3; i++ {
    go func(i int) {
       fmt.Printf("锁住这个锁. (协程%d)\n", i)
       mutex.Lock()
       fmt.Printf("这个锁已锁住. (协程%d)\n", i)
    }(i)
}
// 让相关协程可以执行
time.Sleep(time.Second)
fmt.Println("解锁这个锁. (主锁)")
mutex.Unlock()
fmt.Println("已解锁这个锁. (主锁)")
// 让相关协程可以执行
time.Sleep(time.Second)

相关执行后输出:

锁住这个锁. (主锁)
这个锁已锁住. (主锁)
锁住这个锁. (协程0)
锁住这个锁. (协程1)
锁住这个锁. (协程2)
解锁这个锁. (主锁)
已解锁这个锁. (主锁)
这个锁已锁住. (协程0)

可以看到对互斥锁能够对共享资源的访问唯一性进行控制,正是因为它的这一特性,才有效消除了竞态条件。虽然互斥锁能被多个 goroutine 共享,但是还是强烈建议把同一个互斥锁的锁定和解锁操作放到同一个层次代码块中,以减少不相关流程被误用,导致不正确的行为。

注意:

  1. 请误多次解锁同一个互斥锁,如多次解锁会造成恐慌,并且不可恢复(reverse);
  2. 请保持加锁和解锁成对出现,避免误用;

读写锁

读写锁是针对 读写操作 的互斥锁,它和普通互斥锁的区别是可以分别为 读操作写操作 进行 锁定解锁 操作。读写锁控制下的 多个写操作之间都是互斥的,并且 写操作于读操作之间也是互斥的,但是,多个读操作之间却不存在互斥关系

- 读操作 写操作
读操作 非互斥 互斥
写操作 互斥 互斥

简单一句:读完之后可以写,写完之后才可以读,读可以读

例子如下:

var rwm sync.RWMutex
for i := 0; i < 3; i++ {
    go func(i int) {
       fmt.Printf("尝试锁定读 [%d]\n", i)
       rwm.RLock()
       fmt.Printf("读已锁定. [%d]\n", i)
       time.Sleep(time.Second * 2)
       fmt.Printf("尝试解锁读... [%d]\n", i)
       rwm.RUnlock()
       fmt.Printf("读已解锁定 [%d]\n", i)
    }(i)
}
time.Sleep(time.Microsecond * 100)
fmt.Println("尝试锁定写")
rwm.Lock()
fmt.Println("写已锁定")

输出:

尝试锁定读 [1]
读已锁定. [1]
尝试锁定读 [2]
读已锁定. [2]
尝试锁定读 [0]
读已锁定. [0]
尝试锁定写
尝试解锁读... [0]
尝试解锁读... [1]
读已解锁定 [1]
读已解锁定 [0]
尝试解锁读... [2]
读已解锁定 [2]
写已锁定

模拟了真实场景下的 读多写少 的场景,可以看到多次运行后,写锁定总会出现最后一行。是因为之前表格中的互斥操作,才使得必须要读完才能写。

条件变量

条件变量(Condition Variable)用于协调 goroutines 之间的运行,允许一个或多个 goroutines 等待 某个条件变为真条件变量通常与互斥锁一起使用,以确保在检查和修改条件时的安全性。简单点来说:互斥锁 是对一个共享区域进行加锁 所有线程都是一种竞争的状态去访问而条件变量 主要是通过条件状态来判断,实际上他还是会阻塞,只不过不会像互斥锁一样去参与竞争,而是在哪里等待条件变量的状态发生改变过后的通知 再被唤醒。条件变量主要体现为协作。

golang 中标准库的 sync.Cond 类型代表了条件变量。与互斥锁和读写锁不同,简单的申明无法创建出一个可用的条件变量,这需要用到 sync.NewCond 函数,声明如下:

func NewCond(l Locker) *Cond

条件变量总要与互斥量组合使用,sync.NewCond 函数的唯一参数是 sync.Locker 类型的,而具体的参数值既可以是一个互斥锁,也可以是一个读写锁。sync.NewCond 函数被调用之后,会返回一个 *sync.Cond 类型的结果值,可以调用该值拥有的几个方法来操纵这个条件变量。

*sync.Cond 类型共有三个方法:Wait()Signal()Broadcast()

  • Wait() 使当前 goroutine 等待条件变量,并在等待之前释放与条件变量关联的互斥锁,一旦收到通知才会唤醒,并且会尝试锁定当前锁。通常在一个循环中使用,检查共享条件并在条件不满足时调用 Wait()
  • Signal() 唤醒一个等待条件变量的 goroutine,在条件改变时调用,通常由在临界区内的 goroutine 调用,以通知其他等待的 goroutine
  • Broadcast() 唤醒所有等待条件变量的 goroutine,当条件改变时,如果有多个 goroutine 等待,使用 Broadcast() 可以确保所有等待的 goroutine 都被唤醒。

谈到条件变量,不能不谈生产者消费者问题。假设有一个工厂流水线,其中流水线中,生产者角色负责生产相应的产品,当然还有消费者角色,负责消费相应的商品。我们的目的是:流水线上不能长时间堆积产品(可短暂停留),生产的商品需要被消费掉,一个幂等的生产-消费者。

我们很容易想到用 互斥锁 来实现,这里我们假设生产 20 个商品,factory 变量是最终工厂流水线中存在的商品,这里是单消费者的情况。

var mutex sync.Mutex
syncChan := make(chan struct{}, 2)
count := 20  // 假设总共生产 20 个商品
factory := 0 // 工厂流水线中存在的商品

go func() {
    for i := 0; i < count; i++ {
       mutex.Lock()
       factory = factory + 1
       fmt.Printf("生产了一个,工厂中剩余:%d\n", factory)
       mutex.Unlock()
    }
    syncChan <- struct{}{}
}()

go func() {
    for i := 0; i < count; i++ {
       mutex.Lock()
       factory = factory - 1
       fmt.Printf("消费了一个,工厂中剩余:%d\n", factory)
       mutex.Unlock()
    }
    syncChan <- struct{}{}
}()
<-syncChan
<-syncChan
fmt.Println(factory) // 打印工厂流水线中存在的商品

打印结果:

消费了一个,工厂中剩余:-1
...
生产了一个,工厂中剩余:-1
生产了一个,工厂中剩余:0
0

不出意外,我们打印的时候虽然为 0 ,但是我们并没有考虑到,当工厂流水线为 0 的时候,我们可以去消费吗?并不能吧?所以代码如下:

var mutex sync.Mutex
syncChan := make(chan struct{}, 2)
count := 20  // 假设总共生产 20 个商品
factory := 0 // 工厂中存在的商品

go func() {
    for i := 0; i < count; i++ {
       mutex.Lock()
       factory = factory + 1
       fmt.Printf("生产了一个,工厂中剩余:%d\n", factory)
       mutex.Unlock()
    }
    syncChan <- struct{}{}
}()

go func() {
    for i := 0; i < count; i++ {
       mutex.Lock()
       if factory > 0 {
          factory = factory - 1
          fmt.Printf("消费了一个,工厂中剩余:%d\n", factory)
          mutex.Unlock()
       } else {
          mutex.Unlock()
       }
    }
    syncChan <- struct{}{}
}()
<-syncChan
<-syncChan
fmt.Println(factory)

这就有意思了,现在无论如何工厂流水线都会存在商品(试着多运行几次),这就是需要借助条件变量来协调生产者消费者了,于是我们将代码改成了这样。

var mutex sync.Mutex
syncChan := make(chan struct{}, 2)
count := 20  // 假设总共生产 20 个商品
factory := 0 // 工厂中存在的商品
cond := sync.NewCond(&mutex)

go func() {
    for i := 0; i < count; i++ {
       mutex.Lock()
       factory = factory + 1
       fmt.Printf("生产了一个,工厂中剩余:%d\n", factory)
       mutex.Unlock()
       cond.Signal() // 发送唤醒通知
    }
    syncChan <- struct{}{}
}()

go func() {
    for i := 0; i < count; i++ {
       mutex.Lock()
       if factory <= 0 {
          cond.Wait() // 等待唤醒
       }
       factory = factory - 1
       fmt.Printf("消费了一个,工厂中剩余:%d\n", factory)
       mutex.Unlock()
    }
    syncChan <- struct{}{}
}()
<-syncChan
<-syncChan
fmt.Println(factory)

现在确实是可以做到消费了,但是如果流水线上有多个生产者和消费者怎么办呢?

var mutex sync.Mutex
count := 20  // 假设总共生产 20 个商品
factory := 0 // 工厂中存在的商品
cond := sync.NewCond(&mutex)

for i := 0; i < count; i++ {
    go func() {
       mutex.Lock()
       factory = factory + 1
       fmt.Printf("生产了一个,工厂中剩余:%d\n", factory)
       mutex.Unlock()
       cond.Signal() // 发送唤醒通知
    }()
}

for i := 0; i < count; i++ {
    go func() {
       mutex.Lock()
       for factory <= 0 { // 换成了 for
          cond.Wait() // 等待唤醒
       }
       factory = factory - 1
       fmt.Printf("消费了一个,工厂中剩余:%d\n", factory)
       mutex.Unlock()
    }()
}
time.Sleep(time.Second * 2) // 防止主 goroutine 提前结束
fmt.Println(factory)

现在我们能看到,使用多个生产者消费者,也能保证工厂流水线及时生产和消费。

总结

条件变量 是引用的是操作系统中 条件变量 概念,建议可以读一下操作系统中 条件变量

  1. 使用与生产者与消费者模型
  2. 需要和互斥锁同步使用
分享到:
0

说点儿什么吧

头像

表情

本站由陈大剩博客程序搭建 | 湘ICP备2023000975号| Copyright © 2017 - 陈大剩博客 | 本站采用创作共用版权:CC BY-NC 4.0

站长统计| 文章总数[126]| 评论总数[11]| 登录用户[26]| 时间点[130]

logo

登入

社交账号登录