sync.Cond vs channel

2,165 阅读8分钟

当提到Go中的并发同步原语,相信大多数人第一时间想到的是goroutine,channel, sync.Mutex,atomic等等。特别是channel——作为体现Go语言"share memory by communicating"思想的重要原语,是绝大多数gopher考虑并发和同步时候的第一选择。而条件变量 (condition variable) 体现的是"communicate by sharing memory"的思想,很少被大家使用,在标准包中也仅有几次引用,甚至还有开发者提出要在Go2删除sync.Cond。不过个人认为这种想法过于激进,且Go2很大程度上应该也会兼容Go1.x,应该是不会删除的。

软件工程没有银弹,"share memory by communicating"固然是很好的思想,但"communicate by sharing memory"也不一定就是错误的。本篇文章将介绍条件变量的基本用法,channel的局限性,以及 sync.Cond 的使用场景。

示例传送门:github.com/DrmagicE/co…

条件变量是什么

条件变量并不是Go语言提出的概念,它是Posix标准里 pthread中的接口,是多线程编程中的并发原语。条件变量可以让线程阻塞等待某件事件发生,当事件发生后,向关心这件事的线程发出信号来唤醒它们。条件变量在许多语言中都有对应的实现,包括Go语言,只不过在Go中,线程变成了goroutine协程。条件变量其实也是一种锁,但它提供了一种类似于pub/sub响应订阅式的机制,避免线程(协程)通过轮询的方式来监听对应的事件。

Go中的条件变量

sync.Cond 是Go语言实现的条件变量,共有四个关联函数:

// NewCond 是Cond构造函数,可以看到条件变量必须要和锁搭配使用
func NewCond(l Locker) *Cond {...}
// Wait 等待某个条件达成.
func (c *Cond) Wait() {...}
// Signal 向一个goroutine发送信号
func (c *Cond) Signal() {...}
// Broadcast 向所有goroutine广播信号
func (c *Cond) Broadcast() {...}

我们用一段Go伪代码来说明条件变量的基本使用方式:

// goroutinA -----
// 等待事件发生的goroutine
c.L.Lock()  
for !condition() {
    // 如果当前协程所关注的状态还不满足,那么把自己加到通知队列中(相当于订阅这个事件)
    t := runtime_notifyListAdd(&c.notify)
    // 订阅完毕后释放锁(给其他协程修改condition的机会)
    c.L.Unlock()
    // 阻塞,直到被其他goroutine唤醒
    runtime_notifyListWait(&c.notify, t)
    // 当前goroutine被唤醒后,重新加锁(保证接下来的操作都是在满足condition的条件下进行)
    c.L.Lock()
}
// 这里是condition==true的区间
// ....

// 使用完condition后释放
c.L.Unlock()

// -----华丽的分割线-----

// goroutineB -----
// 下面是执行事件,并发出信号通知的goroutine:
c.L.Lock()
changeCondition()
c.Signal() // 对关心condition的一个goroutine发出信号。
c.L.Unlock

从上述伪代码,我们可以得知:

  1. 订阅端和发送端会共同持有一把锁,这把锁用来保护某个状态。
  2. 当条件不满足时,订阅端goroutine会将自己阻塞(而不是轮询条件状态)。等待被其他goroutine唤醒。
  3. 发送端在改变状态后,会调用 Signal 方法来唤醒其他阻塞的goroutine。(或调用 Broadcast 方法来唤醒所有阻塞的goroutine)

示例——用 sync.Cond 模拟Go channel

在开篇提到的“建议在Go2中废弃 sync.Cond ”的issue中,其作者的主要的观点是: sync.Cond 的功能完全可以用channel来实现,而channel是语言层面原生的原语,功能要比 sync.Cond 强大, 而且 sync.Cond 难以使用且容易出错,因而觉得 sync.Cond 的存在是多余的。 sync.Cond 和 channel 的确很相似,大部分情况下都可以相互替代,接下来我们就使用 sync.Cond 来逐步实现一个模拟“Channel” 。

V1——基本功能

在V1版本中,我们将实现原生channel的基本功能——可以协程安全的收发数据。实现代码如下:

