race

一般并发的bug 有两种,死锁(block)和 竞争(race)



死锁发生时,go run 会直接报错
race 发生时,要加race 才会在运行时报warning
go run xxx.go 后面加上 -race 参数



$ go run -race race.go


WARNING: DATA RACE
Write at 0x00c0000a2000 by goroutine 6:
main.main.func2()
/Users/harryhare/git/go_playground/src/race.go:15 +0x38



Previous write at 0x00c0000a2000 by goroutine 5:
main.main.func1()
/Users/harryhare/git/go_playground/src/race.go:9 +0x38



Goroutine 6 (running) created at:
main.main()
/Users/harryhare/git/go_playground/src/race.go:13 +0x9c



Goroutine 5 (running) created at:
main.main()
/Users/harryhare/git/go_playground/src/race.go:7 +0x7a
package main



import “time”



func main(){
var x int
go func(){
for{
x=1
}
}()



go func(){
for{
x=2
}
}()
time.Sleep(100*time.Second) }


这个命令输出了Warning,告诉我们,goroutine5运行到第11行和main goroutine运行到13行的时候触发竞争了。
而且goroutine5是在第12行的时候产生的。



这样我们根据分析这个提示就可以看到这个程序在哪个地方写的有问题了。



当然这个参数会引发CPU和内存的使用增加,所以基本是在测试环境使用,不是在正式环境开启。



https://github.com/xiazemin/race




在上面的例子中,看代码,我们其实看的出来,这里的go func触发的goroutine会修改a。
主goroutine 也会对a进行修改。但是我们如果只go run运行,我们可能往往不会发现什么太大的问题。



runtime go run race1.go
a is 3
可喜的是,golang在1.1之后引入了竞争检测的概念。我们可以使用go run -race 或者 go build -race 来进行竞争检测。
golang语言内部大概的实现就是同时开启多个goroutine执行同一个命令,并且纪录每个变量的状态。



所以在本质上说,goroutine的使用增加了函数的危险系数论go语言中goroutine的使用。比如一个全局变量,如果没有加上锁,我们写一个比较庞大的项目下来,就根本不知道这个变量是不是会引起多个goroutine竞争。



官网的文章Introducing the Go Race Detector给出的例子就说明了这点:



package main



import(
“time”
“fmt”
“math/rand”
)



func main() {
start := time.Now()
var t *time.Timer
t = time.AfterFunc(randomDuration(), func() {
fmt.Println(time.Now().Sub(start))
t.Reset(randomDuration())
})
time.Sleep(5 * time.Second)
}



func randomDuration() time.Duration {
return time.Duration(rand.Int63n(1e9))
}
这个例子看起来没任何问题,但是实际上,time.AfterFunc是会另外启动一个goroutine来进行计时和执行func()。
由于func中有对t(Timer)进行操作(t.Reset),而主goroutine也有对t进行操作(t=time.After)。
这个时候,其实有可能会造成两个goroutine对同一个变量进行竞争的情况。



https://blog.golang.org/race-detector



https://github.com/google/sanitizers/wiki/ThreadSanitizerAlgorithm
https://bradfitz.com/
https://github.com/google/sanitizers



Go race condition以及解决方法
形成条件
一般情况下是由于在没有加锁的情况下多个协程进行操作对同一个变量操作形成竞争条件.



如果没有锁的情况会输出结果非1001.
func main() {
c := 1
g := sync.WaitGroup{}
times := 1000
for i:=0 ; i< times; i++ {
g.Add(1)
go func() {
c++
g.Done()
}()
}
g.Wait()
fmt.Println(c)
}
原因
多核CPU操作同一变量,可能会取到’旧’的值,是一个并发导致的错误.



检查方式
对项目执行竞争检测:



$ go test -race mypkg // test the package
$ go run -race mysrc.go // compile and run the program
$ go build -race mycmd // build the command
$ go install -race mypkg // install the package
可以轻松的看到问题代码:



解决方式
方式1:使用互斥锁sync.Mutex
方式2:使用管道



