聊聊 Go 并发安全
并发安全是最基本的常识,也是最容易忽,同时也考验一个工程师 enginner 的语言基本功和代码规范。
并发访问修改变量,会导致各种不可预期的结果,最严重的就是程序 panic, 比如常见的 go 语言中 map concurrent read/write panic
先来讲几个例子,老生常谈的 case, 再说说如何避免
字符串修改
下面是一个 concurrent read/write string 的例子
package main
import (
"fmt"
"time"
)
const (
FIRST = "WHAT THE"
SECOND = "F*CK"
)
func main() {
var s string
go func() {
i := 1
for {
i = 1 - i
if i == 0 {
s = FIRST
} else {
s = SECOND
}
time.Sleep(10)
}
}()
for {
if s == "WHAT" {
panic(s)
}
fmt.Println(s)
time.Sleep(10)
}
}
一个 goroutine 反复赋值字符串 s, 同时另外 main 去读取变量 s, 如果发现字符串读到的是 "WHAT" 就主动 panic
WHAT THE
WHAT THE
panic: WHAT
goroutine 1 [running]:
main.main()
/Users/zerun.dong/code/gotest/string.go:26 +0x11a
exit status 2
上面代码运行后,注定要 panic, 代码的主观意愿是字符串赋值是原子的,要么是 F*CK
, 要么是 WHAT THE
, 为什么会出现 WHAT
呢?
// StringHeader is the runtime representation of a string.
// It cannot be used safely or portably and its representation may
// change in a later release.
// Moreover, the Data field is not sufficient to guarantee the data
// it references will not be garbage collected, so programs must keep
// a separate, correctly typed pointer to the underlying data.
type StringHeader struct {
Data uintptr
Len int
}
在 go 语言中,字符串是由结构体 StringHeader
表示的,源码中写的清楚非并发安全,如果读取字符串时,巧好有另外一个 goroutine 只更改了 uintptr 没修改 Len, 那就会出现如上问题。
接口
再来举一个 error 接口的例子,来自我司 POI 团队。省去上下文,本质就是 error 变量并发修改导致的 panic
package main
import (
"fmt"
"github.com/myteksi/hystrix-go/hystrix"
"time"
)
var FIRST error = hystrix.CircuitError{Message:"timeout"}
var SECOND error = nil
func main() {
var err error
go func() {
i := 1
for {
i = 1 - i
if i == 0 {
err = FIRST
} else {
err = SECOND
}
time.Sleep(10)
}
}()
for {
if err != nil {
fmt.Println(err.Error())
}
time.Sleep(10)
}
}
复现 case 其实是一样的
ITCN000312-MAC:gotest zerun.dong$ go run panic.go
hystrix: timeout
panic: value method github.com/myteksi/hystrix-go/hystrix.CircuitError.Error called using nil *CircuitError pointer
goroutine 1 [running]:
github.com/myteksi/hystrix-go/hystrix.(*CircuitError).Error(0x0, 0xc0000f4008, 0xc000088f40)
<autogenerated>:1 +0x86
main.main()
/Users/zerun.dong/code/gotest/panic.go:25 +0x82
exit status 2
来看一下 go 语言里接口的定义
// 没有方法的interface
type eface struct {
_type *_type
data unsafe.Pointer
}
// 有方法的interface
type iface struct {
tab *itab
data unsafe.Pointer
}
道理是一模一样的,只要存在并发读写,就会出现所谓的 partial write, 结果就不可预期
看看 rust
fn main() {
let a = String::from("abc");
let b = a;
println!("{}", b);
println!("{}", a);
}
这是一段 rust 入门级代码,运行会报错:
ITCN000312-MAC:hello zerun.dong$ cargo run
Compiling hello v0.1.0 (/Users/zerun.dong/projects/hello)
error[E0382]: borrow of moved value: `a`
--> src/main.rs:6:20
|
2 | let a = String::from("abc");
| - move occurs because `a` has type `String`, which does not implement the `Copy` trait
3 | let b = a;
| - value moved here
...
6 | println!("{}", a);
| ^ value borrowed here after move
error: aborting due to previous error
因为变量 a 己经被 move 走了,所以程序不可以再继续使用该变量。这就是 rust ownership 所有权的概念。在编译器层面就避免了上面提到的问题,当然 rust 学习曲线太陡。
如何保证安全
分好多层面来讲这个事情
语言
简单来讲,锁够了,早年的 leveldb 就是一把大锁撸遍全场
一把足矣,不够的话,就分段锁来个100把 ... 比如 statsd agent, 由于单个 agent 有把大锁,多创建几个 agent 就行了,同步不行换成异步 ...
很多代码都没有严苛到一把锁就严重降低性能的程序,为了程序的正确,切忌过早优化。尤其业务代码,性能不行 asg 扩容堆机器。
CI/CD
靠工具的 linter 提示能做到一些显示的检查,包括不规范的代码什么的,都是可以的。但毕竟不是 rust 编译器检查,其实编译器也并不是万能的。
工程师
打铁也要自身硬,以前 c/c++ 的程序员,每写一行代码,都知道传入传出的变量,是如何构造和析构的,否则内存泄漏了都不知道。
现在更高级的语言,内置 GC 带来了开发效率的提升,但不代表工程师可以不思考了。如果真是那样,是不是哪天 AI 就可以代替程序员了,好像是的...
可能这就是高级语言的不可能三角吧,开发效率、程序性能、运行时安全。听说抖音广告部门把 go 换成 c++ 后,解决了 latency long tail 问题,同时开发效率降低了四倍:(
推荐阅读