这里填写目录标题
Uber 工程师对真实世界并发问题的研究
最近 Uber 工程师放出一篇论文《A Study of Real-World Data Races in Golang[1]》, 作者是 Uber 的工程师 Milind Chabbi 和 Murali Krishna Ramanathan, 他们负责使用 Go 内建的 data race detector 在 Uber 内的落地, 经过 6 个多月的研究分析, 他们将 data race detector 成功落地, 并基于对多个项目的分析, 得出了一些有趣的结论。
我们知道, Go 是 Uber 公司的主打编程语言。他们对 Uber 的 2100 个不同的微服务, 4600 万行 Go 代码的分析, 发现了超过 2000 个有数据竞争的 bug, 修复了其中的 1000 多个, 剩余的正在分析修复中。
谈起真实世界中的 Go 并发 Bug, 其实 2019 年我们华人学者的《Understanding Real-World Concurrency Bugs in Go[2]》论文可以说是开山之作, 首次全面系统地分析了几个流行的大型 Go 项目的并发 bug。
今天谈的这一篇呢, 是 Uber 工程师针对 Uber 的众多的 Go 代码做的分析。我猜他们可能是类似国内工程效能部的同学, 所以这篇论文有一半的篇幅介绍 Go data race detector 是怎么落地的, 这个我们就不详细讲了, 这篇论文的另一半是基于对 data race 的分析, 罗列出了常见的出现 data race 的场景, 对我们 Gopher 同学来说, 很有学习的意义, 所以我好好拜读了一下这篇论文, 做了总结和摘要。
作为一个大厂, 肯定不止一种开发语言, 作者对 Uber 线上个编程语言(Go、Java、Nodejs、Python)进行分析, 可以看到:
- 相比较 Java, 在 Go 语言中会更多的使用并发处理
- 同一个进程中, Nodejs 平均会启动 16 个线程, Python 会启动 16-32 个线程, Java 进程一般启动 128-1024 个线程, 10%的 Java 程序启动 4096 个线程, 7%的 Java 程序启动 8192 个线程。Go 程序一般启动 1024-4096 个 goroutine, 6%的 Go 程序启动 8192 个 goroutine(原文是 8102, 我认为是一个笔误), 最大 13 万个。
可以看到 Go 程序会比其它语言有更多的并发单元, 更多的并发单元意味着存在着更多的并发 bug。Uber 代码库中都有哪些类的并发 bug 呢?
下面的介绍会使用数据竞争概念(data race), 它是并发编程中常见的概念, 有数据竞争, 意味着有多个并发单元对同一个数据资源有并发的读写, 至少有一个写, 有可能会导致并发问题。
透明地引用捕获(Transparent Capture-by-Reference)
直接翻译过来你可能觉得不知所云。Transparent 是指没有显示的声明或者定义, 就直接引用某些变量, 很容易导致数据竞争。通过例子更容易理解。这是一大类, 我们分成小类逐一介绍。
循环变量的捕获
不得不说, 这也是我最常犯的错误。虽然明明知道会有这样的问题, 但是在开发的过程中, 总是无意的犯这样的错误。
for _, job: = range jobs {
go func() {
ProcessJob(job)
}()
} // end for
比如这个简单的例子, job 是索引变量, 循环中启动了一个 goroutine 处理这个 job。job 变量就透明地被这个 goroutine 引用。
循环变量是唯一的, 意味着启动的这个 goroutine, 有可能处理的都是同一个 job, 而并不是期望的没有一个 job。
这个例子还很明显, 有时候循环体内特别复杂, 可能并不像这个例子那么容易发现。
err 变量被捕获
下面这个例子, y、z 的赋值时, 会对同一个 err 进行写操作, 也可能会导致数据竞争, 产生并发问题。
x, err: = Foo()
if err != nil {
...
}
go func() {
var y int
y, err = Bar()
if err != nil {
...
}
}()
var z string
z, err = Baz()
if err != nil {
...
}
捕获命名的返回值
下面这个例子定义了一个命名的返回值 result。可以看到 … = result(读操作)和 return 20(写操作)有数据竞争的问题, 虽然 return 20 你并没有看到对 result 的赋值。
func NamedReturnCallee()(result int) {
result = 10
if ... {
return // this has the effect of " return 10"
}
go func() {
... = result // read result
}()
return 20 // this is equivalent to result =20
}
func Caller() {
retVal: = NamedReturnCallee()
}
defer 也会有类似的效果, 下面这段代码对 err 有数据竞争问题。
func Redeem(request Entity)(resp Response, err error) {
defer func() {
resp, err = c.Foo(request, err)
}()
err = CheckRequest(request)
... // err check but no return
go func() {
ProcessRequest(request, err != nil)
}()
return // the defer function runs after here
}
Slice 相关的数据竞争
下面这个例子, safeAppend 使用锁对 myResults 进行了保护, 但是在每次循环调用(uuid, myResults)并没有读保护, 也会有竞争问题, 而且不容易发现。
func ProcessAll(uuids[] string) {
var myResults[] string
var mutex sync.Mutex
safeAppend: = func(res string) {
mutex.Lock()
myResults = append(myResults, res)
mutex.Unlock()
}
for _, uuid: = range uuids {
go func(id string, results[] string) {
res: = Foo(id)
safeAppend(res)
}(uuid, myResults) // slice read without holding lock
}
...
}
非线程安全的 map
这个很常见了, 几乎每个 Gopher 都曾犯过, 犯过才意识到 Go 内建的 map 对象并不是线程安全的, 需要加锁或者使用 sync.Map 等其它并发原语。
func processOrders(uuids[] string) error {
var errMap = make(map[string] error)
for _, uuid: = range uuids {
go func(uuid string) {
orderHandle, err: = GetOrder(uuid)
if err != nil {
errMap[uuid] = err
return
}
...
}(uuid)
return combineErrors(errMap)
}
}
传值和传引用的误用
Go 标准库常见并发原语不允许在使用后 Copy, go vet 也能检查出来。比如下面的代码, 两个 goroutine 想共享 mutex, 需要传递&mutex, 而不是 mutex。
var a int
// CriticalSection receives a copy of mutex .
func CriticalSection(m sync.Mutex) {
m.Lock()
a++
m.Unlock()
}
func main() {
mutex: = sync.Mutex {}
// passes a copy of m to A .
go CriticalSection(mutex)
}
混用消息传递和共享内存两种并发方式
消息传递常用 channel。下面的例子中, 如果 context 因为超时或者主动 cancel 被取消的话, Start 中的 goroutine 中的 f.ch <- 1 可能会被永远阻塞, 导致 goroutine 泄露。
func(f * Future) Start() {
go func() {
resp, err: = f.f() // invoke a registered function
f.response = resp
f.err = err
f.ch < -1 // may block forever !
}()
}
func(f * Future) Wait(ctx context.Context) error {
select {
case <-f.ch:
return nil
case <-ctx.Done():
f.err = ErrCancelled
return ErrCancelled
}
}
并发测试
Go 的 testing.T.Parallel() 为单元测试提供了并发能力, 或者开发者自己写一些并发的测试程序测试代码逻辑, 在这些并发测试中, 也是有可能导致数据竞争的。不要以为测试不会有数据竞争问题。
不正确的锁调用
为写操作申请读锁
下面这个例子中, g.ready 是写操作, 可是这个函数调用的是读锁。
func(g * HealthGate) updateGate() {
g.mutex.RLock()
defer g.mutex.RUnlock()
// ... several read - only operations ...
if ... {
g.ready = true // Concurrent writes .
g.gate.Accept() // More than one Accept () .
}
}
其它锁的问题
你会发现, 大家经常犯的一个"弱智"的问题, 就是 Mutex 只有 Lock 或者只有 Unlock, 或者两个 Lock, 这类问题本来你认为绝不会出现的, 在现实中却经常能看到。
还有使用 atomic 进行原子写, 但是却没有原子读。
总结
总结一下, 下表列出了基于语言类型统计的数据竞争 bug 数:
整体来看, 锁的误用是最大的数据竞争的原因。并发访问 slice 和 map 也是很常见的数据竞争的原因。
相关链接:
- https://arxiv.org/abs/2204.00764
- https://songlh.github.io/paper/go-study.pdf