标签 Go 下的文章

Go 编译器的 SSA 后端包含一种工具,可以生成编译阶段的 HTML 调试输出。这篇文章介绍了如何为函数方法打印 SSA 输出。

让我们从一个包含函数、值方法和指针方法的示例程序开始:

package main

import (
    "fmt"
)

type Numbers struct {
    vals []int
}

func (n *Numbers) Add(v int) {
    n.vals = append(n.vals, v)
}

func (n Numbers) Average() float64 {
    sum := 0.0
    for _, num := range n.vals {
        sum += float64(num)
    }
    return sum / float64(len(n.vals))
}


func main() {
    var numbers Numbers
    numbers.Add(200)
    numbers.Add(43)
    numbers.Add(-6)
    fmt.Println(numbers.Average())
}

通过 GOSSAFUNC 环境变量控制 SSA 调试输出。此变量含有要转储的函数的名称。这不是函数的完全限定名。对于上面的 func main,函数名称为 main 而不是 main.main

% env GOSSAFUNC=main go build
runtime
dumped SSA to ../../go/src/runtime/ssa.html
t
dumped SSA to ./ssa.html

在这个例子中,GOSSAFUNC=main 同时匹配了 main.main 和一个名为 runtime.main 的函数。 [1] 这有点不走运,但是实际上可能没什么大不了的,因为如果你要对代码进行性能调整,它就不会出现在 func main 中的巨大的意大利面块中。

你的代码更有可能在方法中,你可能已经看到这篇文章,并寻找能够转储方法的 SSA 输出。

要为指针方法 func (n *Numbers) Add 打印 SSA 调试,等效函数名为 (*Numbers).Add [2]

% env "GOSSAFUNC=(*Numbers).Add" go build
t
dumped SSA to ./ssa.html

要为值方法 func (n Numbers) Average 打印 SSA 调试,等效函数名为 (*Numbers).Average即使这是一个值方法

% env "GOSSAFUNC=(*Numbers).Average" go build
t
dumped SSA to ./ssa.html

  1. 如果你没有从源码构建 Go,那么 runtime 软件包的路径可能是只读的,并且可能会收到错误消息。请不要使用 sudo 来解决此问题。 ↩︎
  2. 请注意 shell 引用 ↩︎

via: https://dave.cheney.net/2020/06/19/how-to-dump-the-gossafunc-graph-for-a-method

作者:Dave Cheney 选题:lujun9972 译者:geekpi 校对:wxy

本文由 LCTT 原创编译,Linux中国 荣誉推出

大家常规的认知是,Go 程序中声明的类型越多,生成的二进制文件就越大。这个符合直觉,毕竟如果你写的代码不去操作定义的类型,那么定义一堆类型就没有意义了。然而,链接器的部分工作就是检测没有被程序引用的函数(比如说它们是一个库的一部分,其中只有一个子集的功能被使用),然后把它们从最后的编译产出中删除。常言道,“类型越多,二进制文件越大”,对于多数 Go 程序还是正确的。

本文中我会深入讲解在 Go 程序的上下文中“相等”的意义,以及为什么像这样的修改会对 Go 程序的大小有重大的影响。

定义两个值相等

Go 的语法定义了“赋值”和“相等”的概念。赋值是把一个值赋给一个标识符的行为。并不是所有声明的标识符都可以被赋值,如常量和函数就不可以。相等是通过检查标识符的内容是否相等来比较两个标识符的行为。

作为强类型语言,“相同”的概念从根源上被植入标识符的类型中。两个标识符只有是相同类型的前提下,才有可能相同。除此之外,值的类型定义了如何比较该类型的两个值。

例如,整型是用算数方法进行比较的。对于指针类型,是否相等是指它们指向的地址是否相同。映射和通道等引用类型,跟指针类似,如果它们指向相同的地址,那么就认为它们是相同的。

上面都是按位比较相等的例子,即值占用的内存的位模式是相同的,那么这些值就相等。这就是所谓的 memcmp,即内存比较,相等是通过比较两个内存区域的内容来定义的。

记住这个思路,我过会儿再来谈。

结构体相等

除了整型、浮点型和指针等标量类型,还有复合类型:结构体。所有的结构体以程序中的顺序被排列在内存中。因此下面这个声明:

type S struct {
    a, b, c, d int64
}

会占用 32 字节的内存空间;a 占用 8 个字节,b 占用 8 个字节,以此类推。Go 的规则说如果结构体所有的字段都是可以比较的,那么结构体的值就是可以比较的。因此如果两个结构体所有的字段都相等,那么它们就相等。

a := S{1, 2, 3, 4}
b := S{1, 2, 3, 4}
fmt.Println(a == b) // 输出 true

编译器在底层使用 memcmp 来比较 a 的 32 个字节和 b 的 32 个字节。

填充和对齐

然而,在下面的场景下过分简单化的按位比较的策略会返回错误的结果:

type S struct {
    a byte
    b uint64
    c int16
    d uint32
}

func main()
    a := S{1, 2, 3, 4}
    b := S{1, 2, 3, 4}
    fmt.Println(a == b) // 输出 true
}

编译代码后,这个比较表达式的结果还是 true,但是编译器在底层并不能仅依赖比较 ab 的位模式,因为结构体有填充

Go 要求结构体的所有字段都对齐。2 字节的值必须从偶数地址开始,4 字节的值必须从 4 的倍数地址开始,以此类推 1 。编译器根据字段的类型和底层平台加入了填充来确保字段都对齐。在填充之后,编译器实际上看到的是 2