使用管道的效率要比互斥锁高,也符合Go语言的设计思想.



https://github.com/google/sanitizers/wiki/ThreadSanitizerAlgorithm



https://studygolang.com/articles/5544



Go语言是如何实现race dectect的
2016-11-20



Go语言是如何实现race dectect的



1.



在写如果检测race之前,首先明白第一个问题,什么是race?



当多个goroutine同时在对同一个变量执行读和写冲突的操作时,结果是不能确定的,这就是race。比如goroutine1在读a,goroutine2在写a,如果不能确定goroutine1读到的结果是goroutine2写之前还是写之后的值,就是race了。



var x int
go func() {
v := x
}()
x = 5
上面的代码v的值到底是0,还是5呢?不知道,这段代码存在race。这是比较口头的描述,严谨的形式化的描述,就需要讲Go的内存模型。



Go的内存模型描述的是”在一个groutine中对变量进行读操作能够侦测到在其他goroutine中对该变量的写操作”的条件。



这里不展开,可以读我以前写的一些东西(TL;DR…因为年代久远又没更新,里面的示例在现在还是错的了…不过这篇文章对于理解happens before概念还是很好的)。



假设A和B表示一个多线程的程序执行的两个操作。如果A happens-before B,那么A操作对内存的影响 将对执行B的线程(且执行B之前)可见。



有了happens before这么形式化的描述之后,是否有race,等价于对于同一块内存访问,是否有存在无法判断happens before的冲突操作。即是说:



对于前面那段代码,v := x和x = 5两个操作访问了同一块内存x,并且没有任何保证v := x是happens before x = 5的,所以这段代码有race。



那么”实现race dectect”这个问题,就转化成了”happens before事件的检测问题”。



2.



如何检测到happens before事件呢?



我们可以把”哪个线程id,在什么时间,访问哪块内存,是读还是写”,只要把所有内存访问的事件都记录下来,然后遍历,验证这些操作之间的先后顺序。一旦发现,比如,读和写两条操作记录,无法满足读happens before写,就是检测到race了。



但是要记录所有的内存访问操作,看起来代价似乎有点吓人。其实只是记录可能会被并发访问的变量,并不是所有变量,下里的g是局部变量,就不需要记录了。



func f() {
g := 3
}
但是代价似乎还是很大?确实。好吧,会慢10倍还是100倍我不确定,反正线上代码是不会开race跑的。既然Go都已经做了,肯定是能做的。



需要有两部分,在Go里面-race编译选项会做相应的处理。编译部分需要在涉及到内存访问的地方插入指令来记录事件;运行时则是检测事件之间的happens before。



整个思路就是这样,再具体就是细节了。



3.



一条内存访问事件可以用8个字节来记录:16位线程id,42位时间戳,5位记内存位置,1位标记是读还是写。



线程id不用解释,读写标记也不用解释。时间戳是逻辑时钟,不是每次取真实时间。



只用5位如何记录内存位置呢?这里就有点技巧了,Go的内存管理也用到了同样的技巧。对于实际使用的一块内存区域,映射另一块”影子”内存区域,映射出来的是真实的”影子”。



比如有一个数组A[1000],它的”影子”是B[1000]。A[i]里面发生了什么事件,只在记录在B[i]里面就行了。注意两者大小不需要是一样的,比如



int A[1000]; // 真实使用的数组
char B[1000]; // 用于记录发生在A数组里面操作,如果只记读/写1位足已,记其它也不一定用到8位
同理,对于实际使用的内存区域是[0x7fffffffffff 0x7f0000000000],它的”影子”区域可以是[0x1fffffffffff 0x180000000000],5位可以表示64个单元,如果实际使用的内存使用按8字节对齐之后,是足够表示一组的。



好像有点说不明白,这么解释吧:3位可以表示8个单元的状态,对吧?2的3次方等于8



A[8个8字节的单元] => B[3位]
A里面是否发生了读或者写的操作,在B里面用位的0或1记录来下。说明只用少量内存就可以记录大量事件!



