Uber工程师对真实世界并发问题的研究

标签: Go | 发表时间:2022-04-07 08:16 | 作者:
出处:https://colobu.com/

今天Uber工程师放出一篇论文(A Study of Real-World Data Races in Golang]( https://arxiv.org/abs/2204.00764)),作者是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论文可以说是开山之作,首次全面系统地分析了几个流行的大型Go项目的并发bug。今天谈的这一篇呢,是Uber工程师针对Uber的众多的Go代码做的分析。我猜他们可能是类似国内工程效能部的同学,所以这篇论文有一半的篇幅介绍Go data race detector是怎么落地的,这个我们就不详细讲了,这篇论文的另一半是基于对data race的分析,罗列出了常见的出现data race的场景,对我们Gopher同学来说,很有学习的意义,所以我晚上好好拜读了一下这篇论文,做一总结和摘要。

作为一个大厂,肯定不止一种开发语言,作者对Uber线上个编程语言(go、java、nodejs、python)进行分析,可以看到:

  1. 相比较Java, 在Go语言中会更多的使用并发处理
  2. 同一个进程中,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是指没有显示的声明或者定义,就直接引用某些变量,很容易导致数据竞争。通过例子更容易理解。这是一大类,我们分成小类逐一介绍。

循环变量的捕获

不得不说,这也是我最常犯的错误。虽然明明知道会有这样的问题,但是在开发的过程中,总是无意的犯这样的错误。

     
1
2
3
4
5
     
for _ , job := range jobs {
go func () {
ProcessJob ( job )
}()
} // end for

比如这个简单的例子,job是索引变量,循环中启动了一个goroutine处理这个job。job变量就透明地被这个goroutine引用。

循环变量是唯一的,意味着启动的这个goroutine,有可能处理的都是同一个job,而并不是期望的没有一个job。

这个例子还很明显,有时候循环体内特别复杂,可能并不像这个例子那么容易发现。

err变量被捕获

Go允许返回值赋值给多个变量,通常其中一个变量是error。 x, err := m, n意味着声明和定义left hand side(LHS)变量,如果变量还没有声明过的话,那就是定义了一个新的变量,但是如果变量已声明过得话,那就是对已有变量的重新赋值。

下面这个例子,y,z的赋值时,会对同一个err进行写操作,也可能会导致数据竞争,产生并发问题。

     
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
     
x , err := Foo ()
if err != nil {
...
}
go func () {
y , err := Bar ()
if err != nil {
...
}
}()
z , err := Baz ()
if err != nil {
...
}

捕获命名的返回值

下面这个例子定义了一个命名的返回值 result。可以看到 ... = result(读操作)和 return 20 (写操作)有数据竞争的问题,虽然 return 20你并没有看到对result的赋值。

     
1
2
3
4
5
6
7
8
9
10
11
12
13
14
     
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有数据竞争问题。

     
1
2
3
4
5
6
7
8
9
10
11
12
     
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)并没有读保护,也会有竞争问题,而且不容易发现。

     
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
     
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等其它并发原语。

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

     
1
2
3
4
5
6
7
8
9
10
11
12
13
     
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 )
go CriticalSection ( mutex )
}

混用消息传递和共享内存两种并发方式

消息传递常用channel。下面的例子中,如果context因为超时或者主动cancel被取消的话,Start中的goroutine中的 f.ch <- 1可能会被永远阻塞,导致goroutine泄露。

     
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
     
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是写操作,可是这个函数调用的是读锁。

     
1
2
3
4
5
6
7
8
     
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进行原子写,但是却没有原子读。

我认为这里Uber工程师并没有全面详细的介绍使用锁常见的一些陷阱,推荐你学习极客时间中的 Go 并发编程实战课课程,此课程详细介绍了每个并发原语的陷阱和死锁情况。

总结

总结一下,下表列出了基于语言类型统计的数据竞争bug数:

整体来看,锁的误用是最大的数据竞争的原因。并发访问slice和map也是很常见的数据竞争的原因。

相关 [uber 工程师 真实世界] 推荐:

Uber工程师对真实世界并发问题的研究

- - 鸟窝
我们知道,Go是Uber公司的主打编程语言. 他们对Uber的2100个不同的微服务,4600万行Go代码的分析,发现了超过2000个的有数据竞争的bug, 修复了其中的1000多个,剩余的正在分析修复中. 谈起真实世界中的Go并发Bug,其实2019年我们华人学者的 Understanding Real-World Concurrency Bugs in Go论文可以说是开山之作,首次全面系统地分析了几个流行的大型Go项目的并发bug.

Chaperone:来自Uber工程师团队的Kafka监控工具

