原创分享 关于 Go1.14,你一定想知道的性能提升与新特性

unique_id · 2020年02月11日 · 最后由 penguinn 回复于 2020年03月18日 · 10490 次阅读
本帖已被设为精华帖!

Go 官方团队将在今年 2 月份发布 1.14 版本。相比较于之前的版本升级,Go1.14 在性能提升上做了较大改动,还加入了很多新特性,我们一起来看一下 Go1.14 都给我们带来了哪些惊喜吧!

1.性能提升

先列举几个 Go1.14 在性能提升上做的改进。

1.1 defer 性能 “异常” 牛逼

异常牛逼是有多牛逼呢?我们可以通过一个简单 benchmark 看一看。用例如下 (defer_test.go):

package main

import (
    "testing"
)

type channel chan int

func NoDefer() {
    ch1 := make(channel, 10)
    close(ch1)
}

func Defer() {
    ch2 := make(channel, 10)
    defer close(ch2)
}

func BenchmarkNoDefer(b *testing.B) {
    for i := 0; i < b.N; i++ {
        NoDefer()
    }
}

func BenchmarkDefer(b *testing.B) {
    for i := 0; i < b.N; i++ {
        Defer()
    }
}

我们分别使用 Go1.13 版本和 Go1.14 版本进行测试,关于 Go 多个版本的管理切换,推荐大家使用gvm,非常的方便。首先使用 Go1.13 版本,只需要命令:gvm use go1.13;之后运行命令:go test -bench=. -v,结果如下:

goos: darwin
goarch: amd64
pkg: github.com/GuoZhaoran/myWebSites/data/goProject/defer
BenchmarkNoDefer-4      15759076            74.5 ns/op
BenchmarkDefer-4        11046517           102 ns/op
PASS
ok      github.com/GuoZhaoran/myWebSites/data/goProject/defer   3.526s

可以看到,Go1.13 版本调用 defer 关闭 channel 的性能开销还是蛮大的,op 几乎差了 30ns。切换到 go1.14:gvm use go1.14;再次运行命令:go test -bench=. -v,下面的结果一定会亮瞎了小伙伴的双眼:

goos: darwin
goarch: amd64
pkg: github.com/GuoZhaoran/myWebSites/data/goProject/defer
BenchmarkNoDefer
BenchmarkNoDefer-4      13094874            80.3 ns/op
BenchmarkDefer
BenchmarkDefer-4        13227424            80.4 ns/op
PASS
ok      github.com/GuoZhaoran/myWebSites/data/goProject/defer   2.328s

Go1.14 版本使用 defer 关闭 channel 几乎 0 开销!

关于这一改进,官方给出的回应是:Go1.14 提高了 defer 的大多数用法的性能,几乎 0 开销!defer 已经可以用于对性能要求很高的场景了。

关于 defer,在 Go1.13 版本已经做了一些的优化,相较于 Go1.12,defer 大多数用法性能提升了 30%。而 Go1.14 的此次改进更是激动人心!关于 Go1.14 对 defer 优化的原理和细节,笔者还没有收集到参考资料,相信很快就会有大神整理出来,大家可以关注一下。关于 Go 语言 defer 的设计原理、Go1.13 对 defer 做了哪些改进,推荐给大家下面几篇文章:

1.2 goroutine 支持异步抢占

Go 语言调度器的性能随着版本迭代表现的越来越优异,我们来了解一下调度器使用的 G-M-P 模型。先是一些概念:

  • G(Goroutine): goroutine,由关键字 go 创建
  • M(Machine): 在 Go 中称为 Machine,可以理解为工作线程
  • P(Processor) : 处理器 P 是线程 M 和 Goroutine 之间的中间层 (并不是 CPU)

M 必须持有 P 才能执行 G 中的代码,P 有自己本地的一个运行队列 runq,由可运行的 G 组成,下图展示了 线程 M、处理器 P 和 goroutine 的关系。

GMP模型

Go 语言调度器的工作原理就是处理器 P 从本地队列中依次选择 goroutine 放到线程 M 上调度执行,每个 P 维护的 G 可能是不均衡的,为此调度器维护了一个全局 G 队列,当 P 执行完本地的 G 任务后,会尝试从全局队列中获取 G 任务运行 (需要加锁),当 P 本地队列和全局队列都没有可运行的任务时,会尝试偷取其他 P 中的 G 到本地队列运行 (任务窃取)。