type S struct {
    a byte
    _ [7]byte // 填充
    b uint64
    c int16
    _ [2]int16 // 填充
    d uint32
}

填充的存在保证了字段正确对齐,而填充确实占用了内存空间,但是填充字节的内容是未知的。你可能会认为在 Go 中 填充字节都是 0,但实际上并不是 — 填充字节的内容是未定义的。由于它们并不是被定义为某个确定的值,因此按位比较会因为分布在 s 的 24 字节中的 9 个填充字节不一样而返回错误结果。

Go 通过生成所谓的相等函数来解决这个问题。在这个例子中,s 的相等函数只比较函数中的字段略过填充部分,这样就能正确比较类型 s 的两个值。

类型算法

呵,这是个很大的设置,说明了为什么,对于 Go 程序中定义的每种类型,编译器都会生成几个支持函数,编译器内部把它们称作类型的算法。如果类型是一个映射的键,那么除相等函数外,编译器还会生成一个哈希函数。为了维持稳定,哈希函数在计算结果时也会像相等函数一样考虑诸如填充等因素。

凭直觉判断编译器什么时候生成这些函数实际上很难,有时并不明显,(因为)这超出了你的预期,而且链接器也很难消除没有被使用的函数,因为反射往往导致链接器在裁剪类型时变得更保守。

通过禁止比较来减小二进制文件的大小

现在,我们来解释一下 Brad 的修改。向类型添加一个不可比较的字段 3 ,结构体也随之变成不可比较的,从而强制编译器不再生成相等函数和哈希函数,规避了链接器对那些类型的消除,在实际应用中减小了生成的二进制文件的大小。作为这项技术的一个例子,下面的程序:

package main

import "fmt"

func main() {
    type t struct {
        // _ [0][]byte // 取消注释以阻止比较
        a byte
        b uint16
        c int32
        d uint64
    }
    var a t
    fmt.Println(a)
}

用 Go 1.14.2(darwin/amd64)编译,大小从 2174088 降到了 2174056,节省了 32 字节。单独看节省的这 32 字节似乎微不足道,但是考虑到你的程序中每个类型及其传递闭包都会生成相等和哈希函数,还有它们的依赖,这些函数的大小随类型大小和复杂度的不同而不同,禁止它们会大大减小最终的二进制文件的大小,效果比之前使用 -ldflags="-s -w" 还要好。

最后总结一下,如果你不想把类型定义为可比较的,可以在源码层级强制实现像这样的奇技淫巧,会使生成的二进制文件变小。


附录:在 Brad 的推动下,Cherry ZhangKeith Randall 已经在 Go 1.15 做了大量的改进,修复了最严重的故障,消除了无用的相等和哈希函数(虽然我猜想这也是为了避免这类 CL 的扩散)。

相关文章:

  1. Go 运行时如何高效地实现映射(不使用泛型)")
  2. 空结构体
  3. 填充很难
  4. Go 中有类型的 nil(2)

  1. 在 32 位平台上 int64unit64 的值可能不是按 8 字节对齐的,因为平台原生的是以 4 字节对齐的。查看 议题 599 了解内部详细信息。
  2. 32 位平台会在 ab 的声明中填充 _ [3]byte。参见前一条。
  3. Brad 使用的是[0]func(),但是所有能限制和禁止比较的类型都可以。添加了一个有 0 个元素的数组的声明后,结构体的大小和对齐不会受影响。

via: https://dave.cheney.net/2020/05/09/ensmallening-go-binaries-by-prohibiting-comparisons

作者:Dave Cheney 选题:lujun9972 译者:lxbwolf 校对:wxy

本文由 LCTT 原创编译,Linux中国 荣誉推出

2019 年 Python 开发者调查结果发布:Linux 最受欢迎

近日,Python 官方发布了 2019 年 Python 开发者调查结果。84% 的受访者将 Python 作为主要语言;最常与 Python 一起使用的语言是 JavaScript,占比 43%;NumPy(63%)和 Pandas(55%)是最受欢迎的数据科学框架;超过一半的受访者都在使用 Requests 库;超过 2/3 的受访者使用 Linux 进行开发;最喜欢的 Python 开发工具是 PyCharm 和 VScode,接着是 Vim。完整报告

来源:开源中国

硬核老王点评:做开发的你不用 Linux,感觉总是差点什么。

8000 元,100% 开源的笔记本电脑众筹

MNT Reform 主打开源,不仅搭载了开源系统(预装 Debian GNU/Linux 11),其硬件部分包括 CPU、主板、外壳等等也全部是开源的,用户甚至可以自己下载数据自行 3D 打印一套笔记本机身。MNT Reform 的另一个优势是模块化,其 CPU 与内存放在一个拥有 200 针 SODIMM 接口的模块上,用户可以更换升级。

来源:快科技

硬核老王点评:全部开源的笔记本一定是 RMS 的心头爱。

2020 年最抢手的编程语言是谷歌的 Go

根据 HackerEarth 公司对来自 76 个国家超过 16,655 名开发者进行的调查结果显示,在最想了解的编程语言中 Go 名列前茅。调查发现,32% 的有经验的开发者选择 Go 作为他们想学习的编程语言,超过了 Python(24%)。

来源:cnBeta.COM

硬核老王点评:Go 语言的前景(钱景)可观,目前发展态势也很好。

微软表示每月有 1.5 亿用户在体验免密登录

该公司在一篇博客文章中表示,得益于 Windows Hello、Microsoft Authenticator 、以及基于 FIDO2 的安全密钥方案,用户可以更加放心地轻松登录体验各项服务,包括 Azure、GitHub、Office 和 Xbox 。短六个月时间,这一数字就增长了近 5000 万。

