更新:如果你是从一篇题为 《糟糕的 Go 语言》 的汇编文章看到这篇博文的话,那么我想表明的是,我很惭愧被列在这样的名单上。Go 绝对是我使用过的最不糟糕的的编程语言。在我写作本文时,我是想遏制我所看到的一种趋势,那就是过度使用 Go 的一些较复杂的部分。我仍然认为 通道 Channel 可以更好,但是总体而言,Go 很棒。这就像你最喜欢的工具箱中有 这个工具;它可以有用途(甚至还可能有更多的用途),它仍然可以成为你最喜欢的工具箱!
更新 2:如果我没有指出这项对真实问题的优秀调查,那我将是失职的:《理解 Go 中的实际并发错误》。这项调查的一个重要发现是…Go 通道会导致很多错误。
从 2010 年中后期开始,我就断断续续地在使用 Google 的 Go 编程语言,自 2012 年 1 月开始(在 Go 1.0 之前!),我就用 Go 为 Space Monkey 编写了合规的产品代码。我对 Go 的最初体验可以追溯到我在研究 Hoare 的 通信顺序进程 并发模型和 Matt Might 的 UCombinator 研究组 下的 π-演算 时,作为我(现在已重定向)博士工作的一部分,以更好地支持多核开发。Go 就是在那时发布的(多么巧合啊!),我当即就开始学习尝试了。
它很快就成为了 Space Monkey 开发的核心部分。目前,我们在 Space Monkey 的生产系统有超过 42.5 万行的纯 Go 代码(不 包括我们所有的 vendored 库中的代码量,这将使它接近 150 万行),所以也并不是你见过的最多的 Go 代码,但是对于相对年轻的语言,我们是重度用户。我们之前 写了我们的 Go 使用情况。也开源了一些使用率很高的库;许多人似乎是我们的 OpenSSL 绑定(比 crypto/tls 更快,但请保持 openssl 本身是最新的!)、我们的 错误处理库、日志库 和 度量标准收集库/zipkin 客户端 的粉丝。我们使用 Go、我们热爱 Go、我们认为它是目前为止我们使用过的最不糟糕的、符合我们需求的编程语言。
尽管我也不认为我能说服自己不要提及我的广泛避免使用 goroutine-local-storage 库 (尽管它是一个你不应该使用的魔改技巧,但它是一个漂亮的魔改),希望我的其他经历足以证明我在解释我故意煽动性的帖子标题之前知道我在说什么。
等等,什么?
如果你在大街上问一个有名的程序员,Go 有什么特别之处? 她很可能会告诉你 Go 最出名的是 通道 Channels 和 goroutine。 Go 的理论基础很大程度上是建立在 Hoare 的 CSP( 通信顺序进程 Communicating Sequential Processes )模型上的,该模型本身令人着迷且有趣,我坚信,到目前为止,它产生的收益远远超过了我们的预期。
CSP(和 π-演算)都使用通信作为核心同步原语,因此 Go 会有通道是有道理的。Rob Pike 对 CSP 着迷(有充分的理由)相当深 已经有一段时间了。(当时 和 现在)。
但是从务实的角度来看(也是 Go 引以为豪的),Go 把通道搞错了。在这一点上,通道的实现在我的书中几乎是一个坚实的反模式。为什么这么说呢?亲爱的读者,让我细数其中的方法。
你可能最终不会只使用通道
Hoare 的 “通信顺序进程” 是一种计算模型,实际上,唯一的同步原语是在通道上发送或接收的。一旦使用 互斥量 mutex 、 信号量 semaphore 或 条件变量 condition variable 、bam,你就不再处于纯 CSP 领域。 Go 程序员经常通过高呼 “通过交流共享内存” 的 缓存的思想 来宣扬这种模式和哲学。
那么,让我们尝试在 Go 中仅使用 CSP 编写一个小程序!让我们成为高分接收者。我们要做的就是跟踪我们看到的最大的高分值。如此而已。
首先,我们将创建一个 Game
结构体。
type Game struct {
bestScore int
scores chan int
}
bestScore
不会受到 互斥量 mutex 的保护!这很好,因为我们只需要一个 goroutine 来管理其状态并通过通道来接收新的分值即可。
func (g *Game) run() {
for score := range g.scores {
if g.bestScore < score {
g.bestScore = score
}
}
}
好的,现在我们将创建一个有用的构造函数来开始 Game
。
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
,我们假设所有错误都是致命的,并将获得的比分向下传递到通道。
func (g *Game) HandlePlayer(p Player) error {
for {
score, err := p.NextScore()
if err != nil {
return err
}
g.scores BenchmarkSimpleSet-8 3000000 391 ns/op
> BenchmarkSimpleChannelSet-8 1000000 1699 ns/o
>
无缓冲通道的情况与此类似,甚至是在争用而不是串行运行的情况下执行相同的测试。
也许 Go 调度器会有所改进,但与此同时,良好的旧互斥量和条件变量是非常好、高效且快速。如果你想要提高性能,请使用久经考验的方法。
通道与其他并发原语组合不佳
好的,希望我已经说服了你,有时候,你至少还会与除了通道之外的原语进行交互。标准库似乎显然更喜欢传统的同步原语而不是通道。
你猜怎么着,正确地将通道与互斥量和条件变量一起使用,其实是有一定的挑战性的。
关于通道的一个有趣的事情是,通道发送是同步的,这在 CSP 中是有很大意义的。通道发送和通道接收的目的是为了成为同步屏蔽,发送和接收应该发生在同一个虚拟时间。如果你是在执行良好的 CSP 领域,那就太好了。
实事求是地说,Go 通道也有多种缓冲方式。你可以分配一个固定的空间来考虑可能的缓冲,以便发送和接收是不同的事件,但缓冲区大小是有上限的。Go 并没有提供一种方法来让你拥有任意大小的缓冲区 —— 你必须提前分配缓冲区大小。 这很好,我在邮件列表上看到有人在争论,因为无论如何内存都是有限的。
What。
这是个糟糕的答案。有各种各样的理由来使用一个任意缓冲的通道。如果我们事先知道所有的事情,为什么还要使用 malloc
呢?
没有任意缓冲的通道意味着在 任何 通道上的幼稚发送可能会随时阻塞。你想在一个通道上发送,并在互斥下更新其他一些记账吗?小心!你的通道发送可能被阻塞!
// ...
s.mtx.Lock()
// ...
s.ch