重新思考 Go 系列:这个系列希望结合工作中在 Go 编程与性能优化中遇到过的问题,探讨 Go 在语言哲学、底层实现和现实需求三者之间关系与矛盾。


Go 语言是一门为实现 CSP 并发模型而设计的语言,这也是它区别于其他语言最大的特色。而为了实现这一点,Go 在语法上就内置了 chan 的数据结构来作为不同协程间通信的载体。

Go 的 channel 提供 input 和 output 两种操作语法。input 一个已经 full 的 channel ,或是 output 一个 empty 的 channel 都会引发整个协程的阻塞。这个协程阻塞性能代价很低,也是协程让渡执行权的主要方法。

ch := make(chan int, 1024)
// input
ch <- 1
// output
<-ch

然而 channel 的实现恰好和进程内消息队列的大部分需求是吻合的,所以这个结构时常被用来作为生产者消费者模型的实现,甚至还作为 channel 的主流应用场景而推广。

但事实上,如果真的把该数据结构用来作为系统内核心链路的生产消费者模型底层实现,一不留神就会遇到雪崩级别的问题,且这些问题都不是简单的代码修改便能解决的。

Input 失败导致阻塞

当 channel 满的时候,<- 操作会导致整个 goroutine 阻塞。显然这并不总是编程者希望的,所以 Go 提供了 select case 的方法来判断 <- 是否成功:

select {
case ch <- 1:
default:
    // input failed
}

但问题是,当 channel input 失败时,编程者还能怎么做?除非队列的消息是可以被丢弃的,否则我们可能只再去创建一个类似 queue 的结构,将这部分消息缓存下来。但是这个 queue 的结构可能又要和这个 ch 本身的队列顺序处理好并发关系。

再或者就是创建一个额外 goroutine 来执行 channel input 的操作,但这样的代价是 goroutine 大量增加且消息变得无序:

go func() {
    ch <- 1
}

或者还有一种最粗暴同时也是简单的方法,就是把队列 size 扩大到 N,N 的远大于业务系统正常流量下会遇到的大小。即便这种解决方法有掩耳盗铃的嫌疑,但却是我所见过的大部分人最常用的解决问题方法。实际上,让我们回归到现实中,回想我们这些年来 make channel 的时候,真的做过非常仔细地做过估算吗?还是绝大部份时候就是随便选一个差不多的数字?况且即便是在当下经过严谨的估算,这个合理值也会随着系统流量变化或是代码逻辑的改变,导致不再合理。

在大部分时候,或许并不会有什么太大的问题。但当有问题的时候,这个 input 操作可能就会导致某个核心 goroutine 直接阻塞。尤其是 input 的目的往往就是不要阻塞核心链路处理,才会想用消息队列的方式去异步处理,所以 input 发生在核心链路的概率非常大。

Output 速度不可控

最常见的消费者的写法如下:

for msg := range ch {
	go process(msg)
}

如果这里的 process 函数是 CPU 密集型函数或者是需要访问外部网络,这里都需要单独开一个 goroutine 异步执行。否则消费速度会因为外部原因出现不受控的抖动,进而导致整个消息队列生产也被卡住。

但一旦开了 goroutine 异步执行,倘若 process 是 CPU 密集型函数,我们又完全无法控制消费速度,一旦生产速度过快,系统也会遭遇到非预期的压力。何况大部分进程内消息队列使用的目的就是为了不重要的事情延后直接来提升服务实时任务的延迟。

而这里的限速,我们并不能直接限在 ch 消费的 for 循环本身,因为 channel 的消费速度和生产速度是相等的,一旦限速在这里,会导致生产者也被阻塞。所以我们还得在 process 函数内,单独加入一些深思熟虑的限速逻辑。

当你做完所有保护措施后,你会发现或许最早的时候把 channel 作为消息队列的技术选型就是值得商榷的。

重新思考 Channel

从 Go 语言本身来说,为用户提供一个便捷的乃至是语法级别的进程内消息队列实现,显然并不是语言设计者想要考虑的事情。之所以 Go 在语法层面上提供了 chan 这样一个结构,真正的目的是为了多协程间通信和休眠唤醒。它的功能和语法也很单一,并不能真正做到一个健壮的消息队列数据结构所该有的能力,也并非它的设计初衷。

其他语言中,如果用户想要实现同类需求,会自然而然去找有更工程化设计的生产者消费者模型实现,而 Go 中由于 channel 结构用得顺手,语法简单,会自然地在技术选型中使用它作为实现。进而忽视了更多安全性的问题。

解决这个问题的可行办法,只能是 Go 需要在标准库里内置一个更加完备的消息队列实现,至少需要实现可以非阻塞且安全地生产消息的能力,才能让用户脱离对 channel 队列能力的依赖和认知。