Go 的 Channels 不好用 (2016)
Posts
首次发布: 2016年3月2日, 上午8:38 -0700 最后编辑: 2019年3月4日, 上午10:39 -0700
Go channels are bad and you should feel bad
更新: 如果你因为一个名为“Go is not good”的文集而来到这篇博客,我想明确表示,我为出现在这样的列表中感到羞愧。Go 绝对是我用过的最不糟糕的编程语言。在我写这篇文章的时候,我想遏制我当时看到的一种趋势,即过度使用 Go 中比较棘手的部分之一。我仍然认为 channels 可以做得更好,但总的来说,Go 很棒。就像你最喜欢的工具箱里有这个;这个工具可以有用途(即使它可以有更多的用途),它仍然可以是你最喜欢的工具箱!
更新 2: 如果我不指出这份关于实际问题的优秀调查,我将是失职的:Understanding Real-World Concurrency Bugs In Go。这项调查的一个重要发现是……Go channels 导致了很多 bug。
从 2010 年中后期开始,我一直在断断续续地使用 Google 的 Go programming language,并且自 2012 年 1 月以来(在 Go 1.0 之前!),我已经为 Space Monkey 编写了用 Go 编写的合法的生产代码。我最初使用 Go 的经验是在我研究 Hoare 的 Communicating Sequential Processes 并发模型和 π-calculus 时,由 Matt Might 的 UCombinator research group 作为我的(现在重定向的)博士工作的一部分,目的是更好地支持多核开发。Go 恰好在那时发布(多么巧合!),我立即开始尝试。
它很快成为 Space Monkey 开发的核心部分。我们 Space Monkey 的生产系统目前包含超过 42.5 万行纯 Go 代码(_不_包括我们所有的 vendored 库,这将使它接近 150 万行),所以不是你见过的最多的 Go 代码,但对于相对年轻的语言来说,我们是重度用户。我们之前写过关于我们对 Go 的使用。我们开源了一些使用相当广泛的库;很多人似乎是我们的 OpenSSL bindings 的粉丝(它比 crypto/tls 快,但请保持 openssl 本身是最新的!),我们的 error handling library,logging library 和 metric collection library/zipkin client。我们使用 Go,我们喜欢 Go,我们认为它是我们迄今为止使用过的满足我们需求的编程语言中最不糟糕的。
虽然我不认为我可以避免提到我被广泛避免的 goroutine-local-storage library (即使它是一个你不应该使用的 hack,但它是一个漂亮的 hack),希望我的其他经验足以证明我知道我在说什么,然后我解释我故意煽动性的帖子标题。
等等,什么?
如果你问街上随便一位程序员 Go 有什么特别之处,她很可能会告诉你 Go 最出名的是 channels 和 goroutines。Go 的理论基础很大程度上基于 Hoare 的 CSP 模型,CSP 模型本身非常迷人有趣,我坚信它比我们迄今为止获得的收益更多。
CSP(和 π-calculus)都使用通信作为核心同步原语,因此 Go 具有 channels 是有道理的。Rob Pike出于充分的 理由 一直对 CSP 着迷。
但从务实的角度来看(Go 以此为荣),Go 的 channels 搞错了。在我看来,channels 的实现几乎是一个坚定的反模式。为什么?亲爱的读者,请允许我细数原因。
你可能最终不会只使用 channels。
Hoare 的 Communicating Sequential Processes 是一种计算模型,其中本质上唯一的同步原语是在 channel 上发送或接收。只要你使用 mutex、semaphore 或 condition variable,bam,你就脱离了纯 CSP 的世界。Go 程序员经常通过吟唱 cached thought “share memory by communicating” 来吹捧这种模型和哲学。
所以让我们尝试在 Go 中编写一个小程序,只使用 CSP!让我们做一个高分接收器。我们所要做的就是跟踪我们见过的最大高分值。就这样。
首先,我们将创建一个 Game
struct。
type Game struct {
bestScore int
scores chan int
}
bestScore
不会被 mutex 保护!没关系,因为我们只需要一个 goroutine 来管理其状态并通过 channel 接收新的分数。
func (g *Game) run() {
for score := range g.scores {
if g.bestScore < score {
g.bestScore = score
}
}
}
好的,现在我们将创建一个有用的构造函数来启动一个游戏。
func NewGame() (g *Game) {
g = &Game{
bestScore: 0,
scores: make(chan int),
}
go g.run()
return g
}
接下来,假设有人给了我们一个可以返回分数的 Player
。它也可能返回一个错误,因为传入的 TCP 流可能会死掉,或者玩家退出。
type Player interface {
NextScore() (score int, err error)
}
为了处理 player,我们将假设所有错误都是致命的,并将接收到的分数传递到 channel 中。
func (g *Game) HandlePlayer(p Player) error {
for {
score, err := p.NextScore()
if err != nil {
return err
}
g.scores <- score
}
}
耶!好的,我们有一个 Game
类型,它可以以线程安全的方式跟踪 Player
收到的最高分数。
你完成了你的开发,并且你正在拥有客户的路上。你公开了这个游戏服务器,并且你非常成功!你的游戏服务器正在创建很多游戏。
很快,你发现有人有时会离开你的游戏。很多游戏不再有任何玩家玩了,但没有什么能阻止游戏循环。你被死亡的 (*Game).run
goroutine 淹没了。
**挑战:**修复上面的 goroutine 泄漏,而无需 mutexes 或 panics。认真地,向上滚动到上面的代码,并提出一个仅使用 channels 解决此问题的计划。
我会等的。
就其价值而言,它完全可以用 channels 来完成,但请观察以下解决方案的简单性,甚至没有这个问题:
type Game struct {
mtx sync.Mutex
bestScore int
}
func NewGame() *Game {
return &Game{}
}
func (g *Game) HandlePlayer(p Player) error {
for {
score, err := p.NextScore()
if err != nil {
return err
}
g.mtx.Lock()
if g.bestScore < score {
g.bestScore = score
}
g.mtx.Unlock()
}
}
你更喜欢使用哪一个?不要被误导地认为 channel 解决方案在更复杂的情况下以某种方式使它更具可读性和可理解性。拆卸非常困难。这种拆卸对于 mutex 来说只是一块蛋糕,但对于仅 Go 特定的 channels 来说,是最难解决的事情。此外,如果有人回复说 channels 发送 channels 在这里更容易推理,那将导致我立即将头撞向桌子。
重要的是,这个特定情况可能实际上可以_轻松_地通过_channels_解决,但 Go 没有提供一些运行时帮助!不幸的是,就目前的情况而言,与 Go 版本的 CSP 相比,使用传统同步原语可以更好地解决很多问题。我们稍后将讨论 Go 可以做些什么来使这种情况更容易。
**练习:**仍然怀疑?尝试使上面的两个解决方案(仅 channel 与仅 mutex)在 bestScore
大于或等于 100 后停止向 Players
请求分数。继续并打开你的文本编辑器。这是一个小型玩具问题。
这里的总结是,如果你想做任何实际的事情,你将除了 channels 之外还使用传统的同步原语。
Channels 比自己实现更慢
我假设 Go 如此强烈地基于 CSP 理论的一件事是,运行时应该可以对 channels 进行一些非常出色的调度程序优化。也许 channels 并不总是最简单的原语,但它们肯定高效快速,对吧?
正如 Dustin Hiatt 在 Tyler Treat 关于 Go 的帖子 中指出的那样,
在幕后,channels 使用锁来序列化访问并提供线程安全性。因此,通过使用 channels 来同步对内存的访问,实际上你是在使用锁;锁被包装在一个线程安全的队列中。那么 Go 的花哨锁与仅使用其标准库
sync
包中的 mutexes 相比如何?以下数字是通过使用 Go 的内置基准测试功能来串行调用其各自类型的一个集合上的 Put 获得的。
BenchmarkSimpleSet-8 3000000 391 ns/op BenchmarkSimpleChannelSet-8 1000000 1699 ns/o
无缓冲 channel 的情况类似,甚至在争用而不是串行运行的情况下,同一个测试也是如此。
也许 Go 调度程序会改进,但与此同时,旧的 mutexes 和 condition variables 非常好、高效且快速。如果你想要性能,你使用经过验证的方法。
### Channels 与其他并发原语的组合效果不佳
好的,希望我已经说服你,你至少有时会与除了 channels 之外的原语进行交互。标准库肯定似乎更喜欢传统的同步原语而不是 channels。
好吧,猜猜怎么着,实际上将 channels 与 mutexes 和 condition variables 一起正确使用有点挑战!
channels 最有趣的事情之一是,来自 CSP 的很多想法是 channel 发送是同步的。channel 发送和 channel 接收旨在成为同步屏障,并且发送和接收应该在同一虚拟时间发生。如果你在执行良好的 CSP 世界中,那就太好了。