来源:cnBeta.COM

硬核老王点评:这种采用 MFA 的免密认证要比使用简单密码的认证安全得多,也方便得多。

Ubuntu 20.04 LTS 对树莓派进行认证,以提供全面支持

在 Ubuntu 20.04 LTS 公开发布的同一天,所有经认证的树莓派上都已添加了对 Ubuntu Server 20.04 的全面支持,用户可以将 20.04 刷入到树莓派中。经过认证后,树莓派将每三周收到经过测试的更新和安全补丁。

来源:开源中国

安卓 App 奇葩的“链式启动”

MIUI 加入了“照明弹”功能,该功能可以把安卓 App 在后台的一举一动暴露在用户面前,很多人惊讶地发现,为数不少的 App 竟然会唤醒其他 App!例如只要你开启微博 App,就会唤起墨迹天气、百词斩、抖音、云闪付等 App——这,就是大名鼎鼎的“链式启动”。App 一旦接入了友盟、极光、个推等等这些推送 SDK,就会自带链式启动的特性。两个风马牛不相及的 App 之所以相互唤醒,就是这个原因。

来源:太平洋电脑网

硬核老王点评:可恶!

Google 翻译内核升级 称误差再降 55% 以上

Google 翻译搭载的 Google 的神经机器翻译(GNMT)系统使用了当前最先进的训练技术。十多年前,Google 发布了 Google 翻译,早年基于短语的统计机器翻译,这种翻译方式的劣势非常明显。而 GNMT 则将输入的句子作为一个整体翻译。现在,移动版和网页版的 Google 翻译的中英翻译每天进行大约 1800 万条翻译。

来源:TechWeb

硬核老王点评:似乎目前几个翻译服务都各有千秋,或许要是有个可以综合各个翻译服务的结果的 AI,可能翻译出来的结果会更好。

上一篇文章中我论述了 叶子内联 leaf inlining 是怎样让 Go 编译器减少函数调用的开销的,以及延伸出了跨函数边界的优化的机会。本文中,我要论述内联的限制以及叶子内联与 栈中内联 mid-stack inlining 的对比。

内联的限制

把函数内联到它的调用处消除了调用的开销,为编译器进行其他的优化提供了更好的机会,那么问题来了,既然内联这么好,内联得越多开销就越少,为什么不尽可能多地内联呢?

内联可能会以增加程序大小换来更快的执行时间。限制内联的最主要原因是,创建许多函数的内联副本会增加编译时间,并导致生成更大的二进制文件的边际效应。即使把内联带来的进一步的优化机会考虑在内,太激进的内联也可能会增加生成的二进制文件的大小和编译时间。

内联收益最大的是小函数,相对于调用它们的开销来说,这些函数做很少的工作。随着函数大小的增长,函数内部做的工作与函数调用的开销相比省下的时间越来越少。函数越大通常越复杂,因此优化其内联形式相对于原地优化的好处会减少。

内联预算

在编译过程中,每个函数的内联能力是用内联预算计算的 1 。开销的计算过程可以巧妙地内化,像一元和二元等简单操作,在 抽象语法数 Abstract Syntax Tree (AST)中通常是每个节点一个单位,更复杂的操作如 make 可能单位更多。考虑下面的例子:

package main

func small() string {
    s := "hello, " + "world!"
    return s
}

func large() string {
    s := "a"
    s += "b"
    s += "c"
    s += "d"
    s += "e"
    s += "f"
    s += "g"
    s += "h"
    s += "i"
    s += "j"
    s += "k"
    s += "l"
    s += "m"
    s += "n"
    s += "o"
    s += "p"
    s += "q"
    s += "r"
    s += "s"
    s += "t"
    s += "u"
    s += "v"
    s += "w"
    s += "x"
    s += "y"
    s += "z"
    return s
}

func main() {
    small()
    large()
}

使用 -gcflags=-m=2 参数编译这个函数能让我们看到编译器分配给每个函数的开销:

% go build -gcflags=-m=2 inl.go
# command-line-arguments
./inl.go:3:6: can inline small with cost 7 as: func() string { s := "hello, world!"; return s }
./inl.go:8:6: cannot inline large: function too complex: cost 82 exceeds budget 80
./inl.go:38:6: can inline main with cost 68 as: func() { small(); large() }
./inl.go:39:7: inlining call to small func() string { s := "hello, world!"; return s }

编译器根据函数 func small() 的开销(7)决定可以对它内联,而 func large() 的开销太大,编译器决定不进行内联。func main() 被标记为适合内联的,分配了 68 的开销;其中 small 占用 7,调用 small 函数占用 57,剩余的(4)是它自己的开销。

可以用 -gcflag=-l 参数控制内联预算的等级。下面是可使用的值:

  • -gcflags=-l=0 默认的内联等级。
  • -gcflags=-l(或 -gcflags=-l=1)取消内联。
  • -gcflags=-l=2-gcflags=-l=3 现在已经不使用了。和 -gcflags=-l=0 相比没有区别。
  • -gcflags=-l=4 减少非叶子函数和通过接口调用的函数的开销。 2

不确定语句的优化

一些函数虽然内联的开销很小,但由于太复杂它们仍不适合进行内联。这就是函数的不确定性,因为一些操作的语义在内联后很难去推导,如 recoverbreak。其他的操作,如 selectgo 涉及运行时的协调,因此内联后引入的额外的开销不能抵消内联带来的收益。

不确定的语句也包括 forrange,这些语句不一定开销很大,但目前为止还没有对它们进行优化。

栈中函数优化

