Contents
  1. 1. Go语言 1.14版本发布的新特性
    1. 1.1. GO MOD用于生产环境
      1. 1.1.1. Go1.5及以前:GOPATH
      2. 1.1.2. Go1.6到1.10:vendor
      3. 1.1.3. Go1.11至今:
    2. 1.2. 具有重叠方法集的接口类型
      1. 1.2.1. 关于使接口类型允许嵌入重叠方法集的提议
      2. 1.2.2. 本质:解决菱形问题
    3. 1.3. 提升defer的性能
      1. 1.3.1. Go1.14以前的defer
      2. 1.3.2. Go1.14做出的改变:
    4. 1.4. goroutine实现异步抢占
      1. 1.4.1. 新信号SIGURG
      2. 1.4.2. 使用sysmon去检测抢占
      3. 1.4.3. 发送SIGURG信号
      4. 1.4.4. 接收到SIGURG信号之后

Go语言 1.14版本发布的新特性

在2020年2月25日, Go语言的1.14版本发布了, 在Go语言官方的博客上Alex Rakoczy写道:

“今天, Go语言团队非常开心的宣布Go 1.14版本的发布, 您可以从下载页面上下载它”

“这个版本的一些亮点包括:”

  • 现在, Go Module已经准备好用于生产环境了, 我们鼓励所有的用户使用Go Mod进行依赖性管理
  • 嵌入具有重叠方法集的接口
  • 改进defer的性能
  • goroutine异步抢占
  • 页面分配器效率更高
  • 内置的time中的计时器的效率更高了

我们可以看到Alex主要提到了六点, 接下来我将分别讲述这六点

GO MOD用于生产环境

对于每一个开发者而言,程序的依赖管理都是不可缺少的,因为不管我们使用什么语言,都是站在巨人的肩膀上进行开发。全球的开发者都会互相帮助,这也是开源精神很重要的一部分。
除了Go之外的其他语言,Java有maven,Python有pip,PHP有compose,Node.js有npm等等。对于Go语言而言,就是在1.6版本之后出现的vendor和1.11版本之后的go module了。

Go1.5及以前:GOPATH

在Go1.5及以前,都是依靠最简单的GOPATH的方式进行代码导入的。
通过设置全局变量GOPATH的方式来导入本地的代码,而且还存在和内置库冲突的风险。
这个时期的GOPATH不能称之为包管理,因为根本没有依赖性的概念和版本的概念。是非常不方便、不成熟的一种方式。

Go1.6到1.10:vendor

vendor的本意是供应商或者小贩,在Go1.6时推出了使用vendor来进行应用依赖性管理的功能。

vendor本身是应用根目录下的一个文件夹,而文件夹内用于存放应用的一些第三方依赖,在build我们的应用时,可以自动从vendor中进行搜索和导入。这样子应用和依赖可以整体进行打包,成为了真正意义上的包管理工具。

接下来进行推测:既然我们自己构建的应用会有vendor,那么意味着我们依赖的第三方的包也会有同样的情况产生,也就是我们的vendor目录下其实会有多级vendor的出现。

vendor的加载流程:

  • 包根目录下的vendor
  • 包根目录向上的最近的一个vendor
  • GOPATH src/下的vendor
  • GOROOT src/
  • GOPATH src/

但是只依靠vendor还是没办法管理依赖的版本,这个时候很多第三方的工具出现了:glide,godep还有govendor等等…

Go1.11至今:

Go1.11版本开始,实验性的出现了可以不用定义GOPATH的功能,而且官方有go mod进行支持,到了Go1.12更是正式化了这一功能。

很明显的一个标志是源于全局变量GO111MODULE,这个全局变量用于设置Go1.11版本以来对于go mod功能的开关:

  • GO111MODULE=off:关闭MOD
  • GO111MODULE=on,开启MOD,忽略GOPATH和vendor,只根据go.mod文件进行下载依赖
  • GO111MODULE=auto,该项目在GOPATH src/外且根目录有go.mod时,才开启模块支持

但是go mod的功能一直不是很稳定,我们团队之前的工程模板就会发生在Go1.13版本下使用go mod导致无法正常编译的错误,不得已更换了依赖(升级到使用uber体验还是很不错的)。

但是Go1.14大声的告诉我们:go mod已经完全可以使用在生产环境了!(虽然在这之前我在公司的项目就一直用的是go mod来着…)


具有重叠方法集的接口类型