在 Go1.1 版本中,调度器还不支持抢占式调度,只能依靠 goroutine 主动让出 CPU 资源,存在非常严重的调度问题:

  • 单独的 goroutine 可以一直占用线程运行,不会切换到其他的 goroutine,造成饥饿问题
  • 垃圾回收需要暂停整个程序(Stop-the-world,STW),如果没有抢占可能需要等待几分钟的时间,导致整个程序无法工作

Go1.12 中编译器在特定时机插入函数,通过函数调用作为入口触发抢占,实现了协作式的抢占式调度。但是这种需要函数调用主动配合的调度方式存在一些边缘情况,就比如说下面的例子:

package main

import (
    "runtime"
    "time"
)

func main() {
    runtime.GOMAXPROCS(1)

    go func() {
        for {
        }
    }()

    time.Sleep(time.Millisecond)
    println("OK")
}

其中创建一个 goroutine 并挂起, main goroutine 优先调用了 休眠,此时唯一的 P 会转去执行 for 循环所创建的 goroutine,进而 main goroutine 永远不会再被调度。换一句话说在 Go1.14 之前,上边的代码永远不会输出 OK,因为这种协作式的抢占式调度是不会使一个没有主动放弃执行权、且不参与任何函数调用的 goroutine 被抢占。

Go1.14 实现了基于信号的真抢占式调度解决了上述问题。Go1.14 程序启动时,在 runtime.sighandler 函数中注册了 SIGURG 信号的处理函数 runtime.doSigPreempt,在触发垃圾回收的栈扫描时,调用函数挂起 goroutine,并向 M 发送信号,M 收到信号后,会让当前 goroutine 陷入休眠继续执行其他的 goroutine。

Go 语言调度器的实现机制是一个非常深入的话题。下边推荐给读者几篇文章,特别值得探索学习:

1.3 time.Timer 定时器性能得到 “巨幅” 提升

我们先来看一下官方的 benchmark 数据吧。数据来源

Changes in the time package benchmarks:

name                      old time/op  new time/op  delta
AfterFunc-12              1.57ms ± 1%  0.07ms ± 1%  -95.42%  (p=0.000 n=10+8)
After-12                  1.63ms ± 3%  0.11ms ± 1%  -93.54%  (p=0.000 n=9+10)
Stop-12                   78.3µs ± 3%  73.6µs ± 3%   -6.01%  (p=0.000 n=9+10)
SimultaneousAfterFunc-12   138µs ± 1%   111µs ± 1%  -19.57%  (p=0.000 n=10+9)
StartStop-12              28.7µs ± 1%  31.5µs ± 5%   +9.64%  (p=0.000 n=10+7)
Reset-12                  6.78µs ± 1%  4.24µs ± 7%  -37.45%  (p=0.000 n=9+10)
Sleep-12                   183µs ± 1%   125µs ± 1%  -31.67%  (p=0.000 n=10+9)
Ticker-12                 5.40ms ± 2%  0.03ms ± 1%  -99.43%  (p=0.000 n=10+10)
Sub-12                     114ns ± 1%   113ns ± 3%     ~     (p=0.069 n=9+10)
Now-12                    37.2ns ± 1%  36.8ns ± 3%     ~     (p=0.287 n=8+8)
NowUnixNano-12            38.1ns ± 2%  37.4ns ± 3%   -1.87%  (p=0.020 n=10+9)
Format-12                  252ns ± 2%   195ns ± 3%  -22.61%  (p=0.000 n=9+10)
FormatNow-12               234ns ± 1%   177ns ± 2%  -24.34%  (p=0.000 n=10+10)
MarshalJSON-12             320ns ± 2%   250ns ± 0%  -21.94%  (p=0.000 n=8+8)
MarshalText-12             320ns ± 2%   245ns ± 2%  -23.30%  (p=0.000 n=9+10)
Parse-12                   206ns ± 2%   208ns ± 4%     ~     (p=0.084 n=10+10)
ParseDuration-12          89.1ns ± 1%  86.6ns ± 3%   -2.78%  (p=0.000 n=10+10)
Hour-12                   4.43ns ± 2%  4.46ns ± 1%     ~     (p=0.324 n=10+8)
Second-12                 4.47ns ± 1%  4.40ns ± 3%     ~     (p=0.145 n=9+10)
Year-12                   14.6ns ± 1%  14.7ns ± 2%     ~     (p=0.112 n=9+9)
Day-12                    20.1ns ± 3%  20.2ns ± 1%     ~     (p=0.404 n=10+9)

