并发安全是最基本的常识,也是最容易忽视的前提,更考验一个工程师 enginner 的语言基本功和代码规范。

并发访问修改变量,会导致各种不可预期的结果,最严重的就是程序 panic, 比如常见的 go 语言中 map concurrent read/write panic

先来讲几个例子,老生常谈的 case, 再说说如何避免

字符串修改

下面是一个 concurrent read/write string 的例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
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

1
2
3
4
5
6
7
8
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 呢?

1
2
3
4
5
6
7
8
9
10
// 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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
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 其实是一样的

1
2
3
4
5
6
7
8
9
10
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 语言里接口的定义

1
2
3
4
5
6
7
8
9
10
// 没有方法的interface
type eface struct {
_type *_type
data unsafe.Pointer
}
// 有方法的interface
type iface struct {
tab *itab
data unsafe.Pointer
}

道理是一模一样的,只要存在并发读写,就会出现所谓的 partial write

看看 rust

1
2
3
4
5
6
7
fn main() {
let a = String::from("abc");
let b = a;

println!("{}", b);
println!("{}", a);
}

这是一段 rust 入门级代码,运行会报错:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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 学习曲线太陡。

如何保证安全

分好多层面来讲这个事情

语言

简单来讲,一把大锁足矣,一把不够,就分段锁来个100把 … 比如 statsd agent, 由于单个 agent 有把大锁,多创建几个 agent 就行了,同步不行换成异步 …

很多代码都没有严苛到一把锁就严重降低性能的程序,为了程序的正确,切忌过早优化。尤其业务代码,性能不行 asg 扩容堆机器。

CI/CD

靠工具的 linter 提示能做到一些显示的检查,包括不规范的代码什么的,都是可以的。但毕竟不是 rust 编译器检查,其实编译器也并不是万能的。

工程师

当然打铁也要自身硬,以前 c/c++ 的程序员,每写一行代码,都知道传入传出的变量,是如何构造和析构的,否则内存泄漏了都不知道。

现在更高级的语言,自带 gc 带来了开发效率的提升,但不代表工程师可以不思考了。如果真是那样,是不是哪天 AI 就可以代替程序员了,好像是的…

可能这就是高级语言的不可能三角吧,开发效率、心智负担、运行时安全

小结

这次分享就这些,以后面还会分享更多的内容,如果感兴趣,可以关注并点击左下角的分享转发哦(:

s