回到事件的记录格式,一条记录占8个字节,其中有5位记录内存位置。5位是可以记录64个8字节的,也就是race dectect的空间开销是使用的内存的1/8(其实不是,因为对同一内存的事件,要记录一组)。



看个例子,我们记录下了第一条事件,线程T1,在E1时间戳,访问内存区域[0 2],执行写操作:



(T1,E1,0:2,W)
第二条事件,线程T2,在E2时间戳,读内存区域[4 8]:



(T2,E2,4:8,R)
因为位置没有交集,所以没有冲突。



第三条事件,线程T3,在E3时间戳,读内存区域[0 4]:



(T3,E3,0:4,R)
这个区域是跟第一个事件的区域有交集的,那么假设E1无法满足happens before E3,那么就检测到冲突了。



完。



参考资料:https://github.com/google/sanitizers/wiki/ThreadSanitizerAlgorithm



https://studygolang.com/articles/5544
https://segmentfault.com/a/1190000020107431



第三篇 Go 官方博客译文,主要是关于 Go 内置的竞态条件检测工具。它可以有效地帮助我们检测并发程序的正确性。使用非常简单,只需在 go 命令加上 -race 选项即可。



本文最后介绍了两个真实场景下的竞态案例,第一个案例相对比较简单。重点在于第二个案例,这个案例比较难以理解,在原文的基础上,我也简单做了些补充,不知道是否把问题讲的足够清楚。同时,这个案例也告诉我们,任何时候我们都需要重视检测器给我们的提示,因为一不小心,你就可能为自己留下一个大坑。



概要
在程序世界中,竞态条件是一种潜伏深且很难发现的错误,如果将这样的代码部署线上,常会产生各种谜一般的结果。Go 对并发的支持让我们能非常简单就写出支持并发的代码,但它并不能阻止竞态条件的发生。



本文将会介绍一个工具帮助我们实现它。



Go 1.1 加入了一个新的工具,竞态检测器,它可用于检测 Go 程序中的竞态条件。当前,运行在 x86_64 处理器的 Linux、Mac 或 Windows 下可用。



竞态检测器的实现基于 C/C++ 的 ThreadSanitizer 运行时库,ThreadSanitier 在 Googgle 已经被用在一些内部基础库以及 Chromium上,并且帮助发现了很多有问题的代码。



ThreadSanitier 这项技术在 2012 年 9 月被集成到了 Go 上,它帮助检测出了标准库中的 42 个竞态问题。它现在已经是 Go 构建流程中的一部分,当竞态条件出现,将会被它捕获。



如何工作
竞态检测器集成在 Go 工具链,当命令行设置了 -race 标志,编译器将会通过代码记录所有的内存访问,何时以及如何被访问,运行时库也会负责监视共享变量的非同步访问。当检测到竞态行为,警告信息会把打印出来。(具体详情阅读 文章)



这样的设计导致竞态检测只能在运行时触发,这也意味着,真实环境下运行 race-enabled 的程序就变得非常重要,但 race-enabled 程序耗费的 CPU 和内存通常是正常程序的十倍,在真实环境下一直启用竞态检测是非常不切合实际的。



是否感受到了一阵凉凉的气息?



这里有几个解决方案可以尝试。比如,我们可以在 race-enabled 的情况下执行测试,负载测试和集成测试是个不错的选择,它偏向于检测代码中可能存在的并发问题。另一种方式,可以利用生产环境的负载均衡,选择一台服务部署启动竞态检测的程序。



开始使用
竞态检测器已经集成到 Go 工具链中了,只要设置 -race 标志即可启用。命令行示例如下:



$ go test -race mypkg
$ go run -race mysrc.go
$ go build -race mycmd
$ go install -race mypkg
通过具体案例体验下,安装运行一个命令,步骤如下:



$ go get -race golang.org/x/blog/support/racy
$ racy
接下来,我们介绍 2 个实际的案例。



案例 1:Timer.Reset
这是一个由竞态检测器发现的真实的 bug,这里将演示的是它的一个简化版本。我们通过 timer 实现随机间隔(0-1 秒)的消息打印,timer 会重复执行 5 秒。