从基准测试的结果可以看出 Go1.14 time 包中 AfterFunc、After、Ticker 的性能都得到了 “巨幅” 提升。

在 Go1.10 之前的版本中,Go 语言使用 1 个全局的四叉小顶堆维护所有的 timer。实现机制是这样的:

Go旧版本timer实现机制

看图有些抽象,下面用文字描述一下上述过程:

  • G6 调用函数创建了一个 timer,系统会产生一个 TimerProc,放到本地队列的头部,TimerProc 也是一个 G,由系统调用
  • P 调度执行 TimerProc 的 G 时,调用函数让出 P,G 是在 M1 上执行的,线程休眠,G6 阻塞在 channel 上,保存到堆上
  • 唤醒 P,获得 M3 继续调度执行任务 G1、G4,执行完所有任务之后让出 P,M3 休眠
  • TimerProc 休眠到期后,重新唤醒 P,执行 TimerProc 将 G6 恢复到 P 的本地队列,等待执行。TimerProc 则再次和 M1 休眠,等待下一次创建 timer 时被唤醒
  • P 再次被唤醒,获得 M3,执行任务 G6

对 Timer 的工作原理可能描述的比较粗略,但我们可以看出执行一次 Timer 任务经历了好多次 M/P 切换,这种系统开销是非常大的,而且从全局唯一堆上遍历 timer 恢复 G 到 P 是需要加锁的,导致 Go1.10 之前的计时器性能比较差,但是在对于计时要求不是特别苛刻的场景,也是完全可以胜任的。

Go1.10 将 timer 堆增加到了 64 个,使用协程所属的 ProcessID % 64 来计算定时器存入的相应的堆,也就是说当 P 的数量小于 64 时,每个 P 只会把 timer 存到 1 个堆,这样就避免了加锁带来的性能损耗,只有当 P 设置大于 64 时才会出现多个 P 分布于同一个堆中,这个时候还是需要加锁,虽然很少有服务将 P 设置的大于 64。

Go1.10对timer的优化

但是正如我们前边的分析,提升 Go 计时器性能的关键是消除唤醒一个 timer 时进行 M/P 频繁切换的开销,Go1.10 并没有解决根本问题。Go1.14 做到了!直接在每个 P 上维护自己的 timer 堆,像维护自己的一个本地队列 runq 一样。

Go1.14对计时器的优化

不得不说这种设计实在是太棒了,首先解决了最关键的问题,唤醒 timer 不用进行频繁的 M/P 切换,其次不用再维护 TimerProc 这个系统协程了 (Go1.14 删除了 TimerProc 代码的实现),同时也不用考虑因为竞争使用锁了。timer 的调度时机更多了,在 P 对 G 调度的时候,都可以检查一次 timer 是否到期,而且像 G 任务一样,当 P 本地没有 timer 时,可以尝试从其他的 P 偷取一些 timer 任务运行。

关于 Go1.14 time.Timer 的实现,推荐给大家 B 站上的视频,我从中受益很多:Go time.Timer 源码分析

2. 语言层面的变化

2.1 允许嵌入具有重叠方法集的接口

这应该是 Go1.14 在语言层面上最大的改动了,如下的接口定义在 Go1.14 之前是不允许的:

type ReadWriteCloser interface {
    io.ReadCloser
    io.WriteCloser
}

因为 io.ReadCloser 和 io.WriteCloser 中 Close 方法重复了,编译时会提示:duplicate method Close。Go1.14 开始允许相同签名的方法可以内嵌入一个接口中,注意是相同签名,下边的代码在 Go1.14 依然不能够执行,因为 MyCloser 接口中定义的 Close 方法和 io.ReadCloser 接口定义的 Close 方法的签名不同。

type MyCloser interface {
    Close()
}

type ReadWriteCloser interface {
    io.ReadCloser
    MyCloser
}

将 MyCloser 的 Close 方法签名修改为:

type MyCloser interface {
    Close() error
}

这样代码就可以在 Go1.14 版本中 build 了!轻松实现接口定义的重载。

2.2 testing 包的 T、B 和 TB 都加上了 CleanUp 方法

在并行测试和子测试中,CleanUp(f func()) 非常有用,它将以后进先出的方式执行 f(如果注册多个的话)。

举一个例子:

func TestSomeing(t *testing.T) {
    t.CleanUp(func() {
        fmt.Println("Cleaning Up!")
    })

    t.Run(t.Name(), func(t *testing.T) {

    })
}