在过去,Go 编译器只对叶子函数进行内联 —— 只有那些不调用其他函数的函数才有资格。在上一段不确定的语句的探讨内容中,一次函数调用就会让这个函数失去内联的资格。

进入栈中进行内联,就像它的名字一样,能内联在函数调用栈中间的函数,不需要先让它下面的所有的函数都被标记为有资格内联的。栈中内联是 David Lazar 在 Go 1.9 中引入的,并在随后的版本中做了改进。这篇文稿深入探究了保留栈追踪行为和被深度内联后的代码路径里的 runtime.Callers 的难点。

在前面的例子中我们看到了栈中函数内联。内联后,func main() 包含了 func small() 的函数体和对 func large() 的一次调用,因此它被判定为非叶子函数。在过去,这会阻止它被继续内联,虽然它的联合开销小于内联预算。

栈中内联的最主要的应用案例就是减少贯穿函数调用栈的开销。考虑下面的例子:

package main

import (
    "fmt"
    "strconv"
)

type Rectangle struct {}

//go:noinline
func (r *Rectangle) Height() int {
    h, _ := strconv.ParseInt("7", 10, 0)
    return int(h)
}

func (r *Rectangle) Width() int {
    return 6
}

func (r *Rectangle) Area() int { return r.Height() * r.Width() }

func main() {
    var r Rectangle
    fmt.Println(r.Area())
}

在这个例子中, r.Area() 是个简单的函数,调用了两个函数。r.Width() 可以被内联,r.Height() 这里用 //go:noinline 指令标注了,不能被内联。 3

% go build -gcflags='-m=2' square.go                                                                                                          
# command-line-arguments
./square.go:12:6: cannot inline (*Rectangle).Height: marked go:noinline                                                                               
./square.go:17:6: can inline (*Rectangle).Width with cost 2 as: method(*Rectangle) func() int { return 6 }
./square.go:21:6: can inline (*Rectangle).Area with cost 67 as: method(*Rectangle) func() int { return r.Height() * r.Width() }                       
./square.go:21:61: inlining call to (*Rectangle).Width method(*Rectangle) func() int { return 6 }                                                     
./square.go:23:6: cannot inline main: function too complex: cost 150 exceeds budget 80                        
./square.go:25:20: inlining call to (*Rectangle).Area method(*Rectangle) func() int { return r.Height() * r.Width() }
./square.go:25:20: inlining call to (*Rectangle).Width method(*Rectangle) func() int { return 6 }

由于 r.Area() 中的乘法与调用它的开销相比并不大,因此内联它的表达式是纯收益,即使它的调用的下游 r.Height() 仍是没有内联资格的。

快速路径内联

关于栈中内联的效果最令人吃惊的例子是 2019 年 Carlo Alberto Ferraris 通过允许把 sync.Mutex.Lock() 的快速路径(非竞争的情况)内联到它的调用方来提升它的性能。在这个修改之前,sync.Mutex.Lock() 是个很大的函数,包含很多难以理解的条件,使得它没有资格被内联。即使锁可用时,调用者也要付出调用 sync.Mutex.Lock() 的代价。

Carlo 把 sync.Mutex.Lock() 分成了两个函数(他自己称为 外联 outlining )。外部的 sync.Mutex.Lock() 方法现在调用 sync/atomic.CompareAndSwapInt32() 且如果 CAS( 比较并交换 Compare and Swap )成功了之后立即返回给调用者。如果 CAS 失败,函数会走到 sync.Mutex.lockSlow() 慢速路径,需要对锁进行注册,暂停 goroutine。 4

% go build -gcflags='-m=2 -l=0' sync 2>&1 | grep '(*Mutex).Lock'
../go/src/sync/mutex.go:72:6: can inline (*Mutex).Lock with cost 69 as: method(*Mutex) func() { if "sync/atomic".CompareAndSwapInt32(&m.state, 0, mutexLocked) { if race.Enabled {  }; return  }; m.lockSlow() }

通过把函数分割成一个简单的不能再被分割的外部函数,和(如果没走到外部函数就走到的)一个处理慢速路径的复杂的内部函数,Carlo 组合了栈中函数内联和编译器对基础操作的支持,减少了非竞争锁 14% 的开销。之后他在 sync.RWMutex.Unlock() 重复这个技巧,节省了另外 9% 的开销。

相关文章:

  1. Go 中的内联优化
  2. goroutine 的栈为什么会无限增长?
  3. 栈追踪和 errors 包
  4. 零值是什么,为什么它很有用?

  1. 不同发布版本中,在考虑该函数是否适合内联时,Go 编译器对同一函数的预算是不同的。
  2. 时刻记着编译器的作者警告过“更高的内联等级(比 -l 更高)可能导致错误或不被支持”。 Caveat emptor。
  3. 编译器有足够的能力来内联像 strconv.ParseInt 的复杂函数。作为一个实验,你可以尝试去掉 //go:noinline 注释,使用 -gcflags=-m=2 编译后观察。
  4. race.Enable 表达式是通过传递给 go 工具的 -race 参数控制的一个常量。对于普通编译,它的值是 false,此时编译器可以完全省略代码路径。

via: https://dave.cheney.net/2020/05/02/mid-stack-inlining-in-go

作者:Dave Cheney 选题:lujun9972 译者:lxbwolf 校对:wxy

本文由 LCTT 原创编译,Linux中国 荣誉推出

本文讨论 Go 编译器是如何实现内联的,以及这种优化方法如何影响你的 Go 代码。