官方原文的描述:

  • Per the overlapping interfaces proposal, Go 1.14 now permits embedding of interfaces with overlapping method sets: methods from an embedded interface may have the same names and identical signatures as methods already present in the (embedding) interface. This solves problems that typically (but not exclusively) occur with diamond-shaped embedding graphs. Explicitly declared methods in an interface must remain unique, as before.

  • 根据重叠接口类型提议,Go1.14现在已经允许使用具有重叠的方法集的嵌入接口类型:被嵌入的接口类型可能具有和嵌入的接口类型具有相同名称的方法。这解决了最经典的菱形嵌入问题(虽然导致这个问题出现的不一定是菱形拓扑结构)。和之前一样,在接口中明确声明的方法必须保证唯一性。

在这个描述中,有两点是值得注意的:一是有关于这个改变的提议,另一个则是所谓的菱形嵌入(继承)问题。

关于使接口类型允许嵌入重叠方法集的提议

这个提议源于golang的ISSUE-6977,作者是Robert Griesemer。提议的传送门

以下是提议的主要内容(自己翻译的,可能不准确):

目前,在Go语言的接口类型章节中是这样陈述的:

  • 一个接口类型T可以通过嵌入一个另一个接口类型E来代替自身的接口方法声明。这个过程称为嵌入E到T中。它会将E中所有的导出方法和未导出方法都添加到接口T中。

而我们现在希望将其改为:

  • 一个接口类型T可以通过嵌入一个另一个接口类型E来代替自身的接口方法声明。这个过程称为嵌入E到T中。接口T的方法集为其显式声明的方法集和嵌入的方法集的并集。

并且添加以下描述:

  • 这个并集仅包括所有的方法集的所有方法(导出的和未导出的)一次,并且相同名称的方法必须保持相同的签名。

这个提议可以这样去理解,Go语言的面向对象的方式是以组合(嵌入)的方式实现的。和继承一样,存在着对于成员方法和字段的合并问题。

Go语言以前的方针是不允许任何同名的方法出现在同一个接口类型的合并过程中。哪怕是完全相同的两个方法也不可以。

在这次的修改后,这个方针改变为,可以有相同名称的方法出现在合并的过程中,但是必须函数的签名完全一致。

Go语言中函数签名包括的几部分:方法接受者(receiver),方法名(name),参数列表(param_list)和返回值列表(return_list),四者唯一确定一个函数签名

在同一个接口中,接受者是一致的,也就是要保证方法名,参数列表和返回值列表完全一致。这样在合并的过程中才不会出现报错。

我们可以看出,这样的一个举措是完全和重载背道而驰的做法,也算是一个特色了。(重载在遇到重名方法的时候,是允许不同的函数签名同时存在的)

本质:解决菱形问题

菱形问题

在面向对象的语言中,菱形继承问题是不可避免的一个话题,和上面的图一样形象的问题:一个类(类型)D会继承(组合)来自不同方向的不同的类(类型),但是有可能B和C都具有同样的方法,这样调用D的方法时会产生一个究竟走哪条调用链的问题。

比如A有方法a,B和C分别重写了方法a,成为a-B和a-C,这个时候D继承了B和C,在调用a-D的时候,就会产生应该调用a-B还是a-C的问题。

这个问题在C++,Python中都不同的解决方案。分别是虚继承和MRO(Method Resolution Order)+C4算法。而这次Go1.14的重叠方法集应该算是Go语言对于菱形问题的解决方案。


提升defer的性能

原文描述:

  • This release improves the performance of most uses of defer to incur almost zero overhead compared to calling the deferred function directly. As a result, defer can now be used in performance-critical code without overhead concerns.
  • 这个版本提升了defer大多数用法的性能,接近了几乎0开销。因此,现在可以将defer用于高性能场景了。

先来看一下defer在Go1.14以前是如何实现的:

Go1.14以前的defer

defer

在源码的src/src/runtime/runtime2.go:_defer中定义了defer的数据结构:

1
2
3
4
5
6
7
8
9
10
// go1.12.13
type _defer struct {
siz int32 // 参数和结果的内存大小
started bool
sp uintptr // 函数栈的指针
pc uintptr // 程序计数器
fn *funcval // defer传入的函数地址
_panic *_panic // 对应的panic数据结构, 用于触发延迟调用
link *_defer // 以链表结构连接多个defer
}

