简单说下问题:多个goroutine并发读写string,读取string(fmt.Println
和json.Marshal
)的goroutine会panic。根因是string是一个胖指针,除了pointer字段之外还有一个len字段的元数据。在给string变量赋值(拷贝)时,会逐个设置pointer和len字段,这个过程不是原子的。在有并发修改时,pointer和len就不一致了,这时就回发生问题:当len不为0,pointer为nil(0x0)时,就会panic: runtime error: invalid memory address or nil pointer dereference
。
本文首先探究下为什么golang string有这个问题,然后对比下java的string为什么没这个问题,最后介绍数据争用(data race)问题以及Golang和Rust如何避免该问题。
golang string data race的panic复现及分析
最简复现:
package main
// go run main/string_data_race_panic.go
import (
"fmt"
"time"
)
// 并发读写string,会panic
func main() {
fullPath := "init"
go func() { // goroutine 不断读取fullpath
for i := 1; i < 10000; i++ {
request(fullPath)
}
}()
for { // main goroutine会不断修改fullPath.
fullPath = ""
time.Sleep(10 * time.Nanosecond)
fullPath = "/test/test/test"
time.Sleep(10 * time.Nanosecond)
}
}
func request(c string) { // 这里传参,有一次拷贝,会做feild(string的poiner和len)的赋值
fmt.Printf("fullPath: %s\n", c)
// 或者下面的json Marshal,也会panic
// _, _ = json.Marshal(c)
}
panic内容:
panic: runtime error: invalid memory address or nil pointer dereference
[signal SIGSEGV: segmentation violation code=0x2 addr=0x0 pc=0x102310388]
goroutine 18 [running]:
fmt.(*buffer).writeString(...)
/opt/homebrew/Cellar/go/1.22.6/libexec/src/fmt/print.go:108
fmt.(*fmt).padString(0x1400011a1c0?, {0x0, 0xf})
/opt/homebrew/Cellar/go/1.22.6/libexec/src/fmt/format.go:110 +0x23c
fmt.(*fmt).fmtS(0x14000104da8?, {0x0?, 0xd0?})
/opt/homebrew/Cellar/go/1.22.6/libexec/src/fmt/format.go:359 +0x40
fmt.(*pp).fmtString(0x0?, {0x0?, 0x14000104da8?}, 0x232fb58?)
/opt/homebrew/Cellar/go/1.22.6/libexec/src/fmt/print.go:497 +0xe4
fmt.(*pp).printArg(0x1400007c000, {0x102367ae0, 0x14000010b60}, 0x73)
/opt/homebrew/Cellar/go/1.22.6/libexec/src/fmt/print.go:741 +0x314
fmt.(*pp).doPrintf(0x1400007c000, {0x10233b9e5, 0xd}, {0x14000104fb0, 0x1, 0x1})
/opt/homebrew/Cellar/go/1.22.6/libexec/src/fmt/print.go:1075 +0x2d8
fmt.Fprintf({0x10237c568, 0x14000116008}, {0x10233b9e5, 0xd}, {0x14000104fb0, 0x1, 0x1})
/opt/homebrew/Cellar/go/1.22.6/libexec/src/fmt/print.go:224 +0x54
fmt.Printf(...)
/opt/homebrew/Cellar/go/1.22.6/libexec/src/fmt/print.go:233
main.request(...)
/Users/arloor/go-actions/main/string_data_race_panic.go:31
main.main.func1()
/Users/arloor/go-actions/main/string_data_race_panic.go:15 +0x80
created by main.main in goroutine 1
/Users/arloor/go-actions/main/string_data_race_panic.go:13 +0x7c
exit status 2
关注panic信息中的这一行:
fmt.(*fmt).padString(0x1400011a1c0?, {0x0, 0xf}) // 0x0是指针地址nil,0xf是长度(15,即/test/test/test的长度)
0x0是指针地址nil,0xf是长度(15,即/test/test/test的长度)。发现len是15,尝试解引用nil指针来读底层数据,就会panic
根因分析:
为了分析这个panic的根因,先看string的定义:
// go/src/reflect/value.go
// StringHeader is the runtime representation of a string.
// ...
type StringHeader struct {
Data uintptr // 指针
Len int // 长度元数据
}
string很明确的是一个胖指针结构体。在给string变量赋值(拷贝)时,会逐个设置pointer和len字段,这个过程不是原子的。在有并发修改时,pointer和len就不一致了,这时就会发生问题:
- 赋值时 len!=0, pointer=nil:
panic: runtime error: invalid memory address or nil pointer dereference
- 赋值时 len和pointer都不为0,但是两者不匹配:会读到错误的数据,截断或读到错误数据
回顾一下golang的string类型的特征:
- string是值类型。虽然string和slice一样也是胖指针,但string的实现确保修改一个变量的内容时,这个修改对其他变量不可见(重新分配底层数据,而不是通过下标原地修改)
- string是不可变的。
作为一个java老手,“不可变对象是线程安全的”是一个基本概念。但是golang的string却在多线程数据争用中出现了问题,为什么java和golang有这样的差异?后面会讲到。
一种修复方案:使用 atomic.Value
使用 atomic.Value
包裹string。不过atomic是基于CAS的,这样的改动在 sleep 10 纳秒
的情况下,会导致CAS陷入忙等待,CPU占用率100%且阻塞住,这时候就要显式使用mutex了。
package main
import (
"fmt"
"sync/atomic"
"time"
)
func main() {
var atomicFullPath atomic.Value
atomicFullPath.Store("init")
go func() {
for i := 1; i < 10000; i++ {
request(atomicFullPath)
}
}()
for {
atomicFullPath.Store("")
time.Sleep(10 * time.Nanosecond)
atomicFullPath.Store("/test/test/test")
time.Sleep(10 * time.Nanosecond)
}
}
func request(c atomic.Value) {
println(fmt.Sprintf("fullPath: %s", c.Load().(string)))
}
java的String为什么没这个问题
首先:java赋值/传参是pass by copy of object reference
以下面的代码为例,Java的赋值和传参(非基础类型)操作可以分为两步:
String str = new String()
步骤 | 是否原子 | 备注 | |
---|---|---|---|
1 | 通过new()方法初始化对象(省略更前面的类加载、内存申请、static变量初始化、父类对象初始化) | 不原子 | 逐个初始化各个字段。在《java并发编程》中说到,不能在new()方法中泄漏this引用,因为此时的this还没有被完全初始化好 |
2 | pass by copy of object reference | 引用的拷贝都是原子的 |
另外提两个点:
- java的object其实都是object reference
- java都是值传递的,针对object reference,值传递指的是pass by copy of object reference。这个拷贝是原子的。
而golang的string胖指针是个struct,赋值时会逐个设置pointer和len字段,这个过程不是原子的。这是java String和golang string的第一个区别,但不是全部,请继续看。
其次:Java对象的final字段初始化后对所有线程可见
final
在Java中本来是变量、属性创建后不可修改的意思。JSR-133修订新增了针对 final
字段的两个“禁止重排序”规则,以保证 final
字段在构造方法执行完毕后对所有线程可见(详见Java内存模型中final字段语意)。这也是Java不可变对象是线程安全的根本原因。
JSR-133修订还给 volatile
关键词增加了“顺序一致性”的保证,一个典型的场景是使用双重检验锁 + static volatile
来保证顺序一致性(防止读到未完全初始化的对象)和可见性(读到最新的值)。总之JSR-133修订是对Java内存模型一次重要的修订。
一切的罪魁祸首:数据争用(data race)
Rust的一个文档Data Races and Race Conditions介绍了data race(数据争用)和 race condition(竞态条件)。引用Rust文档中对data race的定义:
Safe Rust guarantees an absence of data races, which are defined as:
- two or more threads concurrently accessing a location of memory
- one or more of them is a write
- one or more of them is unsynchronized
这意味着如果要在golang中完全避免数据争用,需要对某个data的全部并发访问都上锁。这无疑是困难的,现实是代码里data race到处可见( go build
加上 -race
,运行时会一直panic)。在The Go Memory Model(golang内存模型)中,全篇都在说如何使用channel、lock、atomic、once等同步手段实现data-race-free的golang程序,有兴趣可以看下
但是rust这门优秀的语言天生避免了这个问题(不使用unsafe rust的前提下),其实现机制如下:
- 值的可变引用只能有一个(所有权机制),只有可变引用可以修改值
- 需要跨线程传递/同步的值需要满足
send + sync
约束,实现方式是包裹Arc<Mutex<YourData>>
。编译器强制你包裹Mutex,否则编译都通不过。——Rust代码只要可以编译,运行时就不大会出离谱的问题。
除了data race,还有race condition竞态条件,这需要通过临界区保护,详见Data Races and Race Conditions,本文不展开。