请注意:本文重点讨论 gc,这是来自 golang.org 的事实标准的 Go 编译器。讨论到的概念可以广泛适用于其它 Go 编译器,如 gccgo 和 llgo,但它们在实现方式和功效上可能有所差异。

内联是什么?

内联 inlining 就是把简短的函数在调用它的地方展开。在计算机发展历程的早期,这个优化是由程序员手动实现的。现在,内联已经成为编译过程中自动实现的基本优化过程的其中一步。

为什么内联很重要?

有两个原因。第一个是它消除了函数调用本身的开销。第二个是它使得编译器能更高效地执行其他的优化策略。

函数调用的开销

在任何语言中,调用一个函数 1 都会有消耗。把参数编组进寄存器或放入栈中(取决于 ABI),在返回结果时的逆反过程都会有开销。引入一次函数调用会导致程序计数器从指令流的一点跳到另一点,这可能导致管道滞后。函数内部通常有 前置处理 preamble ,需要为函数执行准备新的栈帧,还有与前置相似的 后续处理 epilogue ,需要在返回给调用方之前释放栈帧空间。

在 Go 中函数调用会消耗额外的资源来支持栈的动态增长。在进入函数时,goroutine 可用的栈空间与函数需要的空间大小进行比较。如果可用空间不同,前置处理就会跳到 运行时 runtime 的逻辑中,通过把数据复制到一块新的、更大的空间的来增长栈空间。当这个复制完成后,运行时就会跳回到原来的函数入口,再执行栈空间检查,现在通过了检查,函数调用继续执行。这种方式下,goroutine 开始时可以申请很小的栈空间,在有需要时再申请更大的空间。 2

这个检查消耗很小,只有几个指令,而且由于 goroutine 的栈是成几何级数增长的,因此这个检查很少失败。这样,现代处理器的分支预测单元可以通过假定检查肯定会成功来隐藏栈空间检查的消耗。当处理器预测错了栈空间检查,不得不放弃它在推测性执行所做的操作时,与为了增加 goroutine 的栈空间运行时所需的操作消耗的资源相比,管道滞后的代价更小。

虽然现代处理器可以用预测性执行技术优化每次函数调用中的泛型和 Go 特定的元素的开销,但那些开销不能被完全消除,因此在每次函数调用执行必要的工作过程中都会有性能消耗。一次函数调用本身的开销是固定的,与更大的函数相比,调用小函数的代价更大,因为在每次调用过程中它们做的有用的工作更少。

因此,消除这些开销的方法必须是要消除函数调用本身,Go 的编译器就是这么做的,在某些条件下通过用函数的内容来替换函数调用来实现。这个过程被称为内联,因为它在函数调用处把函数体展开了。

改进的优化机会

Cliff Click 博士把内联描述为现代编译器做的优化措施,像常量传播(LCTT 译注:此处作者笔误,原文为 constant proportion,修正为 constant propagation)和死代码消除一样,都是编译器的基本优化方法。实际上,内联可以让编译器看得更深,使编译器可以观察调用的特定函数的上下文内容,可以看到能继续简化或彻底消除的逻辑。由于可以递归地执行内联,因此不仅可以在每个独立的函数上下文处进行这种优化决策,也可以在整个函数调用链中进行。

实践中的内联

下面这个例子可以演示内联的影响:

package main

import "testing"

//go:noinline
func max(a, b int) int {
    if a > b {
        return a
    }
    return b
}

var Result int

func BenchmarkMax(b *testing.B) {
    var r int
    for i := 0; i < b.N; i++ {
        r = max(-1, i)
    }
    Result = r
}

运行这个基准,会得到如下结果: 3

% go test -bench=. 
BenchmarkMax-4   530687617         2.24 ns/op

在我的 2015 MacBook Air 上 max(-1, i) 的耗时约为 2.24 纳秒。现在去掉 //go:noinline 编译指令,再看下结果:

% go test -bench=. 
BenchmarkMax-4   1000000000         0.514 ns/op

从 2.24 纳秒降到了 0.51 纳秒,或者从 benchstat 的结果可以看出,有 78% 的提升。

% benchstat {old,new}.txt
name   old time/op  new time/op  delta
Max-4  2.21ns ± 1%  0.49ns ± 6%  -77.96%  (p=0.000 n=18+19)

这个提升是从哪儿来的呢?

首先,移除掉函数调用以及与之关联的前置处理 4 是主要因素。把 max 函数的函数体在调用处展开,减少了处理器执行的指令数量并且消除了一些分支。

现在由于编译器优化了 BenchmarkMax,因此它可以看到 max 函数的内容,进而可以做更多的提升。当 max 被内联后,BenchmarkMax 呈现给编译器的样子,看起来是这样的:

func BenchmarkMax(b *testing.B) {
    var r int
    for i := 0; i < b.N; i++ {
        if -1 > i {
            r = -1
        } else {
            r = i
        }
    }
    Result = r
}

再运行一次基准,我们看一下手动内联的版本和编译器内联的版本的表现:

% benchstat {old,new}.txt
name   old time/op  new time/op  delta
Max-4  2.21ns ± 1%  0.48ns ± 3%  -78.14%  (p=0.000 n=18+18)

现在编译器能看到在 BenchmarkMax 里内联 max 的结果,可以执行以前不能执行的优化措施。例如,编译器注意到 i 初始值为 0,仅做自增操作,因此所有与 i 的比较都可以假定 i 不是负值。这样条件表达式 -1 > i 永远不是 true 5

证明了 -1 > i 永远不为 true 后,编译器可以把代码简化为:

