• 0

  • 462

Golang1.14性能提升与新特性

3星期前

1.性能提升

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,可以参考www.hi-linux.com/posts/20165… ,非常的方便。首先使用 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 的关系。

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。实现机制是这样的:

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

  • 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。 但是正如我们前边的分析,提升Go计时器性能的关键是消除唤醒一个 timer 时进行 M/P 频繁切换的开销,Go1.10并没有解决根本问题。Go1.14做到了!直接在每个P上维护自己的timer堆,像维护自己的一个本地队列runq一样。 不得不说这种设计实在是太棒了,首先解决了最关键的问题,唤醒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在语言层面还做了很多其他的改动,下面列举一些(不是很全面):

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?[7]

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

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

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

原作者: gocn.vip/topics/9611

免责声明:文章版权归原作者所有,其内容与观点不代表Unitimes立场,亦不构成任何投资意见或建议。

程序员

462

相关文章推荐

未登录头像

暂无评论