实际上,Go channels 也有缓冲的变体。你可以分配固定数量的空间来考虑可能的缓冲,以便发送和接收是不同的事件,但缓冲区大小是有限制的。Go 没有提供拥有任意大小缓冲区的方法——你必须提前分配缓冲区大小。_这很好_,我看到有人在邮件列表中争论说,_因为内存无论如何都是有界的_。
什么。
这是一个糟糕的答案。有很多理由使用任意缓冲的 channel。如果我们预先知道所有信息,为什么还要有 `malloc`?
没有任意缓冲的 channels 意味着在_任何_ channel 上的简单发送都可能随时阻塞。你想在 channel 上发送并在 mutex 下更新一些其他 bookkeeping 吗?小心!你的 channel 发送可能会阻塞!
// ... s.mtx.Lock() // ... s.ch <- val // 可能会阻塞! s.mtx.Unlock() // ...
这是哲学家晚餐斗争的配方。如果你获得一个锁,你应该快速更新状态并释放它,并且尽可能不在锁下执行任何阻塞操作。
有一种方法可以在 Go 中的 channel 上执行非阻塞发送,但它不是默认行为。假设我们有一个 channel `ch := make(chan int)` 并且我们想在上面发送值 `1` 而不阻塞。这是你必须做的最少量的输入才能在不阻塞的情况下发送:
select { case ch <- 1: // 它发送了 default: // 它没有 }
这不是初学者 Go 程序员自然而然想到的。
总结是,因为 channels 上的许多操作都会阻塞,所以需要仔细推理哲学家和他们的用餐才能成功地将 channel 操作与 mutex 保护一起使用,而不会导致死锁。
### Callbacks 严格来说更强大,不需要不必要的 goroutines。

每当 API 使用 channel 时,或者每当我指出 channel 使某些事情变得困难时,总是有人指出我应该启动一个 goroutine 来从 channel 中读取,并在从 channel 中读取时进行任何翻译或修复。
嗯,不。如果我的代码在热路径中怎么办?很少有实例需要 channel,并且如果你的 API 可以使用 mutexes、semaphores 和 callbacks 进行设计而没有额外的 goroutines(因为所有事件边缘都由 API 事件触发),那么使用 channel 迫使我向我的资源使用情况添加另一个内存分配堆栈。Goroutines 比线程轻得多,是的,但更轻并不意味着尽可能最轻。
正如我之前[在关于使用 channels 的一篇文章的评论中](https://www.jtolio.com/2016/03/go-channels-are-bad-and-you-should-feel-bad/<http:/www.informit.com/articles/article.aspx?p=2359758#comment-2061767464>)(哈哈互联网)争论的那样,如果使用 callbacks 而不是 channels,你的 API _总是_可以更通用,_总是_更灵活,并且占用更少的资源。“总是”是一个可怕的词,但我的意思是在这里。有证明级别的材料。
如果有人向你提供基于 callback 的 API 并且你需要 channel,你可以提供一个 callback,它以很少的开销和充分的灵活性在 channel 上发送。
另一方面,如果有人向你提供基于 channel 的 API 并且你需要 callback,你必须启动一个 goroutine 来从 channel 中读取,_并且_你必须希望当你不不再读取时,没有人尝试在 channel 上发送更多内容,因此你导致阻塞的 goroutine 泄漏。
对于一个超级简单的真实示例,请查看 [context interface](https://www.jtolio.com/2016/03/go-channels-are-bad-and-you-should-feel-bad/<https:/godoc.org/golang.org/x/net/context>)(顺便说一句,这是一个非常有用的包,你应该使用它而不是 [goroutine-local storage](https://www.jtolio.com/2016/03/go-channels-are-bad-and-you-should-feel-bad/<https:/github.com/jtolds/gls>)):
type Context interface { ... // Done 返回一个 channel,当这个工作单元应该被取消时,它会关闭。 Done() <-chan struct{} // Err 返回一个非 nil 错误,当 Done channel 关闭时 Err() error ... }
想象一下,你只想在 `Done()` channel 触发时记录相应的错误。你必须做什么?如果你没有一个已经选择 channel 的好地方,你必须启动一个 goroutine 来处理它:
go func() { <-ctx.Done() logger.Errorf("canceled: %v", ctx.Err()) }()
如果 `ctx` 在关闭 channel `Done()` 返回之前被垃圾回收了怎么办?哎呀!只是泄漏了一个 goroutine!
现在想象一下我们更改了 `Done` 的签名:
// Done 在这个工作单元应该被取消时调用 cb。 Done(cb func())
首先,现在记录非常容易。看看:`ctx.Done(func() { log.Errorf("canceled: %v", ctx.Err()) })`。但假设你真的需要一些选择行为。你可以这样调用它:
ch := make(chan struct{}) ctx.Done(func() { close(ch) })
瞧!没有通过使用 callback 丢失表达能力。`ch` 的工作方式与 channel `Done()` 过去返回的方式相同,并且在日志记录案例中,我们不需要启动一个全新的堆栈。我保留了我的堆栈跟踪(如果我们的日志包倾向于使用它们);我避免了另一个堆栈分配和另一个 goroutine 来提供给调度程序。
下次你使用 channel 时,问问自己是否有一些 goroutine 可以通过使用 mutexes 和 condition variables 来消除。如果答案是肯定的,那么如果更改它,你的代码将更有效率。如果你只是想使用 channels 以便能够使用 `range` 关键字来处理集合,我将不得不要求你把键盘收起来,或者回去写 Python 书籍。

更像是 Zooey De-channel,我说的对不对
### Channel API 不一致且非常疯狂
关闭或发送到已关闭的 channel 会 panic!为什么?如果你想关闭 channel,你需要以某种方式在外部同步其关闭状态(使用不能很好地组合的 mutexes 等),以便其他写入者不会写入或关闭已关闭的 channel,或者只是向前冲并关闭或写入已关闭的 channels 并期望你必须恢复任何引发的 panics。
这是如此奇怪的行为。几乎 Go 中的每个其他操作都有一种避免 panic 的方法(例如,类型断言具有 `, ok =` 模式),但对于 channels,你只需处理它。
好的,所以当发送将失败时,channels 会 panic。我想这在某种程度上是有道理的。但与几乎所有其他具有 nil 值的东西不同,发送到 nil channel 不会 panic。相反,它将永远阻塞!这非常违反直觉。这可能是有用的行为,就像你的除草机上附带一个开罐器可能有用(并且在 Skymall 中找到),但这肯定是意想不到的。与与 nil map(执行隐式指针取消引用)、nil 接口(隐式指针取消引用)、未经检查的类型断言以及所有其他事情交互不同,nil channels 表现出实际的 channel 行为,就好像刚刚为此操作实例化了一个全新的 channel。
接收稍微好一些。当你在已关闭的 channel 上接收时会发生什么?嗯,这有效 - 你得到一个零值。好吧,我想这是有道理的。奖金!接收允许你执行一个 `, ok =` 样式的检查,以查看 channel 在你收到值时是否已打开。感谢上帝,我们在这里得到了 `, ok =`。
但是如果你从 nil channel 接收会发生什么?_也永远阻塞!_耶!不要试图使用你的 channel 为 nil 的事实来跟踪你是否关闭了它!
## Channels 有什么好处?
当然,channels 对于某些事情是有好处的(毕竟它们是一个通用容器),并且有些事情你只能用它们来做 (`select`)。
### 它们是另一个特殊情况的通用数据结构
Go 程序员非常习惯于关于 generics 的争论,以至于我仅仅通过提出这个词就可以感受到 PTSD 的来临。我不是来谈论它的,所以擦去你额头上的汗水,让我们继续前进。
无论你对 generics 的看法如何,Go 的 map、slice 和 channel 都是支持通用元素类型的数据结构,因为它们已被特殊情况化到语言中。
在一种不允许你编写自己的通用容器的语言中,_任何_允许你更好地管理事物集合的东西都很有价值。在这里,channels 是一种线程安全的数据结构,支持任意值类型。
所以这很有用!我想这可以节省一些样板代码。
我很难将其算作 channel 的胜利。
### Select
你可以使用 channel 的主要功能是 `select` 语句。在这里,你可以等待固定数量的输入以获取事件。它有点像 epoll,但你必须预先知道你将要等待多少个套接字。
这确实是一个有用的语言功能。如果不是因为 `select`,Channels 将完全被冲刷。但是天哪,让我告诉你第一次你决定可能需要在多个东西上进行选择,但你不知道有多少,并且你必须使用 `reflect.Select`。
## Channels 如何才能更好?
很难说 Go 语言团队可以为 Go 2.0 做出的最具策略性的事情是什么(Go 1.0 的兼容性保证很好,但束缚了手脚),但这不会阻止我提出一些建议。
### Select on condition variables!
我们可以直接避免对 channel 的需求!这是我建议我们摆脱一些神圣奶牛的地方,但让我问你这个问题,如果你可以在任何自定义同步原语上进行选择,那该有多好? (A:太棒了。)如果我们有这个,我们将根本不需要 channel。
### GC 可以帮助我们吗?
在第一个示例中,如果我们能够使用方向类型 channel 垃圾收集来帮助我们清理,我们可以轻松地解决高分服务器清理问题。

如你所知,Go 具有方向类型 channel。你可以拥有仅支持读取的 channel 类型 (`<-chan`) 和仅支持写入的 channel 类型 (`chan<-`)。太好了!
Go 也有垃圾收集。很明显,某些类型的记账工作太繁重了,我们不应该让程序员处理它们。我们清理未使用的内存!垃圾收集有用且整洁。
那么为什么不帮助清理未使用的或死锁的 channel 读取呢?而不是让 `make(chan Whatever)` 返回一个双向 channel,而是让它返回两个单向 channel (`chanReader, chanWriter := make(chan Type)`)。
让我们重新考虑原始示例:
type Game struct { bestScore int scores chan<- int } func run(bestScore *int, scores <-chan int) { // 我们不直接保留对 *Game 的引用,因为那样我们会持有 // 到 channel 的发送端。 for score := range scores { if *bestScore < score { *bestScore = score } } } func NewGame() (g *Game) { // 这种 make(chan) 返回样式是一个提案! scoreReader, scoreWriter := make(chan int) g = &Game{ bestScore: 0, scores: scoreWriter, } go run(&g.bestScore, scoreReader) return g } func (g *Game) HandlePlayer(p Player) error { for { score, err := p.NextScore() if err != nil { return err } g.scores <- score } }
如果垃圾收集在我们可以证明不再有任何值进入 channel 时关闭了 channel,那么此解决方案已完全修复。是的,是的,`run` 中的注释表明存在一把相当大的枪对准你的脚,但至少现在这个问题很容易解决,而以前真的不是。此外,一个聪明的编译器可能会做出适当的证明来减少来自所述 foot-gun 的损害。
### 其他较小的问题
* **Dup channels?** - 如果我们可以在 channel 上使用等效于 `dup` 系统调用的东西,那么我们也可以很容易地解决多生产者问题。每个生产者都可以关闭他们自己的 `dup`-ed channel,而不会破坏其他生产者。
* **修复 channel API!** - 关闭不是幂等的?发送到已关闭的 channel 会 panic,无法避免它?哎呀!
* **任意缓冲的 channels** - 如果我们可以创建没有固定缓冲区大小限制的缓冲 channels,那么我们可以创建不阻塞的 channels。
## 那么我们如何告诉人们关于 Go 的信息呢?
如果你还没有,请去看看我目前最喜欢的编程帖子:[What Color is Your Function](https://www.jtolio.com/2016/03/go-channels-are-bad-and-you-should-feel-bad/<http:/journal.stuffwithstuff.com/2015/02/01/what-color-is-your-function/>)。虽然不是专门关于 Go 的,但这篇博客文章比我能更雄辩地阐述了为什么 goroutines 是 Go 最好的特性(并且顺便说一句,也是 Go 在某些应用程序中比 Rust 更好的方法之一)。
如果你仍然在使用一种编程语言编写代码,该语言强制你使用诸如 `yield` 之类的关键字来获得高性能、并发性或事件驱动模型,那么你正生活在过去,无论你或其他人是否知道这一点。到目前为止,Go 是我见过的实现 M:N 线程模型(不是 1:1)的最佳语言之一,而且非常强大。
所以,告诉人们关于 goroutines 的信息。
如果我必须选择 Go 的另一个主要特性,那就是接口。静态类型的 [duck typing](https://www.jtolio.com/2016/03/go-channels-are-bad-and-you-should-feel-bad/<https:/en.wikipedia.org/wiki/Duck_typing>) 使扩展和使用你自己的或别人的项目变得如此有趣和令人惊叹,以至于可能值得我在其他时间写一套完全不同的文字来介绍它。
## 所以…
我不断看到人们冲进 Go,渴望充分利用 channels 的潜力。这是我对你的建议。
**JUST STAHP IT**
当你编写 APIs 和接口时,尽管“永远不要”这个建议有多糟糕,但我可以肯定的是,在任何时候,channels 都不会更好,而且我用过的每一个使用 channel 的 Go API,我最终都不得不与之抗争。我从来没有想过“哦,太好了,这里有一个 channel;”相反,它总是 _**WHAT FRESH HELL IS THIS?**_ 的某种变体。
所以,_请,请在适当的时候以及仅在适当的时候使用 channels。_
在我使用的所有 Go 代码中,我可以用一只手来计算 channel 真正是最佳选择的次数。有时它们是。太好了!那就用它们。但否则就停止。

_特别感谢我的校对员 Jeff Wendling、[Andrew Harding](https://www.jtolio.com/2016/03/go-channels-are-bad-and-you-should-feel-bad/<https:/github.com/azdagron>)、[George Shank](https://www.jtolio.com/2016/03/go-channels-are-bad-and-you-should-feel-bad/<https:/twitter.com/taterbase>) 和 [Tyler Treat](https://www.jtolio.com/2016/03/go-channels-are-bad-and-you-should-feel-bad/<http:/bravenewgeek.com>) 提供的宝贵反馈。_
如果你想在 Space Monkey 与我们一起使用 Go,请[联系我](https://www.jtolio.com/2016/03/go-channels-are-bad-and-you-should-feel-bad/</contact/>)!