近年来,Uber开始采用Golang(简称Go)作为开发微服务的主要编程语言。目前,其Gomonorepo包含约5000万行代码和约2100个独特的Go服务。而且,它们都还在生长。为了实现并发,我们通常使用go关键字为函数调用添加前缀,实现异步运行调用。在Go中,这种异步函数调用称为goroutines。开发人员可以通过创建goroutine来隐藏延迟,例如,对其他服务进行IO或RPC调用。不同的goroutines可以通过消息传递和共享内存来传递数据。其中,共享内存恰好是Go中最常用的数据通信方式之一。因为goroutineGo易于程序员创建和使用,所以被认为是“轻量级”。同时,用Go写的程序一般都会比用其他语言写的程序有更强的并发性。例如,通过扫描数据中心运行的数十万个微服务实例,我们发现Go微服务的并发量可以达到Java微服务的8倍。当然,更高的并发性也意味着更多潜在的并发错误。我们经常用数据竞争(datarace)来描述当两个或多个goroutine访问同一份数据,并且其中至少有一个处于writing状态时,由于它们之间没有顺序,就会出现并发错误。总的来说,根据Go自身交互等特性,数据竞争等隐性错误是非常容易出现的,我们应该尽量避免。最近,我们开发了一个系统,专门用于使用动态数据竞争检测技术检测Uber的数据竞争。在上线后的六个月里,它在我们的Go代码库中检测到大约2,000次数据竞争。这些比赛中约有1,100场已由开发人员修复。下面,我将向您展示我们发现的各种常见数据竞争模式。Go在goroutine中通过引用透明地捕获自由变量Go中的嵌套函数(又名闭包)通过引用透明地捕获所有自由变量。程序员通常不需要在闭包语法中明确指定需要捕获哪些自由变量。这种方法不同于Java和C++。Java的lambda只按值捕获,它们有意识地避免了并发错误。另一方面,C++要求开发人员明确指出是使用数字捕获还是引用捕获。当闭包很大时,开发者不知道闭包中使用的变量是否是自由的,是否可以通过引用捕获。由于引用捕获和goroutines是并发的,Go程序可能会以无序访问自由变量而告终,因为它们无法显式执行同步。我们可以通过以下三个示例来证明这一点:示例1:循环索引捕获的变量导致数据竞争。图1A:由循环索引的变量捕获,导致数据竞争在这里,开发人员会将厚ProcessJob包装在匿名goroutine中。但是,循环索引变量的赋值是通过goroutine内部的引用来捕获的。当goroutine开始进行第一次循环迭代并访问job的变量时,父goroutine中的for循环会更新切片中相同的循环索引变量job并指向切片中的第二个元素,从而导致数据竞争。这种数据竞争可能发生在数字和引用类型上;在切片、数组和映射上;以及循环体内的读写访问。为此,Go推荐了一个编码惯用语来隐藏和私有化循环体中循环索引的变量。但是,开发人员并不总是能够遵循这一点。示例2:捕获err变量导致的数据竞争图1B:捕获err变量导致的数据竞争Go一直提倡函数有多个返回值。图1B显示了通过返回实际值和错误对象来指示是否存在错误的常见用法。可见,当且仅当错误值为nil(空)时,实际的返回值才会被认为是有意义的。因此,我们通常的做法是:将返回的错误对象赋值给一个名为err的变量,然后检查它是否为空(nilness)。但是,由于我们可以在函数体中调用多个返回错误的函数,所以程序每次都会给err变量赋多个值,然后检查它是否为空。当开发人员将这个惯用语与goroutines混合使用时,错误变量会在闭包中通过引用被捕获。因此,程序在goroutine中对err的读写访问与后续对封闭函数(或goroutine的多个实例)中的同一个err变量的读写同时运行。这会导致数据竞争。示例3:被命名返回变量捕获,导致数据竞争图1C:被命名返回变量捕获,导致数据竞争Go引入了一个称为命名返回值的语法块。命名返回变量被视为在函数顶部定义的变量,其作用域超出了函数体。没有参数的返回语句称为“裸”命名返回值。由于闭包,如果您将正常(非裸)返回与命名返回混合,或者在具有命名返回的函数中使用延迟返回,则可能会引入数据竞争。上面图1C中的NamedReturnCallee函数返回一个整数,返回变量名为result。根据这种语法,函数体的其余部分可以直接读写结果,无需额外声明。如果函数在第4行返回一个简单的return,第13行的调用者将看到返回值10,因为它在第2行被赋值为result=10。然后编译器将安排将结果复制到retVal。此外,命名返回函数可以使用第9行所示的标准返回语法。此语法使编译器复制return语句中的返回值20以分配给命名返回变量result。第6行创建一个goroutine,它捕获指定返回变量的结果。在设置这个goroutine的时候,即使是并发专家也可能认为读取第7行的结果是安全的,毕竟没有写入同一个变量,而且第9行语句返回的20是一个常量,它似乎没有触及命名的返回变量结果。但是,如前所述,在代码生成过程中,return20语句将被转换为写入结果。此时,一旦我们突然对共享结果变量进行并发读写,就会发生数据竞争。切片创建难以诊断的数据竞争切片实际上是一些动态数组和引用类型。在内部,切片包含指向底层数组的指针、它的当前长度以及底层数组可以扩展的最大容量。为了便于讨论,我们将这些变量统称为切片的元字段。切片上的一个常见操作是通过追加操作来增长它。当达到其容量限制时,代码进行新的分配(例如,将当前容量加倍)并更新其相应的元字段。当goroutines并发访问切片时,Go将通过互斥锁保护对它的访问。图2:即使有锁,切片仍然遭受数据竞争在图2中,开发人员通常假设第6行的切片已被锁定以防止数据竞争。事实上,当第14行将切片作为参数传递给不受锁保护的goroutine时,就会发生数据竞争。具体来说,goroutine调用导致切片中的元字段从调用者(第14行)复制到被调用者(第11行)。考虑到切片是引用类型,我们认为将其传递(复制)给被调用者时会发生数据竞争。不过由于slice和pointer的类型不同,毕竟meta字段是根据value进行copy的,所以这种datarace的概率很低。并发访问Go内置的、线程不安全的映射可能导致频繁的数据竞争哈希表(或映射)是Go中的内置语言功能。但是,它不是线程安全的。如果多个goroutine同时访问同一个哈希表,并且其中至少有一个goroutine试图修改哈希表(插入或删除一项),则会发生数据竞争。开发人员通常认为他们可以同时访问哈希表中的不同项。事实上,与数组或切片不同,map(哈希表)是一种稀疏数据结构,访问一个元素可能会导致访问另一个元素,如果在同一个进程中发生另一个插入或删除,那么就会引起数据竞争修改稀疏数据结构。我们观察到由并发地图访问引起的更复杂的数据竞争。这样做的原因是相同的哈希表在深层调用路径下传递,而开发人员忘记了这些调用路径是通过异步goroutines来更改哈希表的事实。图3显示了此类数据竞争的示例。图3:由于并发映射访问导致的数据竞争虽然导致数据竞争的哈希表并不是Go独有的,但由于以下原因,Go更容易出现数据竞争:由于映射是一种内置的语言结构,Go开发人员将使用映射比使用其他语言的开发人员更频繁。例如,在我们的Java存储库中,每个MLoC(百万行代码)有4,389个映射结构;在Go中,每个MLoC有5,950个映射结构,增加了1.34倍以上。与Java的get和putAPI不同,哈希表访问语法类似于数组访问语法,易于使用,但可能会意外地与随机访问数据结构混淆。在Go中,我们可以使用table[key]语法轻松查询不存在的映射元素。此语法简单地返回默认值而没有任何错误。这种容错性对于开发者在使用Go的映射时是非常友好的。Go开发人员经常在按值传递方面犯错误,并导致不平凡的数据竞争。Go建议使用按值传递语义来简化逃逸分析,并为变量提供更好的堆栈分配机会。这反过来又减轻了垃圾收集器的压力。与Java所有对象都是引用类型不同,在Go中,对象可以是数值类型(例如结构)或引用类型(例如接口)。由于没有语法差异,这可能导致诸如sync.Mutex和sync.RWMutex之类的数字类型在同步构造中被错误使用。如果一个函数创建了一个mutex结构,并按值传递(pass-by-value)给多个goroutine调用,那么当这些goroutine并发执行时,不同的mutex对象在运行状态期间不会共享内部。这也打破了对受保护共享内存区域的独占访问功能。请参见下面图4中所示的代码。图4A:由引用或指针方法调用引起的数据竞争图4B:sync.Mutex的Lock/Unlock签名由于Go语法在指针和值上调用方法是相同的,因此开发人员倾向于忽略它m.Lock()正在处理互斥量的副本不是指针的事实。调用者仍然可以在互斥值上调用这些API。并且编译器也会透明地安排传递值的地址。相反,如果没有这种透明度,错误可能会被检测为编译器类型不匹配错误。据此,当开发人员不小心实现了一种方法,其中接收者是指向结构的指针,而不是结构的值或副本,则会发生相反的情况。也就是说,调用该方法的多个goroutine最终可能会意外地共享具有相同结构的内部状态。此外,调用者不会意识到数字类型在接收者处被透明地转换为指针类型。显然,这是开发商不希望发生的事情。消息传递(通道)和共享内存的混合使用使代码变得复杂并且容易受到数据竞争的影响图5:混合使用消息传递和共享内存时的数据竞争等待准备好的通道通过Future实现的示例。我们可以通过调用Start()方法来启动Future,并通过调用Future的Wait()方法来阻止Future完成。Start()方法会创建一个goroutine来执行一个注册到Future的函数,并记录它的返回值(比如:response和err)。如第6行所示,goroutine通过在通道ch上发送消息向Wait()方法发出Future完成的信号。对称地,如第11行所示,Wait()方法块从通道获取相应的消息。在Go中,上下文携带截止日期、取消信号和其他跨越API边界和进程之间的请求范围的值。这是为微服务中的任务设置时间表的常见模式。因此,Wait()在已取消(第13行)的上下文或已完成的Future(第11行)上阻塞。此外,Wait()被包裹在一个选择语句(第10行)中并阻塞,直到至少一个选择臂准备就绪。如果context超时,对应的case在第14行记录Future的err字段为ErrCancelled,此时err的写入和第5行对Future同一变量的写入操作形成竞争。Add和Done方法的错误放置会导致同步数据竞争。WaitGroup结构是Go的组同步结构。与C++中barrier和latch的构造不同,WaitGroup的参与人数在构造时并没有确定,而是动态更新的。在WaitGroup对象上,Go允许三种操作:Add(int)、Done()和Wait()。其中,Add()会增加参与者的计数,而Wait()会阻塞,直到Done()被调用count次(通常每个参与者一次)。由于在Go中,组同步的使用是Java中的1.9倍,因此在Go中经常大量使用WaitGroup。在下面的图6中,开发人员打算创建与切片itemId中的元素一样多的goroutine,并并发处理它们。每个goroutine将其成功或失败状态记录在不同索引的结果切片中,并记录在第12行的父函数块中,直到所有goroutine完成。然后依次访问结果中的所有元素,计算有多少被成功处理。图6A:由于WaitGroup.Add()错位导致的数据竞争为了让这段代码起作用,我们需要确保在调用wg.Wait()之前执行wg.Add(1),即注册参与者的数量,必须等于itemIds的长度。这意味着wg.Add(1)应该在每个goroutine之前在第5行被调用。但是,如果开发者错误地将wg.Add(1)放在第7行的goroutinebody中,则无法保证外层函数WaitGrpExample调用Wait()时完全执行。相应地,调用Wait()时,WaitGroup中注册的itemId的长度可能会缩短。正是由于这个原因,Wait()被提早解封了。据此,WaitGrpExample函数可以从切片结果开始读取(即:第13行),并且一些goroutines开始并发写入同一个切片。此外,我们还发现过早地在Waitgroup上调用wg.Done()也会导致数据竞争。下面的图6B显示了wg.Done()与Go的defer语句交互的结果。当遇到多个defer语句时,代码会按照“后进先出”的顺序执行。其中,第9行的wg.Wait()将在doCleanup()运行之前完成。也就是说,父goroutine会在第10行访问locationErr,而子goroutine可能仍然会在延迟的doCleanup()函数中写入locationErr(为了简洁这里没有显示)。图6B:WaitGroup.Done()的错误放置会延迟语句排序并导致数据竞争并发运行测试会导致生产或测试代码中的数据竞争测试是Go的内置功能。在那些后缀为_test.go的文件中,任何以Test为前缀的函数都可以测试Go构建的系统。如果测试代码调用API--testing.T.Parallel(),它将与其他相同类型的测试同时运行。我们发现此类并发测试有时会在测试代码中创建大量数据竞争,有时会在生产代码中创建。此外,在单个Test前缀的函数中,Go开发人员通常会编写许多子测试并通过Go提供的套件包执行它们。Go建议开发人员使用表驱动测试套件习惯用法来编写和运行测试套件。相应地,我们的开发人员在同一个测试中编写了数十个甚至数百个子测试,这些子测试可以被系统并发运行。开发人员假定代码执行串行测试,而忘记在大型复杂测试套件中使用共享对象。此外,当在没有线程安全的情况下并发调用生产级API时(可能因为没有必要),情况会恶化。总结上面我们分析了Go语言中的各种数据竞争模式,并对背后的原因进行了分类。当然,不同的原因也可能相互作用和影响。下表是对各种问题的总结。图7:待分类的数据竞争以上讨论主要基于我们在Uber的Gomonorepo中发现的各种数据竞争模式,难免有些遗漏。事实上,代码的交错覆盖也可能产生数据竞争模式。希望以上的各种经验可以帮助更多的Go开发者关注并发代码的编写,考虑不同语言的特点,避免因自身编程习惯导致的并发错误。原文链接:https://eng.uber.com/data-race-patterns-in-go/译者介绍JulianChen(朱利安陈),社区编辑,拥有十多年IT项目实施经验,是擅长内部控制外部资源和风险,注重传播网络与信息安全知识和经验;持续以博文、专题、翻译等形式分享前沿技术和新知识;经常在线上和线下开展信息安全培训和讲座。