可以在 test 或者 benchmark 结束后调用 t.CleanUp 或 b.CleanUp 做一些收尾统计工作,非常有用!

2.3 添加了新包 hash/maphash

这个新包提供了字节序列上的 hash 函数。这些哈希函数用于实现哈希表或其他的数据结构,这些哈希表或其他数据结构需要将任意字符串或字节序列映射为整数的均匀分布。这些 hash 函数具有抗冲突性,但不是加密安全的。

2.4 WebAssembly 的变化

对 WebAssembly 感兴趣的小伙伴注意了,Go1.14 对 WebAssembly 做了如下改动:

  • 可以通过 js.Value 对象从 Go 引用的 Javascript 值进行垃圾回收
  • js.Value 值不再使用 == 操作符来比较,必须使用 Equal 函数
  • js.Value 增加了 IsUndefined,IsNull,IsNaN 函数

2.5 reflect 包的变化

reflect 在 StructField 元素中设置了 PkgPath 字段,StructOf 支持使用未导出字段创建结构类型。

2.6 语言层面其他改动

Go1.14 在语言层面还做了很多其他的改动,下面列举一些 (不是很全面):

代码包 改动
crypto/tls 移除了对 SSLv3 的支持;默认开启 TLS1.3,通过 Config.MaxVersion 字段配置其版本而不是通过 DEBUG 环境变量进行配置
strconv NumError 类型新增加了一个 UnWrap 方法,可以用于找到转换失败的原因,可以用 Errors.Is 来查看 NumError 值是否是底层错误:strconv.ErrRange 或 strconv.ErrSyntax
runtime runtime.Goexit 不再被递归的 panic/recover 终止
runtime/pprof 生成的 profile 不再包括用于内联标记的伪 PC。内联函数的符号信息以 pprof 工具期望的格式编码
net/http 新的 Header 方法的 Values 可用于获取规范化 Key 关联的所有制,新的 Transport 字段 DialTLSContext 可用于指定可选的以非代理 https 请求创建 TLS 连接的 dail 功能
net/http/httptest Server 的字段 EnableHTTP2 可用于在 test server 上支持 HTTP/2
mime .js 和.mjs 文件的默认类型是 text/javascript,而不是 application/javascirpt
mime/multipart 新的 Reader 方法 NextRawPart 支持获取下一个 MIME 的部分,而不需要透明的解码引用的可打印数据
signal 在 Windows 上,CTRL_CLOSE_EVENT、CTRL_LOGOFF_EVENT、CTRL_SHUTDOWN_EVENT 将生成一个 syscall.SIGTERM 信号,类似于 Control-C 和 Control-Break 如何生成 syscall.SIGINT 信号
math 新的 FMA 函数在浮点计算 x*y + z 的时候,不对 x*y 计算进行舍入处理(几种体系结构使用专用的硬件指令来实现此计算,以提高性能)
math/bits 新的函数 Rem,Rem32,Rem64 即使在商溢出时也支持计算余数
go/build Context 类型有了一个新字段 Dir,用于设置 build 的工作目录
unicode 整个系统中的 unicode 包和相关支持已经从 Unicode1.0 升级到了 Unicode12.0,增加了 554 个新字符,其中包括 4 个脚本和 61 个新 emoji

3. 工具的变化

关于 Go1.14 中对工具的完善,主要说一下 go mod 和 go test,Go 官方肯定希望开发者使用官方的包管理工具,Go1.14 完善了很多功能,如果大家在业务开发中对 go mod 有其他的功能需求,可以给官方提 issue。

go mod 主要做了以下改进:

  • incompatiable versions:如果模块的最新版本包含 go.mod 文件,则除非明确要求或已经要求该版本,否则 go get 将不再升级到该模块的不兼容主要版本。直接从版本控制中获取时,go list 还会忽略此模块的不兼容版本,但如果由代理报告,则可能包括这些版本。
  • go.mod 文件维护:除了 go mod tidy 之外的 go 命令不再删除 require 指令,该指令指定了间接依赖版本,该版本已由主模块的其他依赖项隐含。除了 go mod tidy 之外的 go 命令不再编辑 go.mod 文件,如果更改只是修饰性的。
  • Module 下载:在 module 模式下,go 命令支持 SVN 仓库,go 命令现在包括来自模块代理和其他 HTTP 服务器的纯文本错误消息的摘要。如果错误消息是有效的 UTF-8,且包含图形字符和空格,只会显示错误消息。

