golang sync pakcage
1. Share Memory By Communicating
传统的线程模型(通常在编写 Java、C++ 和Python 程序时使用)程序员在线程之间通信需要使用共享内存。通常,共享数据结构由锁保护,线程将争用这些锁来访问数据。在某些情况下,通过使用线程安全的数据结构(如Python的Queue),这会变得更容易。
Go 的并发原语 goroutines 和 channels 为构造并发软件提供了一种优雅而独特的方法。Go 没有显式地使用锁来协调对共享数据的访问,而是鼓励使用 chan 在 goroutine 之间传递对数据的引用。这种方法确保在给定的时间只有一个goroutine 可以访问数据。go中非常经典的slogan
Do not communicate by sharing memory; instead, share memory by communicating.
2. Detecting Race Conditions With Go
data race 是两个或多个 goroutine 访问同一个资源(如变量或数据结构),并尝试对该资源进行读写而不考虑其他 goroutine。这种类型的代码可以创建您见过的最疯狂和最随机的 bug。通常需要大量的日志记录和运气才能找到这些类型的bug。
早在Go 1.1中,Go 工具引入了一个 race detector。竞争检测器是在构建过程中内置到程序中的代码。然后,一旦你的程序运行,它就能够检测并报告它发现的任何竞争条件。它非常酷,并且在识别罪魁祸首的代码方面做了令人难以置信的工作。下面我会举例一段会产生data race
的代码
package main
import (
"fmt"
"sync"
"time"
)
var Wait sync.WaitGroup
var Counter int = 0
func main() {
for routine := 1; routine <= 2; routine++ {
Wait.Add(1)
go Routine(routine)
}
Wait.Wait()
fmt.Printf("Final Counter: %d\n", Counter)
}
func Routine(id int) {
for count := 0; count < 2; count++ {
value := Counter
time.Sleep(1 * time.Nanosecond)
value++
Counter = value
}
Wait.Done()
}
执行上述代码会有两个goroutine会同时对Counter
变量进行读写操作,所以会发生data race的情况。
执行结果
第一次执行结果
# root @ ttlv in ~/codes/go/src/test [14:15:00]
$ ./test
Final Counter: 2
第二次执行结果
# root @ ttlv in ~/codes/go/src/test [14:15:00]
$ ./test
Final Counter: 4
第三次执行结果
# root @ ttlv in ~/codes/go/src/test [14:15:01]
$ ./test
Final Counter: 2
使用go build -race
命令
# root @ ttlv in ~/codes/go/src/test [14:18:31]
$ go build -race main.go
# root @ ttlv in ~/codes/go/src/test [14:18:34]
$ ls
main main.go
此时会多出一个叫main
的文件,我们执行一下该文件看结果
$ ./main
==================
WARNING: DATA RACE
Read at 0x00000064a9c0 by goroutine 8:
main.Routine()
/root/codes/go/src/test/main.go:23 +0x47
Previous write at 0x00000064a9c0 by goroutine 7:
main.Routine()
/root/codes/go/src/test/main.go:26 +0x74
Goroutine 8 (running) created at:
main.main()
/root/codes/go/src/test/main.go:15 +0x75
Goroutine 7 (finished) created at:
main.main()
/root/codes/go/src/test/main.go:15 +0x75
==================
Final Counter: 4
Found 1 data race(s)
我们来分析下在终端打印的堆栈信息,goroutine 7
在26行出也就是Counter = value
进行了一个写操作,此时goroutine 8
在23行也就是 value := Counter
有一个读操作,俩goroutine对变量Counter
同时进行写和读的操作因此就发生了data race
的情况。用go官方提供的工具可以很方便的定位出类似这种问题。我们看到最后Final Counter: 4
,对于这个结果,我们可以来好好的分析下,为什么这次的结果是4。
routine1完成从0加到2,routine2完成从2加到4。
我们再执行一下 ./main
==================
WARNING: DATA RACE
Write at 0x000000649fc0 by goroutine 8:
main.Routine()
/root/codes/go/src/test/main.go:26 +0x74
Previous read at 0x000000649fc0 by goroutine 7:
main.Routine()
/root/codes/go/src/test/main.go:23 +0x47
Goroutine 8 (running) created at:
main.main()
/root/codes/go/src/test/main.go:15 +0x75
Goroutine 7 (running) created at:
main.main()
/root/codes/go/src/test/main.go:15 +0x75
==================
Final Counter: 2
Found 1 data race(s)
现在Final Counter: 2,我们来分析下原因,根据上面的图我们自然而然的就能发现,出现2的原因一定是因为两个goroutine获取到的
Counter对象都是从0开始计数的,然后最后两个goroutine把最后加到2的结果互相覆盖,所以我们这次看到的结果是2。产生这个问题的根本原因还是time.Sleep(1 * time.Nanosecond)
sleep会导致go park从而导致上下文的切换,就是因为这个切换,使得goroutine从内存中取到的是旧值。
问题现在产生了,我们要想办法怎么去解决这个问题,我看见有同学提出了使用i++的解决方案,不过i++没有从本质上解决问题,因为i++在底层并非是原子操作,什么是原子操作,原子操作的意思就是不能再被分割的操作。i++这一句话其实底层的cpu其实做了三件事情
1. cpu从内存中取出值
2. 实现+1的操作
3. 把+1后的值放回原内存
正因为存在三步,所以说i++不是原子操作,你无法保证在cpu的一个核心执行这三步的过程中不会有另外的线程参与进来修改值。
package main
var i = 0
func g() int {
i++
return i
}
func main() {
_ = g()
}
执行下go tool compile -S main.go,看下上线这段代码的编译结果
0x0000 00000 (main.go:6) MOVQ "".i(SB), AX // 从内存中拷贝值
0x0007 00007 (main.go:6) INCQ AX // 实现自增
0x000a 00010 (main.go:6) MOVQ AX, "".i(SB) // 自增后的值放回原处
我还是将i++的代码贴出来
package main
import (
"fmt"
"sync"
"time"
)
var Wait sync.WaitGroup
var Counter int = 0
func main() {
for routine := 1; routine <= 2; routine++ {
Wait.Add(1)
go Routine(routine)
}
Wait.Wait()
fmt.Printf("Final Counter: %d\n", Counter)
}
func Routine(id int) {
for count := 0; count < 2; count++ {
Counter++ // 这里是改成 i++的实现的地方
time.Sleep(1 * time.Millisecond)
}
Wait.Done()
}
实际上有三行汇编代码在执行以增加计数器。这三行汇编代码看起来很像原始的Go代码。在这三行汇编代码之后可能有一个上下文切换。尽管程序现在正在运行,但从技术上讲,这个bug 仍然存在。我们的 Go 代码看起来像是在安全地访问资源,而实际上底层的程序集代码根本就不安全。所以最后我们怎么办,只能用go提供的同步语义:**Mutex**
我猜熟悉go底层数据结构的小伙伴这个时候一定会想到Single Machine Word。小伙伴很棒哦,一下子就给出了一个比较底层的解决方案。
什么是Single Machine World?中文翻译过来就是机器字。机器字的概念就是系统单次能处理的最小的数据容量。比如现在我的操作系统是Ubuntu20.04 TLS,是64位的操作系统,这就意味着我的机器字是8Byte,也就是说我单次能处理的最大的数据容量是8Byte,我刚刚好可以利用这点来进行原子赋值操作。虽然Single Machine Word的特性确实可以提供原子赋值的能力,不过我始终觉得这是一个风险很大的操作,比如说我在64bit的操作系统对一个16byte长的数据进行操作,比如说interface,在存在并发的场景下,无法保证是前一半的8Byte先写入还是后一半的8Byte被写入,所以这就不能保证原子写入了,所以说Single Machine World存在风险,还请谨慎使用。
下面是一段阐述Single Machine World存在风险这个观点的佐证代码
package main
import "fmt"
type IceCreamMaker interface {
Hello()
}
type Ben struct {
name string
}
func (b *Ben) Hello() {
fmt.Printf("Ben says,\"Hello my name is %s\"\n", b.name)
}
type Jerry struct {
name string
}
func (j *Jerry) Hello() {
fmt.Printf("Jerry says,\"Hello my name is %s\"\n", j.name)
}
func main() {
var ben = &Ben{name: "Ben"}
var jerry = &Jerry{"Jerry"}
var maker IceCreamMaker = ben
var loop0, loop1 func()
loop0 = func() {
maker = ben
go loop1()
}
loop1 = func() {
maker = jerry
go loop0()
}
go loop0()
for {
maker.Hello()
}
}
上述的代码的逻辑比较好理解,我定义了一个叫IceCreamMaker的Interface,这个Interface只有一个叫Hello的方法,然后还定义了俩Struct,一个叫Ben,另一个叫Jerry,这两个对象的除了名字不一样,内容是一样的,都只有一个叫name的字段,这两个Struct都有一个方法叫Hello,main函数有一个叫loop0和一个叫loop1的函数,通过一个for的死循环在不停的相互调用,互相打印,我们期待得到的结果一直是
Jery says,"Hello my name is Jerry"
Ben says,"Hello my name is Ben"
可以我在运行的过程中竟然出现了
Jerry says,"Hello my name is Bem"
Ben says,"Hello my name is Jerry"
出现上述的结果,用专业的术语来描述就是出现了data race
我们大概都可以猜到interface的大小绝对不止8byte,至少也是2个8byte,我们可以看下interface的底层结构
Interface由两个字段组成,分别是Type和Data,这两个字段都是uintptr,是用于指针运算的整数类型指针,指针是8byte的,interface有两个指针对象,所以就是16byte。
Type 指向实现了接口的 struct,Data 指向了实际的值。Data 作为通过 interface 中任何方法调用的接收方传递。
对于语句 var maker IceCreamMaker=ben,编译器将生成执行以下操作的代码。
当 loop1() 执行 maker=jerry 语句时,必须更新接口值的两个字段。
Go memory model 提到过: 表示写入单个 machine word 将是原子的,但 interface 内部是是两个 machine word 的值。另一个goroutine 可能在更改接口值时观察到它的内容,也就是说go routine看到的是上一个data的值。
在这个例子中,Ben 和 Jerry 内存结构布局是相同的,因此它们在某种意义上是兼容的。想象一下,如果他们有不同的内存布局会发生什么混乱?
package main
import "fmt"
type IceCreamMaker interface {
Hello()
}
type Ben struct {
Id int // 这里加了一个 字段叫id
name string
}
func (b *Ben) Hello() {
fmt.Printf("Ben says,\"Hello my name is %s and id is %d\"\n", b.name,b.Id) //这里也要答应id
}
type Jerry struct {
name string
}
func (j *Jerry) Hello() {
fmt.Printf("Jerry says,\"Hello my name is %s\"\n", j.name)
}
func main() {
var ben = &Ben{name: "Ben",Id: 1} // id赋值
var jerry = &Jerry{"Jerry"}
var maker IceCreamMaker = ben
var loop0, loop1 func()
loop0 = func() {
maker = ben
go loop1()
}
loop1 = func() {
maker = jerry
go loop0()
}
go loop0()
for {
maker.Hello()
}
}
对代码进行了一点小修改,Ben的struct加了一个叫id的字段,这样Ben和Jerry就是两个完全不同的struct,这意味着内存布局就不同了,我们现在看看会出现什么结果
Ben says,"Hello my name is Ben and id is 1"
Ben says,"Hello my name is Ben and id is 1"
Jerry says,"Hello my name is Jerry"
Jerry says,"Hello my name is Jerry"
panic: runtime error: invalid memory address or nil pointer dereference
[signal SIGSEGV: segmentation violation code=0x2 addr=0x1 pc=0x100d7a860]
goroutine 1 [running]:
fmt.(*buffer).writeString(...)
/opt/homebrew/Cellar/go/1.16.4/libexec/src/fmt/print.go:82
fmt.(*fmt).padString(0x14000108d40, 0x1, 0x100db3fb0)
/opt/homebrew/Cellar/go/1.16.4/libexec/src/fmt/format.go:110 +0x78
fmt.(*fmt).fmtS(0x14000108d40, 0x1, 0x100db3fb0)
/opt/homebrew/Cellar/go/1.16.4/libexec/src/fmt/format.go:359 +0x54
fmt.(*pp).fmtString(0x14000108d00, 0x1, 0x100db3fb0, 0x73)
/opt/homebrew/Cellar/go/1.16.4/libexec/src/fmt/print.go:446 +0x18c
fmt.(*pp).printArg(0x14000108d00, 0x100ddf0e0, 0x140001366f0, 0x73)
/opt/homebrew/Cellar/go/1.16.4/libexec/src/fmt/print.go:694 +0x7d8
fmt.(*pp).doPrintf(0x14000108d00, 0x100db9032, 0x21, 0x1400011ff08, 0x1, 0x1)
/opt/homebrew/Cellar/go/1.16.4/libexec/src/fmt/print.go:1026 +0x12c
fmt.Fprintf(0x100dfa9a8, 0x14000128008, 0x100db9032, 0x21, 0x1400011ff08, 0x1, 0x1, 0x24, 0x0, 0x0)
/opt/homebrew/Cellar/go/1.16.4/libexec/src/fmt/print.go:204 +0x54
fmt.Printf(...)
/opt/homebrew/Cellar/go/1.16.4/libexec/src/fmt/print.go:213
main.(*Jerry).Hello(0x14000114018)
/Users/ttlv/codes/go/src/test/main.go:23 +0x94
main.main()
/Users/ttlv/codes/go/src/test/main.go:43 +0x190
exit status 2
运行go run main.go
出现了一个空指针的错误,为什么会出现这个错,其实比较好理解,根本原因是两个的内存布局不一样了,Ben比Jerry多了一个id,也就是说Type是Ben,结果Data是Jerry,Jerry的内存数据结构就没有id,那这个时候自然就出现上面的错了。
继续再修改
package main
import "fmt"
type IceCreamMaker interface {
Hello()
}
type Ben struct {
name string
}
func (b *Ben) Hello() {
fmt.Printf("Ben says,\"Hello my name is %s\"\n", b.name)
}
type Jerry struct {
field1 *[5]byte
field2 int
}
func (j *Jerry) Hello() {
fmt.Printf("Jerry says,\"Hello my name is %s\"\n", j.field1)
}
func main() {
var ben = &Ben{name: "Ben"}
var jerry = &Jerry{field1:&[5]byte{},field2: 5}
var maker IceCreamMaker = ben
var loop0, loop1 func()
loop0 = func() {
maker = ben
go loop1()
}
loop1 = func() {
maker = jerry
go loop0()
}
go loop0()
for {
maker.Hello()
}
}
这里的Jerry的结构有两个字段,不过要是熟悉string底层结构的同学肯定就看出来了,这个结构就是string的底层数据结构,这段代码跑起来没有问题。底层的内存布局是一样的在某种程度上是兼容的。是不是很神奇!!!
那么如果是一个普通的指针、map、slice 可以安全的更新吗?
如果您非常熟悉这些数据类型的底层数据结构,我觉得您可以尝试用机器字的特性去完成原子赋值,我还是要阐述一个观点,就是
没有安全的 data race(safe data race)。您的程序要么没有 data race,要么其操作未定义。如果能保证
- 原子性
- 可见行
这两点那么您的代码一定是安全的,不会出现data race.
3. sync.atomic
解决data race的方式在go中有几种。这一小结来讨论一下 sync.atomic的用法,请看下面这段代码
package main
import (
"fmt"
"sync"
)
type Config struct {
a []int
}
func main() {
cfg := &Config{}
go func() {
i := 0
for {
i++
cfg.a = []int{i, i + 1, i + 2, i + 3, i + 4, i + 5}
}
}()
var wg sync.WaitGroup
for n := 0; n < 4; n++ {
wg.Add(1)
go func() {
for n := 0; n < 100; n++ {
fmt.Printf("%v\n", cfg)
}
wg.Done()
}()
}
wg.Wait()
}
有1个goroutine负责写入,有4个goroutine负责读取,很明显这段代码是一段存在data race
的代码,我们可以用go build --race
的命令去做测试
==================
WARNING: DATA RACE
Read at 0x00c00012a018 by goroutine 8:
reflect.typedmemmove()
/opt/homebrew/Cellar/go/1.16.4/libexec/src/runtime/mbarrier.go:177 +0x0
reflect.packEface()
/opt/homebrew/Cellar/go/1.16.4/libexec/src/reflect/value.go:121 +0xf0
reflect.valueInterface()
/opt/homebrew/Cellar/go/1.16.4/libexec/src/reflect/value.go:1046 +0x160
reflect.Value.Interface()
/opt/homebrew/Cellar/go/1.16.4/libexec/src/reflect/value.go:1016 +0x2d08
fmt.(*pp).printValue()
/opt/homebrew/Cellar/go/1.16.4/libexec/src/fmt/print.go:722 +0x2d0c
fmt.(*pp).printValue()
/opt/homebrew/Cellar/go/1.16.4/libexec/src/fmt/print.go:876 +0x1cf0
fmt.(*pp).printArg()
/opt/homebrew/Cellar/go/1.16.4/libexec/src/fmt/print.go:712 +0x1e4
fmt.(*pp).doPrintf()
/opt/homebrew/Cellar/go/1.16.4/libexec/src/fmt/print.go:1026 +0x264
fmt.Fprintf()
/opt/homebrew/Cellar/go/1.16.4/libexec/src/fmt/print.go:204 +0x54
fmt.Printf()
/opt/homebrew/Cellar/go/1.16.4/libexec/src/fmt/print.go:213 +0x98
main.main.func2()
/Users/ttlv/codes/go/src/sync_atmoic/atomic.go:27 +0x2c
Previous write at 0x00c00012a018 by goroutine 7:
main.main.func1()
/Users/ttlv/codes/go/src/sync_atmoic/atomic.go:19 +0xfc
Goroutine 8 (running) created at:
main.main()
/Users/ttlv/codes/go/src/sync_atmoic/atomic.go:25 +0xec
Goroutine 7 (running) created at:
main.main()
/Users/ttlv/codes/go/src/sync_atmoic/atomic.go:15 +0x74
==================
{[154280 154281 154282 154283 154284 154285]}
&{[154113 154281 154282 154283 154284 154285]}
.........
.........
Found 7 data race(s)
我随便截取了一段打印的数据,发现数据有连续的也有不连续的,连续的是我们期望得到的,不连续的就是产生了data race
的结果,当然解决这个问题的方式有很多,我相信很多同学自然而然的就会想到加锁🔐,互斥锁Mutex或者是读写锁RWMutex等等,不可否认,加这些锁使能解决问题,但是加锁毕竟会对性能产生影响,比如说上面这段代码是一个goroutine在写4个goutine在读的情况,频繁的加锁开锁对会产生性能瓶颈,所以就引出了本小结的主角,Atomic。
众所周知,Benchmark出真理,我们可以尝试用Benchmark去对比两者的性能区别
package test
import (
"sync"
`sync/atomic`
"testing"
)
type Config struct {
a []int
}
func(c *Config)T(){}
func BenchmarkAtmoic(b *testing.B){
var v atomic.Value
v.Store(&Config{})
go func() {
i := 0
for {
i++
cfg := &Config{a: []int{i, i + 1, i + 2, i + 3, i + 4, i + 5}}
v.Store(cfg)
}
}()
var wg sync.WaitGroup
for n := 0; n < 4; n++ {
wg.Add(1)
go func() {
for n := 0; n < b.N; n++ {
cfg :=v.Load().(*Config)
cfg.T()
//fmt.Printf("%v\n", cfg)
}
wg.Done()
}()
}
wg.Wait()
}
func BenchmarkMutex(b *testing.B) {
var l sync.RWMutex
var cfg *Config
go func() {
i := 0
for {
i++
l.Lock()
cfg = &Config{a: []int{i, i + 1, i + 2, i + 3, i + 4, i + 5}}
l.Unlock()
}
}()
var wg sync.WaitGroup
for n := 0; n < 4; n++ {
wg.Add(1)
go func() {
for n := 0; n < b.N; n++ {
l.RLock()
cfg.T()
//fmt.Printf("%v\n", cfg)
l.RUnlock()
}
wg.Done()
}()
}
wg.Wait()
}
为了可以看清对比的结果我去掉了打印,用一个啥逻辑都没有的函数代替。
go test -bench=. benchmark_test.go
goos: darwin
goarch: arm64
BenchmarkAtmoic-8 248052165 5.121 ns/op
BenchmarkMutex-8 907572 1438 ns/op
PASS
ok command-line-arguments 3.092s
Atmoic平均读取一次是5.121ns,Mutex平均读取一次是1438ns,量级差距直接肉眼可见。虽然是得出了结论,我们也知道Mutex确实相比之下更重。原因是什么,因为涉及到更多的goroutine之间的上下文切换pack blocking goroutine
以及唤醒goroutine
。goroutine虽然是轻量级的线程模型,但是不管怎么说频繁的上下文切换的花销还是很大。如果觉得这数据不够有说服力,那我可以用go tool trace来显示更加详细的CPU性能指标。
先来分析Atmoic
package main
import (
`fmt`
"os"
"runtime/trace"
"sync"
"sync/atomic"
)
type Config struct {
a []int
}
func main() {
f, err := os.Create("trace-atmoic.out")
if err != nil {
panic(err)
}
defer f.Close()
err = trace.Start(f)
if err != nil {
panic(err)
}
defer trace.Stop()
var v atomic.Value
v.Store(&Config{})
go func() {
i := 0
for {
i++
cfg := &Config{a: []int{i, i + 1, i + 2, i + 3, i + 4, i + 5}}
v.Store(cfg)
}
}()
var wg sync.WaitGroup
for n := 0; n < 4; n++ {
wg.Add(1)
go func() {
for n := 0; n < 1000; n++ {
cfg := v.Load().(*Config)
fmt.Printf("%v\n", cfg)
}
wg.Done()
}()
}
wg.Wait()
}
执行go run main.go
之后会在当前目录下生成一个叫trace-atmoic.out
的文件。执行go tool trace trace-atmoic.out
再来分析先来分析Mutex
package main
import (
`fmt`
"os"
"runtime/trace"
"sync"
)
type Config struct {
a []int
}
func main() {
f, err := os.Create("trace-mutex.out")
if err != nil {
panic(err)
}
defer f.Close()
err = trace.Start(f)
if err != nil {
panic(err)
}
defer trace.Stop()
var l sync.RWMutex
var cfg *Config
go func() {
i := 0
for {
i++
l.Lock()
cfg = &Config{a: []int{i, i + 1, i + 2, i + 3, i + 4, i + 5}}
l.Unlock()
}
}()
var wg sync.WaitGroup
for n := 0; n < 4; n++ {
wg.Add(1)
go func() {
for n := 0; n < 100; n++ {
l.RLock()
fmt.Printf("%v\n", cfg)
l.RUnlock()
}
wg.Done()
}()
}
wg.Wait()
}
我分别给出了Atmoic
和Mutex
的性能数据,明显加锁的Mutex
在CPU占用的时间上更加的零碎,Atmoic
占用CPU持续的时间更长而且总的时间也是更长。但是BenchMark的数据来看,Atmoic
是绝对优于Mutex
。
这一小节的主角是Atomic
所以我们要仔细分析下这个是怎么玩的。Atomic Value
主要用到Copy-On-Write
中文翻译就是写时复制。这个思路在微服务降级或者 local cache 场景中经常使用。写时复制指的是,写操作时候复制全量老数据到一个新的对象中,携带上本次新写的数据,之后利用原子替换(atomic.Value),更新调用者的变量。来完成无锁访问共享数据。copy-on-write 适合读多写少的场景
这点要切记。熟悉redis的同学是不是直接就联想到了bgsave,没错,bgsave也是``````Copy-On-Write
.
package main
import (
"sync/atomic"
"time"
)
func loadConfig() map[string]string {
return make(map[string]string)
}
func requests() chan int {
return make(chan int)
}
func main() {
var config atomic.Value // holds current server configuration
// Create initial config value and store into config.
config.Store(loadConfig())
go func() {
// Reload config every 10 seconds
// and update config value with the new version.
for {
time.Sleep(10 * time.Second)
config.Store(loadConfig())
}
}()
// Create worker goroutines that handle incoming requests
// using the latest config value.
for i := 0; i < 10; i++ {
go func() {
for r := range requests() {
c := config.Load()
// Handle request r using config c.
_, _ = r, c
}
}()
}
下面的示例展示了如何使用写时复制习惯用法维护可伸缩、频繁读取但不频繁更新的数据结构。大家有没有想过一个问题,对于上面的这段代码,如果我Store了一个v2版本的数据,对于下面读取的goroutine来说,他们不会全部都读到v2版本的数据,就是一部分出现读到了v1,一部分读到了v2,这就是使用Atomic
出现的弊端,但是如果用的是读写锁,就不会出现这样的问题。
package main
import (
"sync"
"sync/atomic"
)
func main() {
type Map map[string]string
var m atomic.Value
m.Store(make(Map))
var mu sync.Mutex // used only by writers
// read function can be used to read the data without further synchronization
read := func(key string) (val string) {
m1 := m.Load().(Map)
return m1[key]
}
// insert function can be used to update the data without further synchronization
insert := func(key, val string) {
mu.Lock() // synchronize with other potential writers
defer mu.Unlock()
m1 := m.Load().(Map) // load current value of the data structure
m2 := make(Map) // create a new value
for k, v := range m1 {
m2[k] = v // copy all data from the current object to the new one
}
m2[key] = val // do the update that we need
m.Store(m2) // atomically replace the current object with the new one
// At this point all new readers start working with the new version.
// The old version will be garbage collected once the existing readers
// (if any) are done with it.
}
_, _ = read, insert
}
为什么说是Atomic
适合读多写少的场景,核心的关键问题就在于拷贝,Copy-On-Write
每次都要拷贝一份原始数据出来,如果频繁的写就意味着要频繁的拷贝,这样就导致拷贝的成本会非常高,所以说建议在读多写少的场景下使用Atomic
.
4. Mutex
这个案例基于两个 goroutine:
goroutine 1 持有100毫秒的锁
goroutine 2 每100ms 持有一次锁
都是100ms 的周期,但是由于 goroutine 1 不断的请求锁,可预期它会更频繁的持续到锁。我们基于 Go 1.8 循环了10次,下面是锁的请求占用分布:
g1拿到了7200216
次锁🔐而g2却拿到了10
次,g2确实是在一个for循环里,而且就10次,那理论上确实是只能拿到10次锁,我想表达的观点是这两个量级查了这么多,g1是持有100毫秒的锁,g2是每100ms 持有一次锁,按照持锁的结果来看就意味着g2他不怎么能争到锁,这就意味着会出现锁饥饿的现象,这是我们要关注的重点。你的业务代码因为抢不到锁而blocking,业务逻辑就卡主了。所以1.8版本的go的Mutex其实还是有一定的问题的。以上都是我以结果为导向所阐述的观点,下面我将阐述Mutex持锁的原理,先不讲源码,源码这个等到后面我时间了再去做解析,因为源码这个东西每个版本都在变,特别是实现的细节,所以就先不要这么早就关注源码,先说清楚背后的原理。
首先,goroutine1 将获得锁并休眠100ms。当goroutine2 试图获取锁时,它将被添加到锁的队列中- FIFO 顺序,goroutine 将进入等待状态。
然后,当 goroutine1 完成它的工作时,它将释放锁。此版本将通知队列唤醒 goroutine2。goroutine2 将被标记为可运行的,并且正在等待 Go 调度程序在线程上运行.然而,当 goroutine2 等待运行时,goroutine1将再次请求锁。goroutine2 尝试去获取锁,结果悲剧的发现锁又被人持有了,它自己继续进入到等待模式。就这样不停的重复上演悲剧,所以就出现了开头的g1拿到了七百多万次锁。
我们看看几种 Mutex 锁的实现:
-
Barging. 这种模式是为了提高吞吐量,当锁被释放时,它会唤醒第一个等待者,然后把锁给第一个等待者或者给第一个请求锁的人。这就是Go 1.8的设计和反映我们之前看到的结果.
-
Handsoff. 当锁释放时候,锁会一直持有直到第一个等待者准备好获取锁。它降低了吞吐量,因为锁被持有,即使另一个 goroutine 准备获取它。
我们可以在Linux内核的mutex中找到这个逻辑: Mutex Starvation是可能的,因为mutex_lock()允许锁窃取,其中运行(或乐观spinning)任务优先于唤醒等待者而获取锁. 锁窃取是一项重要的性能优化,因为等待等待者唤醒并获得运行时间可能需要很长时间,在此期间每个人都会在锁定时停止. […]这重新引入了一些等待时间,因为一旦我们进行了切换,我们必须等待等待者再次醒来.
一个互斥锁的 handsoff 会完美地平衡两个goroutine 之间的锁分配,但是会降低性能,因为它会迫使第一个 goroutine 等待锁。
-
Spinning. 自旋在等待队列为空或者应用程序重度使用锁时效果不错。当服务员的队列为空或应用程序大量使用互斥锁时,Spinning可能很有用. Parking 和 unparking goroutines是有成本的,可能比等待下一次锁定获取Spinning慢。
Go 1.8也使用了这种策略.当尝试获取已经保持的锁时,如果本地队列为空并且处理器的数量大于1,则goroutine将spinning几次,使用一个处理器spinning将仅阻止该程序. spinning后,goroutine将停放.在程序密集使用锁的情况下,它充当快速路径. 有关如何设计锁定的更多信息 -barging, handoff, spinlock,Filip Pizlo撰写了一篇必读文章“Locking in WebKit”.
-
starvation: 在Go 1.9之前,Go正在结合barging 和 spinning 模式. 在版本1.9中,Go通过添加新的starvation模式解决了上面提到的问题, 该模式将导致在解锁模式期间进行切换. 所有等待锁定超过一毫秒的goroutine,也称为有界等待goroutine,将被标记为starvation.当标记为starvation时,解锁方法现在将锁直接交给第一个等待者.
总结:Go 1.8 使用了 Barging 和 Spining 的结合实现。当试图获取已经被持有的锁时,如果本地队列为空并且 P [理解成goroutine的队列]的数量大于1,goroutine 将自旋几次(用一个 P 旋转会阻塞程序)。自旋后,goroutine park。在程序高频使用锁的情况下,它充当了一个快速路径(fast path)
Go 1.9 通过添加一个新的饥饿模式来解决先前解释的问题,该模式将会在释放时候触发 handsoff。所有等待锁超过一毫秒的 goroutine(也称为有界等待)将被诊断为饥饿。当被标记为饥饿状态时,unlock 方法会 handsoff 把锁直接扔给第一个等待者。
在饥饿模式下,自旋也被停用,因为传入的goroutines 将没有机会获取为下一个等待者保留的锁。我们来看下最后Go1.9跑本小节刚刚开始的代码的结果
g1值拿到了57次锁,极大的降低了锁饥饿的情况的发生,非常有效。👍🏻
5. errorgroup
在最后推荐一个包,就是errorgroup
在并发请求处理的场景下特别好用。
我们把一个复杂的任务,尤其是依赖多个微服务 rpc 需要聚合数据的任务,分解为依赖和并行,依赖的意思为: 需要上游 a 的数据才能访问下游 b 的数据进行组合。但是并行的意思为: 分解为多个小任务并行执行,最终等全部执行完毕。
package main
func main() {
var a, b int
var err1, err2 error
var ch chan result
// call rpc1
go func() {
a, err1 := rpc_servive1()
ch <- result{a,err1}
}()
// call rpc2
go func() {
b, err2 := rpc_servive2()
ch <- result{b,err2}
}()
<- ch
}
上面的代码是一段伪代码,不能编译,就想说明的是用goroutine并发的去请求比如rpc服务,如果不用errorgroup
,那只能按照上面遮掩写,而且要处理每一个rpc调用的返回值和error还会特别的麻烦,就像我上面的要定一个chan
去接收。errorgroup
的做法就是把这些操作打包处理好。 现在我要讲errgroup
这个包怎么使用了。
其次是贴上代码
package main
import (
"context"
"errors"
"fmt"
"golang.org/x/sync/errgroup"
)
func main() {
g, ctx := errgroup.WithContext(context.Background())
var a []int
// 调用服务a 正常没有error
g.Go(func() error {
a = []int{0}
return nil
})
// 调用服务b异常 有error
g.Go(func() error {
a = []int{1}
return errors.New("error b")
})
err := g.Wait()
fmt.Println(a)
fmt.Println(err)
fmt.Println(ctx.Err())
}
这一看代码就和弄清楚了,对于并发调用的错误处理和服务降级,errgroup
就完全能胜任了。这个包的核心工作原理就是利用 sync.Waitgroup 管理并行执行的 goroutine。
-
并行工作流
-
误处理 或者 优雅降级
-
误处理 或者 优雅降级
-
利用局部变量+闭包
这个包虽然很好用但是并不是完美的,也有缺陷
- 比如说我在goroutine里面加了一个error的handler是直接panic,这直接一整个进程就退出了,很可怕。
- 第二个就是调用者创建了大量的goroutine,完全超出了
GOMAXPROCS
的值。 - 返回的ctx,可能会和别人串用导致大量报错,比如说用了一个已经close的ctx。
这几点其实在Kratos
中已经有对应的解决方案了,Kratos
其实也是基于原生的errgroup
包做了一些优化,解决了上面的三个问题。
6. sync.Pool
在 golang 中有一个池,它特别神奇,你只要和它有个约定,你要什么它就给什么,你用完了还可以还回去,但是下次拿的时候呢,确不一定是你上次存的那个,这个池就是 sync.Pool。
首先我们来看看这个 sync.Pool 是如何使用的,其实非常的简单。
它一共只有三个方法我们需要知道的:New、Put、Get
package main
import (
"fmt"
"sync"
)
var strPool = sync.Pool{
New: func() interface{} {
return "test str"
},
}
func main() {
str := strPool.Get()
fmt.Println(str)
strPool.Put(str)
}
-
通过
New
去定义你这个池子里面放的究竟是什么东西,在这个池子里面你只能放一种类型的东西。比如在上面的例子中我就在池子里面放了字符串。 -
我们随时可以通过
Get
方法从池子里面获取我们之前在New里面定义类型的数据。 -
当我们用完了之后可以通过
Put
方法放回去,或者放别的同类型的数据进去。
那么这个池子的目的是什么呢?其实一句话就可以说明白,就是为了复用已经使用过的对象,来达到优化内存使用和回收的目的。说白了,一开始这个池子会初始化一些对象供你使用,如果不够了呢,自己会通过new产生一些,当你放回去了之后这些对象会被别人进行复用,当对象特别大并且使用非常频繁的时候可以大大的减少对象的创建和回收的时间。所以sync.Pool 的场景是用来保存和复用临时对象,以减少内存分配,降低 GC 压力(Request-Driven 特别合适)。sync.Pool 的场景是用来保存和复用临时对象,以减少内存分配,降低 GC 压力(Request-Driven 特别合适)。