Go建议使用pass-by-value的语义,以简化逃逸分析,并为变量提供更好的栈上分配的机会,进而减少垃圾收集器的压力。 与所有对象皆为引用类型的Java不同,在Go中,对象可以是数值类型(如:结构),也可以是引用类型(如:接口)。由于没有了语法差异,这会导致诸如:sync.Mutex和sync.RWMutex等数值类型,在同步构造中被错误地使用。如果一个函数创建了一个互斥体结构,并通过数值传递(pass-by-value)给多个goroutine调用,那么这些goroutine在并发执行时,不同的互斥对象是不会在操作过程中共享内部状态的。这也就破坏了对于受保护的共享内存区域的互斥访问特性。请参见如下图4所示的代码。 图4A:由by-reference或by-pointer的方法调用所引起的数据竞争 图4B:sync.Mutex的Lock/Unlock签名。 由于Go语法在指针和数值上调用方法是相同的,因此开发人员往往会忽视m.Lock()正在处理互斥锁的副本并非指针这一问题。调用者仍然可以在互斥的数值上调用这些API。而且编译器也会透明地安排传递数值的地址。相反,如果没有此类透明度,该错误就能够会被检测到,并认定为编译器类型不匹配的错误。 据此,当开发人员意外地实现了一个方法,其中的接收者是指向结构的指针,而不是结构的数值或副本时,那么就会发生与此相反的情况。也就是说,调用该方法的多个goroutine,最终会意外地共享结构相同的内部状态。而且,调用者也不会意识到数值类型在接收者处被透明地转换为了指针类型。显然,这都是开发人员所不愿发生的。 PART 05
消息传递(通道)和共享内存的
混合使用使代码变得复杂
且易受数据竞争的影响
图5:将消息传递与共享内存混合时的数据竞争。 图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()被包装在一个select语句(第10行)中,并处于阻止状态,直到至少有一个选择arm准备就绪。 如果上下文超时,则相应的案例将Future的err字段,在第14行上记录为ErrCancelled。此时,对于err的写入与第5行对Future的相同变量的写入操作,便形成了竞争。 PART 06
Add和Done方法的错误放置
会导致数据竞争
sync.WaitGroup结构是Go的组同步结构。与C++的barrier的barrier、以及latch的构造不同,WaitGroup中参与者的数量不是在构造时被确定的,而是动态更新的。在WaitGroup对象上,Go允许进行Add(int)、Done()和Wait()三种操作。其中,Add()会增加参与者的计数,而Wait()会处于阻止状态,直到Done()被调用为count的次数(通常每个参与者一次)。由于在Go中,组同步的使用程度比Java高出1.9倍,因此WaitGroup在Go中常被广泛地使用。 在下图6中,开发人员打算创建与切片itemId里的元素数量相同的goroutine,且并发处理它们。每个goroutine在不同索引的结果切片、以及在第12行对父功能块中,记录其成功或失败的状态,直到所有的goroutine已完成。接着,它会依次访问结果中的所有元素,以计算出被成功处理的数量。 图6A:由于WaitGroup.Add()的错误放置,导致了数据竞争 为了使该代码能够正常工作,我们需要在第12行调用Wait()时,保证wg.Add(1)在调用wg.Wait()之前所执行的次数,也就是注册参与者的数量,必须等于itemIds的长度。这就意味着wg.Add(1)应该在每个goroutine之前被放置在第5行调用。但是,如果开发人员在第7行错误地将wg.Add(1)放置在了goroutine的主体中,它就无法保证在外部函数WaitGrpExample调用Wait()时,完整地执行。据此,在调用Wait()时,被注册到WaitGroup的itemId的长度就可能会变短。正是出于该原因,Wait()会被提前解除阻止。据此,WaitGrpExample函数则可以从切片结果中开始读取(即:第13行),而一些goroutine则开始并发写入同一个切片。 此外,我们还发现过早地在Waitgroup上调用wg.Done(),也会导致数据竞争。下图6B展示了wg.Done()与Go的defer语句交互的结果。当遇到多个defer语句时,代码会按照“后进先出”的顺序去执行。其中,第9行的wg.Wait()会在doCleanup()运行之前完成。即,父goroutine会在第10行去访问locationErr,而子goroutine可能仍然在延迟的doCleanup()函数内写入locationErr(为简洁起见,在此并未显示)。 图6B:由于WaitGroup.Done()的错误放置延迟语句排序,并导致了数据竞争。 PART 07
并发运行测试
会导致产品或测试代码中的数据竞争
测试是Go的内置功能。在那些后缀为_test.go的文件里,任何前缀为Test的函数,都可以测试由Go构建的系统。如果测试代码调用了API--testing.T.Parallel(),那么它将与其他同类测试并发运行。我们发现此类并发测试有时会在测试代码中、有时也会在产品代码中产生大量的数据竞争。 此外,在单个以Test为前缀的函数中,Go开发人员经常会编写许多子测试,并通过由Go提供的套件包去执行它们。Go推荐开发人员通过表驱动的测试套件习语(table-driven test suite
idiom)去编写和运行测试套件。据此,我们的开发人员在同一个测试中就编写了数十、甚至数百个可供系统并发运行的子测试。开发人员以为代码会执行串行测试,而忘记了在大型复杂测试套件中使用共享对象。此外,当产品级API在缺少线程安全(可能是因为没有需要)的情况下,被并发调用时,情况就会更加恶化。 PART 08