从上面的数据结构来看, 可以很明显的看出一个goroutine内的defer是以链表的形式链接在一起的.

src/src/runtime/panic.go中定义了deferproc和deferreturn两个函数:

  • runtime.deferproc: 负责创建新的defer调用
  • runtime.deferreturn: 负责在调用的函数结束时调用所有的延迟调用

runtime.deferproc:

本函数会为每一个defer关键字创建一个新的runtime._defer结构体, 并且设置它的函数指针fn, 程序计数器pc和栈指针sp并且将相关的参数copy到相邻的内存空间中.

本函数的末尾由return0()函数结尾, 为了避免无限递归调用deferreturn()函数.

创建好的新的runtime._defer结构体会被追加到goroutine的_defer链表的最前面, 这也就是为什么后声明的defer会被率先调用的原理.

runtime.deferreturn:

本函数会从goroutine的_defer链表的最前面取出一个_defer结构体并调用runtime.jmpdefer函数传入需要执行的函数和参数.

具体的流程:

  • 获取当前的goroutine
  • 获取当前goroutine的_defer链表头指针, 判断是否为空, 为空则直接返回
  • 获取调用者的栈指针, 若调用者的栈指针和要执行的defer的栈指针不同则直接返回
  • switch-case defer.siz
    • 如果defer占用的内存是0, 便什么都不做
    • 如果defer占用的内存是一个指针类型的大小, 便取出d中的参数到arg0
    • default: 直接将d中的参数取出到arg0
  • 取出defer要调用的函数fn
  • 修改当前goroutine的_defer链表
  • 释放当前处理的defer的内存空间
  • 使用jmpdefer将获取到的参数传入fn进行调用, 并且调用结束后返回本函数

而deferreturn函数在什么时候才会运行呢? 实际上在编译的阶段, deferreturn函数会被插入到调用defer的函数返回之前.

总结:

在Go1.14版本之前, defer的整体逻辑是这样的:

  • 编译阶段:
    • 将defer关键字转换为deferproc()
    • 在调用defer关键字的函数返回前插入deferreturn()
  • 运行时:
    • deferproc()函数会创建一个新的_defer加入到goroutine的_defer链表中.
    • deferreturn()函数会从goroutine中取出_defer并依次执行

Go1.14做出的改变:

Go1.14新加入了Open-Coded defer类型,编译器会将defer中的函数直接插到函数的尾部,避免了runtime的deferproc和复制参数的操作,免除了在没有runtime判断下的deferreturn调用,如果有runtime判断逻辑,则deferreturn不会进行jmpdefer尾递归调用,而是直接在一个循环里遍历执行,这样效率流失的部分就抓了一部分回来了。

  • 编译时直接将defer要调用的函数插入原函数的尾部, 避免了调用deferproc和建立_defer链表和对于每一个_defer的参数复制
  • deferreturn时将多个_defer在一个循环中执行, 而不是进行多次的jmpdefer递归调用

网上也有了很多的benchmark测评,结果是Go1.14比Go1.13的defer的速度快了不少,提升了大概35ns左右,得到的结论是:Go1.14中用不用defer对于性能影响甚微。

更详细的细节在这里


goroutine实现异步抢占

Go语言其实在之前的版本中就已经实现了抢占调度, 不管是陷入到大量的计算还是syscall, 大多可被sysnmon扫描到并进行抢占. 但是还是有些场景是无法抢占成功的. 比如轮询计算: for { i++ }等, 这类操作无法进行newstack, morestack, syscall等操作, 所以无法检测stackguard0 = stackpreempt.

GO语言团队已经意识到了协程之间的抢占是一个问题, 所以在1.14版本中加入了基于信号的协程调度抢占. 原理是这样的: 首先注册绑定SIGURG信号以及处理方法runtime.doSigPreempt, sysmon会间隔性的检测超时的p, 然后发送信号, m收到信号之后会休眠执行的goroutine, 并且进行重新调度.

首先写一个小例子看一下Go1.14以前的版本是怎么处理极端调度情况的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
package main

import (
"runtime"
)

func main() {
runtime.GOMAXPROCS(1)

go func() {
panic("already call")
}()

for {}
}

首先锁定p的数量为1, 以规避并行计算带来的影响, 然后开一个协程, 一旦轮到这个协程执行, 则会直接panic. 但是由于主协程一直不断轮询, 导致程序一直无法退出.