func BenchmarkMax(b *testing.B) {
    var r int
    for i := 0; i < b.N; i++ {
        if false {
            r = -1
        } else {
            r = i
        }
    }
    Result = r
}

并且因为分支里是个常量,编译器可以通过下面的方式移除不会走到的分支:

func BenchmarkMax(b *testing.B) {
    var r int
    for i := 0; i < b.N; i++ {
        r = i
    }
    Result = r
}

这样,通过内联和由内联解锁的优化过程,编译器把表达式 r = max(-1, i)) 简化为 r = i

内联的限制

本文中我论述的内联称作 叶子内联 leaf inlining :把函数调用栈中最底层的函数在调用它的函数处展开的行为。内联是个递归的过程,当把函数内联到调用它的函数 A 处后,编译器会把内联后的结果代码再内联到 A 的调用方,这样持续内联下去。例如,下面的代码:

func BenchmarkMaxMaxMax(b *testing.B) {
    var r int
    for i := 0; i < b.N; i++ {
        r = max(max(-1, i), max(0, i))
    }
    Result = r
}

与之前的例子中的代码运行速度一样快,因为编译器可以对上面的代码重复地进行内联,也把代码简化到 r = i 表达式。

下一篇文章中,我会论述当 Go 编译器想要内联函数调用栈中间的某个函数时选用的另一种内联策略。最后我会论述编译器为了内联代码准备好要达到的极限,这个极限 Go 现在的能力还达不到。

相关文章:

  1. 使 Go 变快的 5 件事
  2. 为什么 Goroutine 的栈空间会无限增长?
  3. Go 中怎么写基准测试
  4. Go 中隐藏的编译指令

  1. 在 Go 中,一个方法就是一个有预先定义的形参和接受者的函数。假设这个方法不是通过接口调用的,调用一个无消耗的函数所消耗的代价与引入一个方法是相同的。
  2. 在 Go 1.14 以前,栈检查的前置处理也被垃圾回收器用于 STW,通过把所有活跃的 goroutine 栈空间设为 0,来强制它们切换为下一次函数调用时的运行时状态。这个机制最近被替换为一种新机制,新机制下运行时可以不用等 goroutine 进行函数调用就可以暂停 goroutine。
  3. 我用 //go:noinline 编译指令来阻止编译器内联 max。这是因为我想把内联 max 的影响与其他影响隔离开,而不是用 -gcflags='-l -N' 选项在全局范围内禁止优化。关于 //go: 注释在这篇文章中详细论述。
  4. 你可以自己通过比较 go test -bench=. -gcflags=-S 有无 //go:noinline 注释时的不同结果来验证一下。
  5. 你可以用 -gcflags=-d=ssa/prove/debug=on 选项来自己验证一下。

via: https://dave.cheney.net/2020/04/25/inlining-optimisations-in-go

作者:Dave Cheney 选题:lujun9972 译者:lxbwolf 校对:wxy

本文由 LCTT 原创编译,Linux中国 荣誉推出

通过学习如何定位并发处理的陷阱来避免未来处理这些问题时的困境。

在复杂的分布式系统进行任务处理时,你通常会需要进行并发的操作。在 Mode.net 公司,我们每天都要和实时、快速和灵活的软件打交道。而没有一个高度并发的系统,就不可能构建一个毫秒级的动态地路由数据包的全球专用网络。这个动态路由是基于网络状态的,尽管这个过程需要考虑众多因素,但我们的重点是链路指标。在我们的环境中,链路指标可以是任何跟网络链接的状态和当前属性(如链接延迟)有关的任何内容。

并发探测链接监控

我们的动态路由算法 H.A.L.O. 逐跳自适应链路状态最佳路由 Hop-by-Hop Adaptive Link-State Optimal Routing )部分依赖于链路指标来计算路由表。这些指标由位于每个 PoP( 存活节点 Point of Presence )上的独立组件收集。PoP 是表示我们的网络中单个路由实体的机器,通过链路连接并分布在我们的网络拓扑中的各个位置。某个组件使用网络数据包探测周围的机器,周围的机器回复数据包给前者。从接收到的探测包中可以获得链路延迟。由于每个 PoP 都有不止一个临近节点,所以这种探测任务实质上是并发的:我们需要实时测量每个临近连接点的延迟。我们不能串行地处理;为了计算这个指标,必须尽快处理每个探测。

 title=

序列号和重置:一个重新排列场景

我们的探测组件互相发送和接收数据包,并依靠序列号进行数据包处理。这旨在避免处理重复的包或顺序被打乱的包。我们的第一个实现依靠特殊的序列号 0 来重置序列号。这个数字仅在组件初始化时使用。主要的问题是我们考虑了递增的序列号总是从 0 开始。在该组件重启后,包的顺序可能会重新排列,某个包的序列号可能会轻易地被替换成重置之前使用过的值。这意味着,后继的包都会被忽略掉,直到排到重置之前用到的序列值。

UDP 握手和有限状态机

这里的问题是该组件重启前后的序列号是否一致。有几种方法可以解决这个问题,经过讨论,我们选择了实现一个带有清晰状态定义的三步握手协议。这个握手过程在初始化时通过链接建立会话。这样可以确保节点通过同一个会话进行通信且使用了适当的序列号。

为了正确实现这个过程,我们必须定义一个有清晰状态和过渡的有限状态机。这样我们就可以正确管理握手过程中的所有极端情况。

 title=

会话 ID 由握手的初始化程序生成。一个完整的交换顺序如下:

  1. 发送者发送一个 SYN(ID) 数据包。
  2. 接收者存储接收到的 ID 并发送一个 SYN-ACK(ID)
  3. 发送者接收到 SYN-ACK(ID) 并发送一个 ACK(ID)。它还发送一个从序列号 0 开始的数据包。
  4. 接收者检查最后接收到的 ID,如果 ID 匹配,则接受 ACK(ID)。它还开始接受序列号为 0 的数据包。

