本来呢,Golang(以下简称 Go,与平台同名)是一门相对新颖、有点特色、有活力的编程语言。但是呢,Go 粉丝里总有些黑粉,破化力极强。众所周之,Go 粉丝爱 Go 之情溢于言表。夸一夸自己喜爱的平台、语言,正常操作,无可厚非。然而、却有黑粉到处碰瓷,一抬一踩。甚至于,虚假宣传,不惜用明显错误的程序假造性能测试!(我没有怀疑某些人数据造假,已经是很客气了。依在下的观感,Github 等社区是很少有人在性能方面吹虚 Go 的。)

性能碰瓷(测试造假)常见伎俩

  • 在循环中插入故意多余的 yield、sleep。强行拉低其他语言的协程性能。(碰瓷各类语言时都常用。)
  • 乱插 gc.collect。干扰 GC 正常运行,并浪费大量时间。(碰瓷 Java/C# 时常用。)
  • 使用功能不同的格式化/字符串转换。Go 用简单的 C-style 转换,其他语言/平台则用复杂的国际化/本地化格式化/字符串转换,以此制造差距。(这点不知道的话,情有可原。被指出后,耍无赖就……)(碰瓷 Java/C# 时常用。)
  • 错误编程,造成大量无意义的容器对象复制。(碰瓷 C++ 时常用。)
  • 错误编程,造成大量无意义的拆装箱。(碰瓷 Java/C# 时常用。)
  • 拿别人的线程和自己的协程比。得出其他语言/平台多线程/异步差劲的结论。
  • 隐瞒资源消耗,不提并发能力。Goroutine 的性能是需要系统上大量资源来支持的。其他语言/平台采用更常规的默认配置,甚至不允许如此激进的调度和资源耗费。(类比一下就是,拿自旋锁比赢其他同步机制,就说其他同步机制垃圾。其实加个并发能力的测试,就露馅了。)
  • 其他语言/平台无需使用 channel (或有更优选择)的情况强行用 channel,并用不合适测试例的默认配置。(不是说强用 channel 就错,注意区分测试目的。)
  • Go 用新的 SDK/运行时。而其他语言用老旧过时的 SDK/运行时。
  • 没用 Release 配置和优化编译。
  • 运行测试时附加调试器。
  • ……(看到再加,欢迎留言)

真相

这是流传比较广的一个例子,我来揭露一下。

测试环境:

我故意把测试代码写得很相像,方便大家对比。上代码。

Go

Java(待补)

C#

Go 运行结果

版本:go1.16.3 windows/amd64

是的,你没看错。花了 13 分钟。原因是内存爆炸了。

CPU、内存占用简直离谱!
我实在不忍看下去了。错过了峰值的截图。

可以想见,服务器上的其他程序会受到极严重性能负面影响。

内存不爆掉话,目测要 10 秒左右,还不错。(哪位同学有 120+ GB 内存主机的可以一试。回头请告诉我结果。)这种激进的线程/协程调度和怠惰的 GC 支撑起的性能「优势」(较短运行时间)是以极高的资源消耗为代价的。这就人们大多数时候都选择不这么做的原因。

Java 运行结果(待补)

C# 运行结果

版本:6.0.100-preview.2.21155.3

这份运行结果,还是在受到刚刚 Go 的余波影响跑出来的。

怎么还有额外的内存 uncommit 不掉…… 可耻的 Windows。

我们看到,内存消耗只有 Go 的 1/10 左右。累计 CPU 时间是差不多的。C# 的 CPU 占比只有 Go 的三分之一左右,但时间跨度约是 Go 的两倍半。由于没有受到内存不足的惩罚,10x 并发测试中,只花了 20 秒左右就完成了任务。总体而言,.NET 的调度更加平稳,GC 更加勤快。这对高并发是为极有利的。

重启系统以后的运行结果:

平均数值上稍稍好了一点点,主要是稳定多了。

如果使用 TaskCompletionSource 或 Task 在协程间传递消息,则 C# 在 CPU 占比、内存消耗和运行时间三方面都完胜 Go。这样比不公平,但另一方面 .NET 也可以用定制的 Channel。能达到同等运行时间,且 CPU 占比和内存消耗均低于 Go(说明并发能力高),后续文章我会进一步介绍。 不过这里重点不在乱比性能,而是要明白各种配置的优劣,配合任务性质的才是好的。

对于一般业务场景,.NET 协程(Task/ValueTask)的综合性能远高于 Go 协程(Goroutine)。这是多个因素导致的。其中一个重要原因是 Go 协程内存消耗大,也就有更多缓存没命中,甚至要换页进出内存。

Go 的优点

Goroutine 本质上是一种优化过的有栈协程。因此具备有栈协程对无栈协程的优势。有栈协程赋予同步过程中 await 异步协程的能力。无栈协程没有这种功能,一般需要利用操作系统功能阻塞等待。(顺带一提,不依赖操作系统的解法主要有三类。一是 Emscripten 中的 Asyncify,其原理是调用堆栈的 unwind 和 rewind(rewind 是重点),其中算法类似于结构化异常处理中算法,性能开销较大,实现繁琐,但适用性最广。二是自旋/轮询,简单粗暴,但性能开销极大,且不适用于单线程执行模型。三是 V8 的 %PerformMicrotaskCheckPoint,功能受限且仅适用于单线程执行模型。)

Go 不需要 async/await。我们注意到,Go 的 async 函数和正常函数没有区别。而无栈协程往往要将 async 函数编译为状态机。这就是 Go 不需要 async/await 的真正原因。即既无需 async 指示编译器将哪些函数编译为状态机。又(站在无栈协程的角度)所有 await 都是隐式的,也不需要指明。剩下的唯一情况是子过程的 detach(非 join) 模式执行。这正好是 go 关键字的用途,说穿了,毫无神奇之处。这对应于 C# 中无 await 调用 async 函数,并直接 ignore 返回的 Task 对象,或传给 Task.Run 。(对比示例代码。)

.NET 提供的 Channel API 存在影响性能的设计缺陷,Go 至少没犯这种低级错误。最重要的是 Go 的 chan 有语言整合程度的支持,用起来非常便捷。C# 的代码啰嗦多了。Java 就更啰嗦了。

defer 语法糖不错。(可惜当年,大家讨论过以后 C# 没有加,我在社区里说反对加,现在稍稍有点后悔。)

Channel 的本质

Channel = Buffer + Semaphore

此处 Buffer 一般用 Queue。看清了这个,就知道怎么把 Channel 当 Semaphore 用了。哈哈。(但是性能肯定比直接用 Semaphore 要差。)

Semaphore 上比较难做文章,这里就吐槽一下。同时支持同步等待和异步等待(指使用 await 等待)的 Semaphore 实现起来稍稍麻烦。大多数语言/平台提供的默认实现只支持一种等待方式。JavaScript 的这个 Semaphore 支持异步等待。C# 的经典版 Semaphore 支持同步等待(SemaphoreSlim 两者都支持)。Go 的 chan 和 sync.WaitGroup(= Semaphore)可以认为支持两者(有栈协程特性)。

Buffer 的部分花样就比较多了。优化得好可以提速不少。特别是观察到,大多是业务场景里,Channel 的数据不会积存,能存几个元素的 Buffer 就够用。这时可不另设堆上空间,随 Channel 存放即可。

Go 如何扬长避短?

首先,谨慎使用 go 关键字。明白原理后(虽然本文没有写),我们能发现,没有必要为 IO 型任务启动新的 goroutine。应当在任务具有一定的计算载荷,且需要运行一段时间(例如 100 ms 以上)时,才使用 go 启动新协程。这样也要求我们尽可能避免引发阻塞的调用。(如果库的实现是阻塞的,那就只能被迫妥协了。)

有栈协程的其他缺点也要注意。例如,因为用 Task 封装异步结果不是必须的。需要这种功能是就需要用 chan 或 sync 里类型了(例如:示例代码里借助 WaitGroup)。但我们知道,这比 Task 的性能要差。所以,可以考虑将 API 设计为批处理(batching/chunky)的形式,避免零碎的调用,发挥 Channel 的真正优势。或在 hot path 中用更基础的同步原语实现。

Go 的标准库并没有提供一套可取消 API,这可谓是一大缺憾。因为理论上讲,有栈协程更需要避免阻塞调用,可取消 API 则是一大利器。一定规模以上的长期项目,应该自行准备一套可取消 API 的支持库。(先把支持库备好,接口改好,不是要立即写好尊重取消 token 状态的业务代码实现。)长远来看,收益很大。既然大家都开源,我建议从 .NET 好好抄一部分来。

辟谣/科普

调度策略激进与否,对性能测试除了有直接影响还有间接影响。例如,激进调度和自旋锁跑满 100% CPU,CPU 不降频。而正常调度和内核可等待对象,一般跑不满 100% CPU(否则早被人骂死了),CPU 可能会降频。一来二去,拉开运行时间的差距,但并不能说明前者更好。没有免费的午餐啊!

支持调用系统同步 API 的语言/平台是不可能需要 async/await all the way 的,例如 C#。污蔑啊。实际上,测试代码里就有例子。Task 是有 Wait 方法可供调用的。退一万步,还有 ContinueWith 和 then(例如 JavaScript)。

吐槽

服务程序用 Go 开发,往往意味着要花更多钱在服务器上!当然,这对像 Google 那样占据舆论高地的云服务提供商来说,真是天大的好事!

我写这篇文章,还原真相外,目的让大家用好 Go,别被带偏了。(当然,我说的也不是全部真相。我建议大家自己动手试一试,找出真相,不要人云亦云的。)但毕竟扫了一些人的兴,甚至影响了一些人的财路。有些知乎作者说得比较委婉,但这不是我的风格。反正我有些憋不住,不吐不快。欢迎在评论区留言讨论。(请尽情给出建议和指出本文错漏之处。)