但是如果在Go1.14版本, 则是可以执行到panic的. 如果要Go1.13版本能够执行到panic的话, 第一个方案就是放开对于p的限制. 第二就是执行一个系统调用, 再就是执行复杂函数产生morestack扩栈. 而Go1.14则是通过发送信号的形式来引起中断.

新信号SIGURG

src/runtime/signal_unix.go, 新定义了用于抢占的信号:

1
const sigPreempt = SIGURG

SIGURG信号本身的作用是通知应用程序带外数据到达. Go团队选用这个信号的原因是因为他们认为这个信号不太可能发挥它原本的作用了, 他们觉得SIGIO也是一个不错的选择, 但是SIGIO更有可能用于它原本的用途, 因此SIGURG在Go1.14中是作为抢占信号存在的.

在定义好抢占用的信号之后, 调用initsig方法注册信号对应的回调方法. 具体的回调方法写在sighandler方法中:

1
2
3
4
5
6
7
8
9
10
11
12
13
func sighandler(sig uint32, info *siginfo, ctxt unsafe.Pointer, gp *g) {
// ...
// if sig == _SIGPROF
// _SIGTRAP
// _SIGUSR1
// 除了sigPreempt之外的信号都是使用原信号来判定的, 这也是因为系统没有定义专用的抢占信号, Go团队是为了不混淆二者而定义常量

if sig == sigPreempt {
// Might be a preemption signal.
doSigPreempt(gp, c)
}
// ...
}

doSigPreempt方法中, 进行了一个简单的判断, 判断G是否需要被抢占, 以及抢占是否是安全的, 然后执行了抢占的关键方法: ctxt.pushCall(funcPC(asyncPreempt))

使用sysmon去检测抢占

在Go1.14版本之前是由sysmon检测抢占. 到了Gol.14当然也是由sysmon进行抢占检测. runtime在程序启动时, 会创建一个线程来执行sysmon,
独立执行的原因是sysmon是golang的runtime系统检测器(system monitor), sysmon可以进行forcegc(force garbage collect), netpoll, retake等操作.

正因为sysmon是独立运行的, 能够不断地进行休眠唤醒操作, 对于抢占, 会间隔性的进行监控, 最长间隔10ms, 最短间隔20us, 如果某协程独占P超过10ms, 那么就会触发抢占.

发送SIGURG信号

同样在signal_unix.go中, preemptM方法用于给M发送SIGURG信号:

1
2
3
4
5
6
7
8
9
func preemptM(mp *m) {
if !pushCallSupported {
return
}
if GOOS == "darwin" && (GOARCH == "arm" || GOARCH == "arm64") && !iscgo {
return
}
signalM(mp, sigPreempt)
}

接收到SIGURG信号之后

在关键方法asyncPreempt中, 调用了preemptPark方法, 它会解绑MG的关系, 封存当前协程, 继而重新调度runtime.schedule()获取可以执行的协程, 至于被封存的协程在后面会进行重启.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// runtime/preempt.go

func asyncPreempt()

func asyncPreempt2() {
gp := getg()
gp.asyncSafePoint = true
if gp.preemptStop {
mcall(preemptPark)
} else {
mcall(gopreempt_m)
}
gp.asyncSafePoint = false
}
1
2
3
4
5
6
7
8
9
10
11
12
// runtime/proc.go

func preemptPark(gp *g) {
// ...
status := readgstatus(gp)
if status&^_Gscan != _Grunning {
dumpgstatus(gp)
throw("bad g status")
}
// ...
schedule()
}

Contents
  1. 1. Go语言 1.14版本发布的新特性
    1. 1.1. GO MOD用于生产环境
      1. 1.1.1. Go1.5及以前:GOPATH
      2. 1.1.2. Go1.6到1.10:vendor
      3. 1.1.3. Go1.11至今:
    2. 1.2. 具有重叠方法集的接口类型
      1. 1.2.1. 关于使接口类型允许嵌入重叠方法集的提议
      2. 1.2.2. 本质:解决菱形问题
    3. 1.3. 提升defer的性能
      1. 1.3.1. Go1.14以前的defer
      2. 1.3.2. Go1.14做出的改变:
    4. 1.4. goroutine实现异步抢占
      1. 1.4.1. 新信号SIGURG
      2. 1.4.2. 使用sysmon去检测抢占
      3. 1.4.3. 发送SIGURG信号
      4. 1.4.4. 接收到SIGURG信号之后