处理状态超时

基本上,每种状态下你都需要处理最多三种类型的事件:链接事件、数据包事件和超时事件。这些事件会并发地出现,因此你必须正确处理并发。

  • 链接事件包括网络连接或网络断开的变化,相应的初始化一个链接会话或断开一个已建立的会话。
  • 数据包事件是控制数据包(SYN/SYN-ACK/ACK)或只是探测响应。
  • 超时事件在当前会话状态的预定超时时间到期后触发。

这里面临的最主要的问题是如何处理并发的超时到期和其他事件。这里很容易陷入死锁和资源竞争的陷阱。

第一种方法

本项目使用的语言是 Golang。它确实提供了原生的同步机制,如自带的通道和锁,并且能够使用轻量级线程来进行并发处理。

 title=

gopher 们聚众狂欢

首先,你可以设计两个分别表示我们的会话和超时处理程序的结构体。

type Session struct {  
  State SessionState  
  Id SessionId  
  RemoteIp string  
}

type TimeoutHandler struct {  
  callback func(Session)  
  session Session  
  duration int  
  timer *timer.Timer  
}

Session 标识连接会话,内有表示会话 ID、临近的连接点的 IP 和当前会话状态的字段。

TimeoutHandler 包含回调函数、对应的会话、持续时间和指向调度计时器的指针。

每一个临近连接点的会话都包含一个保存调度 TimeoutHandler 的全局映射。

SessionTimeout map[Session]*TimeoutHandler

下面方法注册和取消超时:

// schedules the timeout callback function.  
func (timeout* TimeoutHandler) Register() {  
  timeout.timer = time.AfterFunc(time.Duration(timeout.duration) * time.Second, func() {  
    timeout.callback(timeout.session)  
  })  
}

func (timeout* TimeoutHandler) Cancel() {  
  if timeout.timer == nil {  
    return  
  }  
  timeout.timer.Stop()  
}

你可以使用类似下面的方法来创建和存储超时:

func CreateTimeoutHandler(callback func(Session), session Session, duration int) *TimeoutHandler {  
  if sessionTimeout[session] == nil {  
    sessionTimeout[session] := new(TimeoutHandler)  
  }  
   
  timeout = sessionTimeout[session]  
  timeout.session = session  
  timeout.callback = callback  
  timeout.duration = duration  
  return timeout  
}

超时处理程序创建后,会在经过了设置的 duration 时间(秒)后执行回调函数。然而,有些事件会使你重新调度一个超时处理程序(与 SYN 状态时的处理一样,每 3 秒一次)。

为此,你可以让回调函数重新调度一次超时:

func synCallback(session Session) {  
  sendSynPacket(session)

  // reschedules the same callback.  
  newTimeout := NewTimeoutHandler(synCallback, session, SYN_TIMEOUT_DURATION)  
  newTimeout.Register()

  sessionTimeout[state] = newTimeout  
}

这次回调在新的超时处理程序中重新调度自己,并更新全局映射 sessionTimeout

数据竞争和引用

你的解决方案已经有了。可以通过检查计时器到期后超时回调是否执行来进行一个简单的测试。为此,注册一个超时,休眠 duration 秒,然后检查是否执行了回调的处理。执行这个测试后,最好取消预定的超时时间(因为它会重新调度),这样才不会在下次测试时产生副作用。

令人惊讶的是,这个简单的测试发现了这个解决方案中的一个问题。使用 cancel 方法来取消超时并没有正确处理。以下顺序的事件会导致数据资源竞争:

  1. 你有一个已调度的超时处理程序。
  2. 线程 1:

    1. 你接收到一个控制数据包,现在你要取消已注册的超时并切换到下一个会话状态(如发送 SYN 后接收到一个 SYN-ACK
    2. 你调用了 timeout.Cancel(),这个函数调用了 timer.Stop()。(请注意,Golang 计时器的停止不会终止一个已过期的计时器。)
  3. 线程 2:

    1. 在取消调用之前,计时器已过期,回调即将执行。
    2. 执行回调,它调度一次新的超时并更新全局映射。
  4. 线程 1:

    1. 切换到新的会话状态并注册新的超时,更新全局映射。

两个线程并发地更新超时映射。最终结果是你无法取消注册的超时,然后你也会丢失对线程 2 重新调度的超时的引用。这导致处理程序在一段时间内持续执行和重新调度,出现非预期行为。

锁也解决不了问题

使用锁也不能完全解决问题。如果你在处理所有事件和执行回调之前加锁,它仍然不能阻止一个过期的回调运行:

func (timeout* TimeoutHandler) Register() {  
  timeout.timer = time.AfterFunc(time.Duration(timeout.duration) * time._Second_, func() {  
    stateLock.Lock()  
    defer stateLock.Unlock()

    timeout.callback(timeout.session)  
  })  
}

现在的区别就是全局映射的更新是同步的,但是这还是不能阻止在你调用 timeout.Cancel() 后回调的执行 —— 这种情况出现在调度计时器过期了但是还没有拿到锁的时候。你还是会丢失一个已注册的超时的引用。

使用取消通道

你可以使用取消通道,而不必依赖不能阻止到期的计时器执行的 golang 函数 timer.Stop()

这是一个略有不同的方法。现在你可以不用再通过回调进行递归地重新调度;而是注册一个死循环,这个循环接收到取消信号或超时事件时终止。

新的 Register() 产生一个新的 go 线程,这个线程在超时后执行你的回调,并在前一个超时执行后调度新的超时。返回给调用方一个取消通道,用来控制循环的终止。

func (timeout *TimeoutHandler) Register() chan struct{} {  
  cancelChan := make(chan struct{})  
   
  go func () {  
    select {  
    case _ = <- cancelChan:  
      return  
    case _ = <- time.AfterFunc(time.Duration(timeout.duration) * time.Second):  
      func () {  
        stateLock.Lock()  
        defer stateLock.Unlock()

        timeout.callback(timeout.session)  
      } ()  
    }  
  } ()

  return cancelChan  
}

func (timeout* TimeoutHandler) Cancel() {  
  if timeout.cancelChan == nil {  
    return  
  }  
  timeout.cancelChan <- struct{}{}  
}

这个方法给你注册的所有超时提供了取消通道。一个取消调用向通道发送一个空结构体并触发取消操作。然而,这并不能解决前面的问题;可能在你通过通道取消之前以及超时线程拿到锁之前,超时时间就已经到了。

这里的解决方案是,在拿到锁之后,检查一下超时范围内的取消通道。

  case _ = <- time.AfterFunc(time.Duration(timeout.duration) * time.Second):  
    func () {  
      stateLock.Lock()  
      defer stateLock.Unlock()  
     
      select {  
      case _ = <- handler.cancelChan:  
        return  
      default:  
        timeout.callback(timeout.session)  
      }  
    } ()  
  }