首先,通过 time.AfterFunc 创建 timer,定时的间隔从 randomDuration 函数获得,定时函数打印消息,然后通过 timer 的 Reset 方法重置定时器,重复利用。



func main() {
start := time.Now()
var t *time.Timer
t = time.AfterFunc(randomDuration(), func() {
fmt.Println(time.Now().Sub(start))
t.Reset(randomDuration())
})



time.Sleep(5 * time.Second) }


func randomDuration() time.Duration {
return time.Duration(rand.Int63n(1e9))
}
我们的代码看起来一切正常。但在多次运行后,我们会发现在某些特定情况下可能会出现如下错误:



anic: runtime error: invalid memory address or nil pointer dereference
[signal 0xb code=0x1 addr=0x8 pc=0x41e38a]



goroutine 4 [running]:
time.stopTimer(0x8, 0x12fe6b35d9472d96)
src/pkg/runtime/ztime_linux_amd64.c:35 +0x25
time.(*Timer).Reset(0x0, 0x4e5904f, 0x1)
src/pkg/time/sleep.go:81 +0x42
main.func·001()
race.go:14 +0xe3
created by time.goFunc
src/pkg/time/sleep.go:122 +0x48
什么原因?启用下竞态检测器测试下吧,你会恍然大悟的。



$ go run -race main.go


WARNING: DATA RACE
Read by goroutine 5:
main.func·001()
race.go:14 +0x169



Previous write by goroutine 1:
main.main()
race.go:15 +0x174



Goroutine 5 (running) created at:
time.goFunc()
src/pkg/time/sleep.go:122 +0x56
timerproc()
src/pkg/runtime/ztime_linux_amd64.c:181 +0x189
==================
结果显示,程序中存在 2 个 goroutine 非同步读写变量 t。如果初始定时时间非常短,就可能出现在主函数还未对 t 赋值,定时函数已经执行,而此时 t 仍然是 nil,无法调用 Reset 方法。



我们只要把变量 t 的读写移到主 goroutine 执行,就可以解决问题了。如下:



func main() {
start := time.Now()
reset := make(chan bool)
var t time.Timer
t = time.AfterFunc(randomDuration(), func() {
fmt.Println(time.Now().Sub(start))
reset <- true
})
for time.Since(start) < 5
time.Second {
<-reset
t.Reset(randomDuration())
}
}
main goroutine 完全负责 timer 的初始化和重置,重置信号通过一个 channel 负责传递。



当然,这个问题还有个更简单直接的解决方案,避免重用定时器即可。示例代码如下:



package main



import (
“fmt”
“math/rand”
“time”
)



func main() {
start := time.Now()
var f func()
f = func() {
fmt.Println(time.Now().Sub(start))
time.AfterFunc(time.Duration(rand.Int63n(1e9)), f)
}
time.AfterFunc(time.Duration(rand.Int63n(1e9)), f)
time.Sleep(5 * time.Second)
}
代码非常简洁易懂,缺点呢,就是效率相对不高。



案例 2:ioutil.Discard
这个案例的问题隐藏更深。



ioutil 包中的 Discard 实现了 io.Writer 接口,不过它会丢弃所有写入它的数据,可类比 /dev/null。可在我们需要读取数据但又不准备保存的场景下使用。它常常会和 io.Copy 结合使用,实现抽空一个 reader,如下:



io.Copy(ioutil.Discard, reader)
时间回溯至 2011 年,当时 Go 团队注意以这种方式使用 Discard 效率不高,Copy 函数每次调用都会在内部分配 32 KB 的缓存 buffer,但我们只是要丢弃读取的数据,并不需要分配额外的 buffer。我们认为,这种习惯性的用法不应该这样耗费资源。



解决方案非常简单,如果指定的 Writer 实现了 ReadFrom 方法,io.Copy(writer, reader) 调用内部将会把读取工作委托给 writer.ReadFrom(reader) 执行。



Discard 类型增加 ReadFrom 方法共享一个 buffer。到这里,我们自然会想到,这里理论上会存在竞态条件,但因为写入到 buffer 中的数据会被立刻丢弃,我们就没有太重视。



