sync 包(二):条件变量 sync.Cond
sync 包还提供了一个条件变量类型 sync.Cond
,它可以和互斥锁或读写锁(以下统称互斥锁)组合使用,用来协调想要访问共享资源的线程。
不过,与互斥锁不同,条件变量 sync.Cond
的主要作用并不是保证在同一时刻仅有一个线程访问某一个共享资源,而是在对应的共享资源状态发生变化时,通知其它因此而阻塞的线程。条件变量总是和互斥锁组合使用,互斥锁为共享资源的访问提供互斥支持,而条件变量可以就共享资源的状态变化向相关线程发出通知,重在「协调」。
下面,我们来看看如何使用条件变量 sync.Cond
。
sync.Cond
是一个结构体:
type Cond struct {
noCopy noCopy
// L is held while observing or changing the condition
L Locker
notify notifyList
checker copyChecker
}
提供了三个方法:
// 等待通知
func (c *Cond) Wait() {
c.checker.check()
t := runtime_notifyListAdd(&c.notify)
c.L.Unlock()
runtime_notifyListWait(&c.notify, t)
c.L.Lock()
}
// 单发通知
func (c *Cond) Signal() {
c.checker.check()
runtime_notifyListNotifyOne(&c.notify)
}
// 广播通知
func (c *Cond) Broadcast() {
c.checker.check()
runtime_notifyListNotifyAll(&c.notify)
}
我们可以通过 sync.NewCond
返回对应的条件变量实例,初始化的时候需要传入互斥锁,该互斥锁实例会赋值给 sync.Cond
的 L
属性:
locker := &sync.Mutex{}
cond := sync.NewCond(locker)
sync.Cond
主要实现一个条件变量,假设 goroutine A 执行前需要等待另外一个 goroutine B 的通知,那么处于等待状态的 goroutine A 会保存在一个通知列表,也就是说需要某种变量状态的 goroutine A 将会等待(Wait)在那里,当某个时刻变量状态改变时,负责通知的 goroutine B 会通过对条件变量通知的方式(Broadcast/Signal)来通知处于等待条件变量的 goroutine A,这样就可以在共享内存中实现类似「消息通知」的同步机制。
下面来看一个具体的示例。假设我们有一个读取器和一个写入器,读取器必须依赖写入器对缓冲区进行数据写入后,才可以从缓冲区中读取数据,写入器每次完成写入数据后,都需要通过某种通知机制通知处于阻塞状态的读取器,告诉它可以对数据进行访问,这种场景正好可以通过条件变量来实现:
package main
import (
"bytes"
"fmt"
"io"
"sync"
"time"
)
// 数据 bucket
type DataBucket struct {
buffer *bytes.Buffer //缓冲区
mutex *sync.RWMutex //互斥锁
cond *sync.Cond //条件变量
}
func NewDataBucket() *DataBucket {
buf := make([]byte, 0)
db := &DataBucket{
buffer: bytes.NewBuffer(buf),
mutex: new(sync.RWMutex),
}
db.cond = sync.NewCond(db.mutex.RLocker())
return db
}
// 读取器
func (db *DataBucket) Read(i int) {
db.mutex.RLock() // 打开读锁
defer db.mutex.RUnlock() // 结束后释放读锁
var data []byte
var d byte
var err error
for {
//每次读取一个字节
if d, err = db.buffer.ReadByte(); err != nil {
if err == io.EOF { // 缓冲区数据为空时执行
if string(data) != "" { // data 不为空,则打印它
fmt.Printf("reader-%d: %s\n", i, data)
}
db.cond.Wait() // 缓冲区为空,通过 Wait 方法等待通知,进入阻塞状态
data = data[:0] // 将 data 清空
continue
}
}
data = append(data, d) // 将读取到的数据添加到 data 中
}
}
// 写入器
func (db *DataBucket) Put(d []byte) (int, error) {
db.mutex.Lock() // 打开写锁
defer db.mutex.Unlock() // 结束后释放写锁
//写入一个数据块
n, err := db.buffer.Write(d)
db.cond.Signal() // 写入数据后通过 Signal 通知处于阻塞状态的读取器
return n, err
}
func main() {
db := NewDataBucket()
go db.Read(1) // 开启读取器协程
go func(i int) {
d := fmt.Sprintf("data-%d", i)
db.Put([]byte(d)) // 写入数据到缓冲区
}(1) // 开启写入器协程
time.Sleep(100 * time.Millisecond)
}
这里我们使用了读写互斥锁,在读取器里面使用读锁,在写入器里面使用写锁,并且通过 defer
语句释放锁,然后在锁保护的情况下,通过条件变量协调读写线程:在读线程中,当缓冲区为空的时候,通过 db.cond.Wait()
阻塞读线程;在写线程中,当缓冲区写入数据的时候通过 db.cond.Signal()
通知读线程继续读取数据。
执行上述示例代码,结果如下:
reader-1: data-1
上述示例代码只有一个读取器,一个写入器,如果都有多个呢?我们可以通过启动多个读写协程来模拟,此外,通知单个阻塞线程用 Signal
方法,通知多个阻塞线程需要使用 Broadcast
方法,按照这个思路,我们来改写上述示例代码如下:
package main
import (
"bytes"
"fmt"
"io"
"sync"
"time"
)
// 数据 bucket
type DataBucket struct {
buffer *bytes.Buffer //缓冲区
mutex *sync.RWMutex //互斥锁
cond *sync.Cond //条件变量
}
func NewDataBucket() *DataBucket {
buf := make([]byte, 0)
db := &DataBucket{
buffer: bytes.NewBuffer(buf),
mutex: new(sync.RWMutex),
}
db.cond = sync.NewCond(db.mutex.RLocker())
return db
}
// 读取器
func (db *DataBucket) Read(i int) {
db.mutex.RLock() // 打开读锁
defer db.mutex.RUnlock() // 结束后释放读锁
var data []byte
var d byte
var err error
for {
//每次读取一个字节
if d, err = db.buffer.ReadByte(); err != nil {
if err == io.EOF { // 缓冲区数据为空时执行
if string(data) != "" { // data 不为空,则打印它
fmt.Printf("reader-%d: %s\n", i, data)
}
db.cond.Wait() // 缓冲区为空,通过 Wait 方法等待通知,进入阻塞状态
data = data[:0] // 将 data 清空
continue
}
}
data = append(data, d) // 将读取到的数据添加到 data 中
}
}
// 写入器
func (db *DataBucket) Put(d []byte) (int, error) {
db.mutex.Lock() // 打开写锁
defer db.mutex.Unlock() // 结束后释放写锁
//写入一个数据块
n, err := db.buffer.Write(d)
db.cond.Broadcast() // 写入数据后通过 Broadcast 通知处于阻塞状态的读取器
return n, err
}
func main() {
db := NewDataBucket()
for i := 1; i < 3; i++ { // 启动多个读取器
go db.Read(i)
}
for j := 0; j < 10; j++ { // 启动多个写入器
go func(i int) {
d := fmt.Sprintf("data-%d", i)
db.Put([]byte(d)) // 写入数据到缓冲区
}(j)
time.Sleep(100 * time.Millisecond) // 每次启动一个写入器暂停100ms,让读取器阻塞
}
}
执行上述代码,打印结果如下:
可以看到,通过互斥锁+条件变量,我们可以非常方便的实现多个 Go 协程之间的通信,但是这个还是比不上 channel,因为 channel 还可以实现数据传递,条件变量只是发送信号,唤醒被阻塞的协程继续执行,另外 channel 还有超时机制,不会出现协程等不到信号一直阻塞造成内存堆积问题,换句话说,channel 可以让程序更可控。
10 Comments
最后使用广播,唤醒所有阻塞的读取器,会出现多个读取器同时从缓冲区读数据,单个读取器读取数据不完整。
Wait原子地解锁c.L并中止调用goroutine的执行。 稍后恢复执行后,等待锁定c.L,然后再返回。 与其他系统不同,等待不会返回,除非被广播或信号唤醒。
因为在等待第一次恢复时c.L未被锁定,所以调用者通常无法假定等待返回时条件为真。 而是,调用者应在循环中等待
cond.Signal()信号唤醒一个等待在c上的goroutine,如果有的话。
允许但不要求呼叫者在通话过程中保持c.L。
而是在对应的共享资源状态
发送
变化时 ---> 发生感谢反馈 已修正
学院君大佬, 我有个疑问, 读取器的无限for循环是怎么停止循环的啊, 没看明白
主程序退出 协程也就跟着退出了
读取器中获取读锁,当cond.Wait阻塞不会释放读锁,写程序怎么能够获得写锁呢?这么写为什么不会发生死锁呢? 并且我在读取器的defer中加入其他打印也不会输出, defer func() { fmt.Println("release rlock") db.mutex.RUnlock() }() 还望老师解答
并不会,同时只有一个能被执行因为锁只有一个,当wait的时候把锁去了 然后等广播的时候会加锁,但是只有一个能加锁成功,其他的都还是被阻塞住了不会执行的
wait会释放锁,直到Signal或者Broadcast再重新加锁