最终,这可以确保在拿到锁之后执行回调,不会触发取消操作。

小心死锁

这个解决方案看起来有效;但是还是有个隐患:死锁

请阅读上面的代码,试着自己找到它。考虑下描述的所有函数的并发调用。

这里的问题在取消通道本身。我们创建的是无缓冲通道,即发送的是阻塞调用。当你在一个超时处理程序中调用取消函数时,只有在该处理程序被取消后才能继续处理。问题出现在,当你有多个调用请求到同一个取消通道时,这时一个取消请求只被处理一次。当多个事件同时取消同一个超时处理程序时,如连接断开或控制包事件,很容易出现这种情况。这会导致死锁,可能会使应用程序停机。

 title=

有人在听吗?

(已获得 Trevor Forrey 授权。)

这里的解决方案是创建通道时指定缓存大小至少为 1,这样向通道发送数据就不会阻塞,也显式地使发送变成非阻塞的,避免了并发调用。这样可以确保取消操作只发送一次,并且不会阻塞后续的取消调用。

func (timeout* TimeoutHandler) Cancel() {  
  if timeout.cancelChan == nil {  
    return  
  }  
   
  select {  
  case timeout.cancelChan <- struct{}{}:  
  default:  
    // can’t send on the channel, someone has already requested the cancellation.  
  }  
}

总结

在实践中你学到了并发操作时出现的常见错误。由于其不确定性,即使进行大量的测试,也不容易发现这些问题。下面是我们在最初的实现中遇到的三个主要问题:

在非同步的情况下更新共享数据

这似乎是个很明显的问题,但如果并发更新发生在不同的位置,就很难发现。结果就是数据竞争,由于一个更新会覆盖另一个,因此对同一数据的多次更新中会有某些更新丢失。在我们的案例中,我们是在同时更新同一个共享映射里的调度超时引用。(有趣的是,如果 Go 检测到在同一个映射对象上的并发读写,会抛出致命错误 — 你可以尝试下运行 Go 的数据竞争检测器)。这最终会导致丢失超时引用,且无法取消给定的超时。当有必要时,永远不要忘记使用锁。

 title=

不要忘记同步 gopher 们的工作

缺少条件检查

在不能仅依赖锁的独占性的情况下,就需要进行条件检查。我们遇到的场景稍微有点不一样,但是核心思想跟条件变量是一样的。假设有个一个生产者和多个消费者使用一个共享队列的经典场景,生产者可以将一个元素添加到队列并唤醒所有消费者。这个唤醒调用意味着队列中的数据是可访问的,并且由于队列是共享的,消费者必须通过锁来进行同步访问。每个消费者都可能拿到锁;然而,你仍然需要检查队列中是否有元素。因为在你拿到锁的瞬间并不知道队列的状态,所以还是需要进行条件检查。

在我们的例子中,超时处理程序收到了计时器到期时发出的“唤醒”调用,但是它仍需要检查是否已向其发送了取消信号,然后才能继续执行回调。

 title=

如果你要唤醒多个 gopher,可能就需要进行条件检查

死锁

当一个线程被卡住,无限期地等待一个唤醒信号,但是这个信号永远不会到达时,就会发生这种情况。死锁可以通过让你的整个程序停机来彻底杀死你的应用。

在我们的案例中,这种情况的发生是由于多次发送请求到一个非缓冲且阻塞的通道。这意味着向通道发送数据只有在从这个通道接收完数据后才能返回。我们的超时线程循环迅速从取消通道接收信号;然而,在接收到第一个信号后,它将跳出循环,并且再也不会从这个通道读取数据。其他的调用会一直被卡住。为避免这种情况,你需要仔细检查代码,谨慎处理阻塞调用,并确保不会发生线程饥饿。我们例子中的解决方法是使取消调用成为非阻塞调用 — 我们不需要阻塞调用。


via: https://opensource.com/article/19/12/go-common-pitfalls

作者:Eduardo Ferreira 选题:lujun9972 译者:lxbwolf 校对:wxy

本文由 LCTT 原创编译,Linux中国 荣誉推出