竞态检测器完成后,这段代码立刻被标记为竞态的,查看 issues/3970。这促使我们再一次思考,这段代码是否真的存在问题呢,但结论依然是这里的竞态不影响程序运行。为了避免这种 “假的警告”,我们实现了 2 个版本的 black_hole buffer,竞态版本和无竞态版本。而无竞态版只会其在启用竞态检测器的时候启用。



black_hole.go,无竞态版本。



// +build race



package ioutil



// Replaces the normal fast implementation with slower but formally correct one.
func blackHole() []byte {
return make([]byte, 8192)
}
black_hole_race.go,竞态版本。



// +build !race



package ioutil



var blackHoleBuf = make([]byte, 8192)



func blackHole() []byte {
return blackHoleBuf
}
但几个月后,Brad 遇到了一个迷之 bug。经过几天调试,终于确定了原因所在,这是一个由 ioutil.Discard 导致的竞态问题。



实际代码如下:



var blackHole [4096]byte // shared buffer



func (devNull) ReadFrom(r io.Reader) (n int64, err error) {
readSize := 0
for {
readSize, err = r.Read(blackHole[:])
n += int64(readSize)
if err != nil {
if err == io.EOF {
return n, nil
}
return
}
}
}
Brad 的程序中有一个 trackDigestReader 类型,它包含了一个 io.Reader 类型字段,和 io.Reader 中信息的 hash 摘要。



type trackDigestReader struct {
r io.Reader
h hash.Hash
}



func (t trackDigestReader) Read(p []byte) (n int, err error) {
n, err = t.r.Read(p)
t.h.Write(p[:n])
return
}
举个例子,计算某个文件的 SHA-1 HASH。



tdr := trackDigestReader{r: file, h: sha1.New()}
io.Copy(writer, tdr)
fmt.Printf(“File hash: %x”, tdr.h.Sum(nil))
某些情况下,如果没有地方可供数据写入,但我们还是需要计算 hash,就可以用 Discard 了。



io.Copy(ioutil.Discard, tdr)
此时的 blackHole buffer 并非仅仅是一个黑洞,它同时也是 io.Reader 和 hash.Hash 之间传递数据的纽带。当多个 goroutine 并发执行文件 hash 时,它们全部共享一个 buffer,Read 和 Write 之间的数据就可能产生相应的冲突。No error 并且 No panic,但是 hash 的结果是错的。就是如此可恶。



func (t trackDigestReader) Read(p []byte) (n int, err error) {
// the buffer p is blackHole
n, err = t.r.Read(p)
// p may be corrupted by another goroutine here,
// between the Read above and the Write below
t.h.Write(p[:n])
return
}
最终,通过为每一个 io.Discard 提供唯一的 buffer,我们解决了这个 bug,排除了共享 buffer 的竞态条件。代码如下:



var blackHoleBuf = make(chan []byte, 1)



func blackHole() []byte {
select {
case b := <-blackHoleBuf:
return b
default:
}
return make([]byte, 8192)
}



func blackHolePut(p []byte) {
select {
case blackHoleBuf <- p:
default:
}
}
iouitl.go 中的 devNull ReadFrom 方法也做了相应修正。



func (devNull) ReadFrom(r io.Reader) (n int64, err error) {
buf := blackHole()
defer blackHolePut(buf)
readSize := 0
for {
readSize, err = r.Read(buf)



// other } 通过 defer 将使用完的 buffer 重新发送至 blackHoleBuf,因为 channel 的 size 为 1,只能复用一个 buffer。而且通过 select 语句,我们在没有可用 buffer 的情况下,创建新的 buffer。


结论
竞态检测器,一个非常强大的工具,在并发程序的正确性检测方面有着很重要的地位。它不会发出假的提示,认真严肃地对待它的每条警示非常必要。但它并非万能,还是需要以你对并发特性的正确理解为前提,才能真正地发挥出它的价值



https://yq.aliyun.com/articles/336467


Category golang