go test 的改动比较小:

  • go test -v 现在将 t.Log 输出流式传输,而不是在所有测试数据结束时输出。

4. 生态建设

关于 go 语言的生态建设主要说一下 go.dev,2019年11月14日Go 官方团队在 golang-nuts 邮件组宣布 go.dev 上线。我们初次使用 go.dev,发现它提供了 godoc.org 的文档,界面更加友好。godoc.org 也给出声明将重定向到 go.dev,可以看出,Go 官方团队会将 go.dev 作为生态建设的重点。

pkg.go.dev 是 go.org 的配套网站,里边有精选用例和其他资源的信息,提供了 godoc.org 之类的 Go 文档,但它更懂模块,并提供了有关软件包先前版本的信息,它还可以检测并显示许可证,并具有更好的搜索算法。

推荐大家使用!

5. 未来展望

我们先来说说泛型吧!Go 语言因为一直缺少泛型被很多开发者诟病。语言的设计者需要在编程效率、编译速度和运行速度三者进行权衡和选择,泛型的引入一定会影响编译速度和运行速度,同时也会增加编译器的复杂度,所以社区在考虑泛型时也非常谨慎。Go 语言团队认为加入泛型并不紧急,更重要的是完善运行时机制,包括 调度器、垃圾收集器等功能。但是开发者的呼声日益强烈,Go 官方也承诺会在 2.0 加入泛型。小道消息,2020 年末,Go 语言可能会推出泛型,大家期待一下!关于 Go 语言为什么没有泛型,推荐大家一篇文章:为什么 Go 语言没有泛型 · Why's THE Design?

再来说说 Go 语言的错误处理吧。try proposal 获得了很多人的支持,但是也有很多人反对,大家可以关注一下 issue #32825。结论是:Go 已经放弃了这一提案!这些思想还没有得到充分的发展,尤其考虑到更改语言的实现成本时,所以有关枚举和不可变类型,Go 语言团队最近也是不给予考虑实现的。

Go1.14 也有一些计划中但是未完成的工作,Go1.14 尝试优化页分配器(page allocator),能够实现在 GOMAXPROCS 值比较大时,显著减少锁竞争。这一改动影响很大,能显著的提高 Go 并行能力,也会进一步提升 timer 的性能。但是由于实现起来比较复杂,有一些来不及解决的问题,要 delay 到 Go1.15 完成了。

展望 Go 语言的未来发展,官方肯定会努力将调度器、运行时和垃圾回收做的更好,Go 语言的性能也会越来越出众。对于工具链会不断丰富调整相应功能,为开发者提供方便。同时,Go 也会不断完善其生态,工具包、社区成熟的应用越来越多。让我们一起期待吧!

更多原创文章干货分享,请关注公众号
  • 加微信实战群请加微信(注明:实战群):gocnio
astaxie 将本帖设为了精华贴 02月11日 21:19

Go 1.14 改进东西还是非常多,而且泛型已经进入 draft 阶段,💪 💪

非常完整的分享,大家也可以结合2020 年 Go 的一些发展计划(Go 1.14 && Go 1.15) 回顾视频来看。

mai_yang 回复

赶紧发一个帖子,我好转发呢

很期待 1.15。golang 确实越来越好了。对一个现代语言来说最重要的是有可持续的团队维护和客户支持,go 基本具备了,未来可期。

moss GoCN 每日新闻 (2020-02-12) 中提及了此贴 02月12日 12:00

感觉最近几个版本变化都不大, 正如刚离职的 go team 成员 Brad Fitzpatrick 说的, go 已经度过快速发展的时期了. 饱受诟病的 3 大问题版本管理, 错误处理, 泛型, 版本管理算是从 1.11 开始到现在经历了 4 个版本解决了. 错误处理的 try proposal 被否决, 这个问题基本被搁置了, 遥遥无期. 泛型还在 draft 阶段, 还没有个明确的规划.

defer 性能提升的原因是在编译期会将 defer 内联,参考https://rakyll.org/inlined-defers/ ,译文https://www.pengrl.com/p/20023/

什么时候有泛型?类似这种的: func Test《T》 (arg: T) bool where T: Debug + PartialEq + 'static {##########}

类似 C++ 的泛型实现对运行效率应该是没影响的吧

leaxoy-github 回复

会影响编译效率.

不错,不错!感谢分享

写的不全啊。go mod 支持的这些都没写

需要 登录 后方可回复, 如果你还没有账号请点击这里 注册