- -
发布了开源项目Chaperone(中文意为监护人),这是一个. 在Uber,它被用于监控多个数据中心和大容量Kafka集群中数据丢失、延迟以及重复的问题. Uber现在的Kafka数据管道跨越了多个数据中心. Uber的各个系统会生成大量服务调用和事件的日志信息. 这些服务在多个数据仓库间以多活模式运行.

Uber 是如何利用大数据的

- - 博客 - 伯乐在线
这篇文章概述了 Uber 是如何利用大数据分析实现商业上的成功. 文章首次发表于作者在 Data Science Central 的专栏中. Uber 是一款基于智能手机应用的出租车预定服务,将需要出行的用户和愿意提供驾驶服务的司机联结起来. 由于传统出租车的司机认为这破坏了他们的生计,而且大众对 Uber 对司机在管理上的不足也有所顾虑,这项服务已经引起了巨大的争议.

关于Uber机制的思考

- - KantHouse 追从本心,笑斩荆棘
昨天写了一篇关于滴滴打车改版的文章(文章链接),引发了一些关于Uber和滴滴的对比讨论. 质疑明显歪了楼,大家主要讨论的是,Uber忽略目的地的问题和它的派单机制,而我在昨天文章中说的是Uber的首页设计的一些问题,具体来说,是它的出发地和开始用车的按钮不在一起的问题,以及出发地带搜索icon带来误解的问题.

真实世界中最快的浏览器是哪个?你猜

- Marvin - 36氪
Compuware 公司的 Gomez 部门刚刚发布了一组新数据,根据他们的一个网站测试项目来判断哪个浏览器对普通桌面用户来说是速度最快的. 项目只针对使用宽带的用户和测试网页加载时间. 所有数据收集的时间框架是一个月,共收集了超过 200 个网站 18.6 亿个别值,测试结果. 就像你想到的那样,是 Chrome.

Uber 在运营策略上到底厉害在哪?

- - 知乎每日精选
感谢各位知友提醒,博客昨两天访问量太大,一下子冲挂了. 已经升级服务器配置,现在可以正常访问了:). 看不下去了,一些事实+各种吹捧+美化缺点+你学会了吗. 明显的朋友圈文章居然有700多人点赞,特别是还有一个敬佩的前辈点赞,心碎……我觉得真的要学习的话,应该在好的地方辩证思考,在不好的地方承认并且想解决办法,而不是一通乱夸.

Uber火了!它改变了哪些营销游戏规则?

- - 互联网的那点事
一面是专车司机揽客被抓罚款弄得人尽皆知,一面又被媒体视为宠儿上着各大媒体、自媒体的头条要闻. 作为与Airbnb、facebook等同样令人瞩目的创新先锋,为了拉动车源和客源,Uber表现出了许多灵光乍现的创意,如“一键呼叫英雄”、“一键叫高管”、“一键叫人力三轮”、“打船”等,那么除了被媒体曝光的看的见的那些创意噱头,还有哪些Uber修炼的真功夫值得市场营销者学习借鉴的呢.

想要复制 Uber 的成功,你得先知道这些

- - TECH2IPO创见
本文来源: Medium , 译文创见首发 由 TECH2IPO / 创见 阿沫 编译 转载请注明出处. Uber 的成功无疑让创业圈中不少人看到了新商机,一时间「共享经济」的热潮席卷全球,催生了各种各样「XX 领域的 Uber」——只要核心业务带着点分享性质,创业者们都乐意将自己的产品冠上和 Uber 相关的称号,仿佛这层关联性,能让自家产品离成功更近一些.

Uber容错设计与多机房容灾方案

- - 互联网 - ITeye博客
此文是根据赵磊在【QCON高可用架构群】中的分享内容整理而成. 赵磊,Uber高级工程师,08年上海交通大学毕业,曾就职于微软,后加入Facebook主要负责Messenger的后端消息服务. 这个系统在当时支持Facebook全球5亿人同时在线. 目前在Uber负责消息系统的构建并推进核心服务在高可用性方向的发展.

Uber 四年时间增长近 40 倍,背后架构揭秘

- - 博客 - 伯乐在线
据报道,Uber 仅在过去4年的时间里,业务就激增了 38 倍. Uber 首席系统架构师 Matt Ranney 在一个非常有趣和详细的访谈《可扩展的 Uber 实时市场平台》中告诉我们 Uber 软件是如何工作的. 本次访谈中没有涉及你可能感兴趣的峰时定价(Surge pricing,译注:当Uber 平台上的车辆无法满足大量需求时,将提升费率来确保乘客的用车需求).