// 模拟channel
type Channel struct {
    // 条件变量
    cond *sync.Cond
    // l 用于保存channel中的内容
    l *list.List
}
// NewChannel 初始化Channel
func NewChannel() *Channel{
    return &Channel{
        cond:sync.NewCond(&sync.Mutex{}),
        l:list.New(),
    }
}
// Send 向Channel中发送数据
func (c *Channel) Send(i int) {
    c.cond.L.Lock()
    defer func() {
        c.cond.Signal()
        c.cond.L.Unlock()
    }()
    c.l.PushBack(i)
}
// Recv 接收数据,如果Channel中没有内容,则阻塞等待。
func (c *Channel) Recv() (i int){
    // Send 和 Recv都需要访问c.l,要加锁
    c.cond.L.Lock()
    for c.l.Len() == 0 {
        // 如果channel中还没有数据,那么Wait等待Signal或Broadcast的通知。
        // 在Wait()方法中,会包含Unlock的逻辑,当收到信号后,会重新Lock。
        c.cond.Wait()
        // 这里一定要用for而不能用if,因为在Wait方法中,收到信号和重新加锁之并不是原子操作。
        // 其他goroutine是有可能在重新加锁之前对c.l其进行修改的(那么len就可能不是0了),
        // 因此还要再进入for判断一次。
    }
    defer func() {
        c.cond.Signal()
        c.cond.L.Unlock()
    }()
    return c.l.Remove(c.l.Front()).(int)
}

现在我们有了一个协程安全收发数据的“Channel”,使用示例如下:

func main() {
    ch := NewChannel()
    for i:=0;i<10;i++{
        go func(i int) {
            ch.Send(i)
        }(i)
    }
    for i:=0;i<10;i++{
        fmt.Println(ch.Recv())
    }
    // 死锁
    // fmt.Println(ch.Recv())
}

可以看到,我们的“Channel”通过一个加锁队列 *list.List 来保存消息,当 Recv发现队列长度为0时,调用 c.cond.Wait  阻塞自身,将自身协程加入到等待通知的队列中。当*list.List长度因 Send 或是 Recv 调用而发生变化的时,调用方需要执行 c.cond.Signal()方法来唤醒阻塞在 Recv 上的的协程。

请关注 Recv 方法中的注释说明——wait()一定要放在 for !condition() {...}中。

V2——“buffer channel”

接下来,我们要给“Channel”加上buffer,让其能实现类似buffer channel的功能——当buffer满了的时候,阻塞 Send 函数。 首先,我们在构造函数中给“Channel”加上 size ,表示buffer容量:

type Channel struct {
    cond *sync.Cond
    size int 
    l *list.List
} 
func NewChannel(size int) *Channel{
    return &Channel{
        cond:sync.NewCond(&sync.Mutex{}),
        size:size,
        l:list.New(),
    }
}

Send 中判断“Channel”长度,如果满了,则阻塞:

func (c *Channel) Send(i int) {
    c.cond.L.Lock()
    defer func() {
        c.cond.Signal()
        c.cond.L.Unlock()
    }()
    // 如果channel buffer满了,阻塞
    for c.l.Len() >= c.size {
        c.cond.Wait()
    }
    c.l.PushBack(i)
}

Recv 方法维持原样即可。这样我们就得到了一个“buffer channel”,当发送超过buffer时, Send 将被阻塞:

func main() {
    // 创建一个buffer为2的"channel"
    ch := NewChannel(2)
    ch.Send(1)
    ch.Send(2)
    // buffer已满,再发会引发死锁
    // ch.Send(3)
}

V3——关闭广播

最后,我们还有 Broadcast 方法还没用上,下面我们就借助它来完成channel的关闭广播功能。首先,我们给我们的“Channel”加上关闭标记:

type Channel struct {
    cond *sync.Cond
    size int
    // 表示Channel是否被关闭
    close bool
    l *list.List
}

我们知道,在Go原生channel关闭后,所有阻塞在channel上的读操作都会在将channel内容读光后立刻返回,这种特性经常用于实现“广播”的效果。类似的,我们修改 Recv 方法达到类似的效果:

// 为了能在"Channel"关闭的时候,读出所有未读的数据,将返回值修改成数组
func (c *Channel) Recv() (i []int){
    c.cond.L.Lock()
    // 如果"Channel"被关闭了,则不需要阻塞等待
    for c.l.Len() == 0 && !c.close{
        c.cond.Wait()
    }
    defer func() {
        c.cond.Signal()
        c.cond.L.Unlock()
    }()
    if c.close {
        // "Channel"关闭,返回所有未读的数据
        for e := c.l.Front(); e != nil ;{
            curent := e
            e = e.Next()
            i = append(i, c.l.Remove(curent).(int))
        }
        return i
    }
    return []int{c.l.Remove(c.l.Front()).(int)}
}

向已关闭的“Channel”写入数据则会引发panic:

func (c *Channel) Send(i int) {
    c.cond.L.Lock()
    defer func() {
        c.cond.Signal()
        c.cond.L.Unlock()
    }()
    // 如果channel buffer满了,阻塞
    for c.l.Len() >= c.size && !c.close{
        c.cond.Wait()
    }
    // 向已关闭的"Channel"写入数据会引发panic
    if c.close {
        panic("send on closed channel")
    }
    c.l.PushBack(i)
}

至此,我们的模拟“Channel”就大功告成了:

