9.2.2 goroutine 与性能 在了解了goroutine的运作方式之后,接下来我们要考虑的就是如何通过 goroutine 来提高性能。本节在进行性能测试时将沿用上一节定义的print1goPrint1等函数,但为了避免这些函数在并发执行时输出一些乱糟糟的结果,这次我们将把代码中的fmt.Println语句注释掉。代码清单9-4展示了为printl 函数和goPrint1函数设置的基准测试用例,这些用例定义在 goroutine_test.go文件中。 代码清单 9-4 为无goroutine和有goroutine的函数分别创建基准测试用例 func BenchmarkPrintl(b*testing.B){ 对顺序执行的函数进行基 for i := 0; i < b.N; i++ { print1() 准测试 } fune BenchmarkGoPrintl(b *testing.B) { 对以goroutine形式执行的 for i := 0; i < b.N; i++ { 函数进行基准测试 goPrint1() 在使用以下命令进行性能基准测试并跳过功能测试之后: go test -run x-bench.-cpu1我们将看到以下结果: BenchmarkPrintl 100000000 13.9 ns/op BenchmarkGoPrintl 1000000 1090 ns/op (运行这个测试只使用了单个 CPU,具体原因本章稍后将会说到。)正如结果所示,函数 print1 运行得非常快,只使用了13.9ns。令人感到惊讶的是,在使用 goroutine 运行相同函数时,程序的速度居然慢了如此之多,足足耗费了 1090ns!出现这种情况的原因在于“天下没有免费的午餐”:无论 goroutine 有多么的轻量级,启动 goroutine还是有一定的代价的。因为 printNumbers1 函数和 printLettersl 函数是如此简单,它们执行的速度是如此快,所以以goroutine 方式执行它们反而会比顺序执行的代价更大。 如果我们对每次迭代都带有一定延迟的printNumbers2函数和printLetters2函数执行类似的测试,结果又会如何呢?代码清单9-5 展示了 goroutine_test.go 文件中为以上两个函数设置的基准测试用例。 代码清单9-5为无goroutine 和有 goroutine的带延迟函数分别创建基准测试用例 for i := 0; i < b.N; i++ { func BenchmarkPrint2(b *testing.B){对顺序执行的函数进行基 print2() 准测试 } goPrint2() for i:=0;i<b.N; i++ { func BenchmarkGoPrint2(b *testing.B) { 函数进行基准测试对以goroutine形式执行的100次而不是 10 次 下面是这次基准测试的结果: BenchmarkPrint1 20000000 86.7 ns/op BenchmarkGoPrint1 1000000 1177 na/op BenchmarkPrint2 BenchmarkGoPrint22000100000017564 ns/op1184572 ns/op 在这次基准测试中,prin测时间13got度跟上一次相比没有出现太大变化。另一方面,通过延迟模拟负载的函数的测试结果变化非常之大-以顺序方式执行的函数和以goroutine式之间,者的执行时间相差了67之多。因为这次基准测试的迭代次数比之前增加了10,print进测时的速度差不多是上次的110ornt1的时间却几乎是相同的。 注意,到目前为止,我们都是在用一个 CPU 执行测试,但如果我们执行以下命令,改用两个CPU执行带有100次迭代的基准测试: go test -run x -bench . -cpu 2那么我们将得到以下结果: BenchmarkPrint1-2 20000000 87.3 ns/op BenchmarkGoPrint1-2 5000000 391 ns/op BenchmarkPrint2-2 1000 1217151 ns/op BenchmarkGoPrint2-2 200000 8607 ns/op 因为print1函数以顺序方式执行,无论运行时环境提供多少个 CPU,它都只能使用一个 CPU,所以它这次的测试结果跟上一次的测试结果基本相同。与此相反,goPrint1函数这次因为使用了两个CPU来分担计算负载,所以它的性能提高了将近3倍。此外,因为print2也只能使用一个CPU,所以它这次的测试结果也跟预料中的一样,并没有发生什么变化。最后,因为goPrint2使用了两个CPU来分担计算负载,所以它这次的测试比之前快了两倍。现在,如果我们更进一步,使用4个CPU来运行相同的基准测试,结果将会如何? BenchmarkPrint1-4 BenchmarkGoPrintl-4300000020000000479 ns/op90.6 ns/op BenchmarkPrint2-4 BenchmarkGoPrint2-41000300000正如我们预期的那样,print1数和print2数的测结果还是一如既往地没有发生什6193 ns/op1272672 ns/op 么变化。但令人惊奇的是,尽管goPrint1在使用4个CPU时的测试结果还是比只使用一个CPU时的测试结果要好,但使用4CU执然比使用两CPU执行度要慢。与此同时,虽然只有40%的提升,但goPrint2在使用4个CPU时的成绩还是比使用2个 CPU 时的
在运行这一基准测试之后,我们将得到以下结果: BenchmarkPrint2 10000 121384 ns/op BenchmarkGoPrint2 1000000 17206 ns/op 这次的测试结果跟上一次的测试结果有些不同。可以看到,以 goroutine 方式执行 print Numbers2 和printLetters2的速度是以顺序方式执行这两个函数的速度的差不多7倍。现在,让我们把函数的迭代次数从10次改为100次,然后再运行相同的基准测试: func printNumbers2() { for i :=0; i<100; i++ { 迭代100 次而 // fmt.Printf("ld ", i) time.Sleep(1timeMicrosecond)不是10次 } func printlettera20( for i :- 'A'; 1 <'A'+100: 1++ ( // fmt.Printf("$o ",1) time.Sleep(1time.Micronecond)迭代 100次而不是 10 次 下面是这次基准测试的结果: BenchmarkPrint1 20000000 86.7 ns/op BenchmarkGoPrint1 1000000 1177 na/op BenchmarkPrint2 BenchmarkGoPrint22000100000017564 ns/op1184572 ns/op 在这次基准测试中,prin测时间13got度跟上一次相比没有出现太大变化。另一方面,通过延迟模拟负载的函数的测试结果变化非常之大-以顺序方式执行的函数和以goroutine式之间,者的执行时间相差了67之多。因为这次基准测试的迭代次数比之前增加了10,print进测时的速度差不多是上次的110ornt1的时间却几乎是相同的。 注意,到目前为止,我们都是在用一个 CPU 执行测试,但如果我们执行以下命令,改用两个CPU执行带有100次迭代的基准测试: go test -run x -bench . -cpu 2那么我们将得到以下结果: BenchmarkPrint1-2 20000000 87.3 ns/op BenchmarkGoPrint1-2 5000000 391 ns/op BenchmarkPrint2-2 1000 1217151 ns/op BenchmarkGoPrint2-2 200000 8607 ns/op 因为print1函数以顺序方式执行,无论运行时环境提供多少个 CPU,它都只能使用一个 CPU,所以它这次的测试结果跟上一次的测试结果基本相同。与此相反,goPrint1函数这次因为使用了两个CPU来分担计算负载,所以它的性能提高了将近3倍。此外,因为print2也只能使用一个CPU,所以它这次的测试结果也跟预料中的一样,并没有发生什么变化。最后,因为goPrint2使用了两个CPU来分担计算负载,所以它这次的测试比之前快了两倍。现在,如果我们更进一步,使用4个CPU来运行相同的基准测试,结果将会如何? BenchmarkPrint1-4 BenchmarkGoPrintl-4300000020000000479 ns/op90.6 ns/op BenchmarkPrint2-4 BenchmarkGoPrint2-41000300000正如我们预期的那样,print1数和print2数的测结果还是一如既往地没有发生什6193 ns/op1272672 ns/op 么变化。但令人惊奇的是,尽管goPrint1在使用4个CPU时的测试结果还是比只使用一个CPU时的测试结果要好,但使用4CU执然比使用两CPU执行度要慢。与此同时,虽然只有40%的提升,但goPrint2在使用4个CPU时的成绩还是比使用2个 CPU 时的成绩要好。使用更多能下降之提的一多个CPU上调度和运行任务需要耗费一定的资源,如果使用多个CPU带来的性能优势不足以抵消随之而来的额外消耗,那么程序的性能就会不升反降。 从上述测试我们可以看出,增加代码,并对其进行基准测试,以了解它的性能特质。 9.2.3 等待 goroutine 在上一节中,我们了解到程序启动的orouine暴地结束,虽然通过 Sleep增时间免题,但这说到底只是一种权宜之计,并没有真正地解决问题。虽然在实际的代码中,程序本身比goroutine更早结束的情况并不多见,但为了避免意外,我们还是需要有一种机制,使程序可以在确保所有goroutine都已经执行完毕的情况下。再执行下一项工作。 为此,Go语言在sync包中提供了一种名为等待组(waitGroup)的机制,它的运作方式非常简单直接: ■声明一个等待组; 使用Add方法为等待组的计数器设置值; 1当一个goroutine完成它的工作时,使用Done方法对等待组的计数器执行减一操作;■调用Wait方法,该方法将一直阻塞,直到等待组计数器的值变为0。 代码清单 9-6 展示了一个使用等待组的例子,在这个例子中,我们复用了之前展示过的 printNumbers2 函数以及printLetters2 函数,并为它们分别加上了1us的延迟。 代码清单 9-6 使用等待组 package main import "fmt" import "time" import "sync" func printNumbers2(wgsync.WaitGroup){ for i := 0; i < 10; i++ { time.Sleep(1 * time.Microsecond) fmt.Printf("*d ",i) wg.Done() 对计数器执行 } 减一操作 func printLetters2(wg *sync.WaitGroup){ for i := 'A'; i <'A'+10; i++ { time.Sleep(1 *time.Microsecond) fmt.Printf("'c ",i) wg.Done() 对计数器执行减一操作 func main() ( var wg sync.WaitGroup wg.add(2) go printNumbers2(&wg) 为计数器设置值 声明一个等待组 go printletters2(&wg) wg.Wait() 阻塞到计数器的值为0 如果我们运行这个程序,那么它将巧妙地打印出0A1B2C3D4E5F6G7H8 I9J。这个程序的运作原理是这样的:它首先定义一个名为wg的WaitGroup变量,然后通过调用wg的Add方法将计数器的值设置成2;在此之后,程序会分别调用printNumbers2和 printletters2这两个goroutine,而这两个goroutine都会在末尾对计数器的值执行减一操作。之后程序会调用等待组的wait方法,并因此而被阻塞,这一状态将持续到两个 goroutine 都执行完毕并调用 Done方法为止。当程序解除阻塞状态之后,它就会跟平常一样,自然地结束。 如果我们在某个 goroutine里面忘记了对计数器执行减一操作,那么等待组将一直阻塞,直到运行时环境发现所有 goroutine都已经休眠为止,这时程序将引发一个panic: 6A1B2C3D4E5F6G7H819Jfatal error: all goroutines are asleep- deadlock!等待组这一特性不仅简单,而且好用,它对并发编程来说是一种不可或缺的工具。