func main() {
    // 创建一个buffer为2的"channel"
    ch := NewChannel(2)
    ch.Send(1)
    ch.Send(2)
    // 关闭channel
    ch.Close()
    fmt.Println(ch.Recv()) // [1,2]
    // 不会阻塞,返回空
    fmt.Println(ch.Recv()) // []
    // 向已关闭的"Channel"写入数据,panic
    ch.Send(1)
}

channel的局限性

sync.Cond 条件变量使用不慎很容易出错,增加了bug几率和开发者的心智负担。而channel作为原生支持,有 for select , for range 等原生语法支持,使用起来也非常方便。 对于channel完全能够胜任的场景,我们还是应该首选channel。不过channel也不是“银弹”,也有其局限性,在某些场景下, sync.Cond 可能是更好的选择。

channel是个“黑盒队列”

channel像是一个“黑盒队列”,当消息写入channel后,在被读出前,对用户程序都是不可见的。除了将内容读出,我们没有办法查看channel中的内容,也无法对内容进行修改,删除。

当然,我们经常往channel中发送指针,这给修改channel中的内容提供了可能,不过这也违反了“share memory by communicating”的格言,因为这时虽然使用channel传递数据,但实际收发双方还是在“sharing memory”。而且如果我们真的需要修改已经写入到channel中的内容,通常我们需要再加一把锁,避免data race。

而我们对 sync.Cond 有完全的控制权,我们可以对内容进行修改(无需引入额外的锁),甚至是删除我们认为过期的数据。当我们既需要类似channel的通信模型,又希望能对通信队列有更多控制权时,sync.Cond 会是更好的选择。

channel只能广播一次

我们知道,go channel不能重复关闭,否则会引发panic。那么就意味着在不借助另一把锁的情况下,channel只能广播一次。而 sync.Cond则没有这个限制,在上面的示例中,我们可以为我们的“Channel”再加上一个 Open 的方法,让它可以被重复的打开关闭:

func (c *Channel) Open() {
	c.cond.L.Lock()
	defer func() {
            c.cond.Signal()
            c.cond.L.Unlock()
	}()
	c.close = false
}

channel不支持“批处理”

channel一次只能读出/写入一个元素,意味着channel接收者的处理函数只能够一个个依次处理。但对于某些操作,批处理的性能会比逐条处理高。特别是当channel发生积压时,如果能一次批量读出channel中的内容,进行批处理操作,可以大大提升性能。例如在网络编程中,我们从channel中读出要发往客户端消息,写入buffer后执行flush。伪代码如下:

for {
    select {
        case msg := <- out:
            // 写到buffer,通常是&bufio.Writer{}
            writeToBuffer(msg)
            flush() 
    }
}

当需要向客户端一次发送N条消息时,在使用 for select case的情况下,每发送一条消息都要执行一次 flush() 。这意味着即使明知道当前有N条消息待发送,程序依然只能逐条发送然后 flush() ,意味着每发一条消息都要执行一次系统调用。但如果我们可以一次性读出N条,写入buffer后统一 flush() ,可以大大提高性能。 参考上面的示例,使用 sync.Cond可以很方便的一次读出多条数据,使“批处理”成为可能。

for select的性能问题

for select 的性能随着 case 的数量增多而下降,这是因为channel底层也会使用锁,channel数量越多, futex 系统调用就越多,性能越差。详细说明可以参考下面的连接:

stackoverflow.com/questions/4…

sync.Cond 则不存在这样的问题,sync.Cond 只有一把锁。当性能是一个重要考虑因素,而我们又有一个很多 case 的 for select 的时候,可以考虑使用 sync.Cond 来代替,例如下面的伪代码,等同于 for select 中有3个 case 的情况:

func (c *Channel) ForSelect() {
    c.cond.L.Lock()
    defer func() {
            c.cond.Signal()
            c.cond.L.Unlock()
    }()
    // 等待任意一个case中有数据
    for c.caseA.Len() == 0 || c.caseB.Len() == 0 || c.caseC.Len() == 0 {
            c.cond.Wait()
    }
    if c.caseA.Len() != 0 {
        handleCaseA()
    }
    if c.caseB.Len() != 0 {
        handleCaseB()
    }
    if c.caseC.Len() != 0 {
        handleCaseC()
    }
}

结语

“当你手上有一把锤子的时候,看所有的东西都是钉子”——在Go语言中,channel就像一把“万能锤”,它的确很强大,可以解决很多问题,但不是所有问题。条件变量也是非常成熟的解决方案,当遇到channel不擅长的问题时,不妨尝试一下使用sync.Cond

参考

github.com/golang/go/i… garrett.damore.org/2018/12/gol… www.modernescpp.com/index.php/c… dtyler.io/articles/20…