Go语言:通过TDD驱动测试开发为同事写的程序优化提速——初次接触并发与channel
Posted slowlydance2me
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Go语言:通过TDD驱动测试开发为同事写的程序优化提速——初次接触并发与channel相关的知识,希望对你有一定的参考价值。
正文:
假如同事已经写了一个 CheckWebsites
的函数检查 URL 列表的状态。
package concurrency type WebsiteChecker func(string) bool func CheckWebsites(wc WebsiteChecker, urls []string) map[string]bool results := make(map[string]bool) for _, url := range urls results[url] = wc(url) return results
true
,错误响应的值为 false
。WebsiteChecker
处理单个 URL 并返回一个布尔值。它会被函数调用以检查所有的网站。package concurrency import ( "reflect" "testing" ) func mockWebsiteChecker(url string) bool if url == "waat://furhurterwe.geds" return false return true func TestCheckWebsites(t *testing.T) websites := []string "http://google.com", "http://blog.gypsydave5.com", "waat://furhurterwe.geds", actualResults := CheckWebsites(mockWebsiteChecker, websites) want := len(websites) got := len(actualResults) if want != got t.Fatalf("Wanted %v, got %v", want, got) expectedResults := map[string]bool "http://google.com": true, "http://blog.gypsydave5.com": true, "waat://furhurterwe.geds": false, if !reflect.DeepEqual(expectedResults, actualResults) t.Fatalf("Wanted %v, got %v", expectedResults, actualResults)
该功能在生产环境中被用于检查数百个网站。但是你的同事开始抱怨它速度很慢,所以他们请你帮忙为程序提速。
写一个测试
首先我们对 CheckWebsites
做一个基准测试,这样就能看到我们修改的影响。
package concurrency import ( "testing" "time" ) func slowStubWebsiteChecker(_ string) bool time.Sleep(20 * time.Millisecond) return true func BenchmarkCheckWebsites(b *testing.B) urls := make([]string, 100) for i := 0; i < len(urls); i++ urls[i] = "a url" for i := 0; i < b.N; i++ CheckWebsites(slowStubWebsiteChecker, urls)
基准测试使用一百个网址的 slice 对 CheckWebsites
进行测试,并使用 WebsiteChecker
的伪造实现。
slowStubWebsiteChecker
故意放慢速度。它使用 time.Sleep
明确等待 20 毫秒,然后返回 true。
当我们运行基准测试时使用 go test -bench=.
命令 (如果在 Windows Powershell 环境下使用 go test -bench="."
):
CheckWebsite
经过基准测试的时间为 2001269572纳秒,大约 2 秒。编写足够的代码让它通过
CheckWebsites
变得更快。doSomething()
时,我们等待它返回(即使它没有值返回,我们仍然等待它完成)。我们说这个操作是 阻塞 的 —— 它让我们等待它完成。Go 中不会阻塞的操作将在称为 goroutine 的单独 进程 中运行。将程序想象成从上到下读 Go 的 代码,当函数被调用执行读取操作时,进入每个函数「内部」。当一个单独的进程开始时,就像开启另一个 reader(阅读程序)在函数内部执行读取操作,原来的 reader 继续向下读取 Go 代码。go
声明,通过把关键字 go
放在它前面:go doSomething()
。
package concurrency type WebsiteChecker func(string) bool func CheckWebsites(wc WebsiteChecker, urls []string) map[string]bool results := make(map[string]bool) for _, url := range urls go func() results[url] = wc(url) () return results
因为开启 goroutine 的唯一方法就是将 go
放在函数调用前面,所以当我们想要启动 goroutine 时,我们经常使用 匿名函数(anonymous functions)。一个匿名函数文字看起来和正常函数声明一样,但没有名字(意料之中)。你可以在 上面的 for
循环体中看到一个。
匿名函数有许多有用的特性,其中两个上面正在使用。首先,它们可以在声明的同时执行 —— 这就是匿名函数末尾的 ()
实现的。其次,它们维护对其所定义的词汇作用域的访问权 —— 在声明匿名函数时所有可用的变量也可在函数体内使用。
上面匿名函数的主体和之前循环体中的完全一样。唯一的区别是循环的每次迭代都会启动一个新的 goroutine,与当前进程(WebsiteChecker
函数)同时发生,每个循环都会将结果添加到 results
map 中。
但是当我们执行 go test
:
-------- FAIL: TestCheckWebsites (0.00s)
CheckWebsites_test.go:31: Wanted map[http://google.com:true http://blog.gypsydave5.com:true waat://furhurterwe.geds:false], got map[]
FAIL
exit status 1
FAIL github.com/gypsydave5/learn-go-with-tests/concurrency/v1 0.010s
你可能不会得到这个结果。你可能会得到一个 panic 信息,这个稍后再谈。如果你得到的是那些结果,不要担心,只要继续运行测试,直到你得到上述结果。或假装你得到了,这取决于你。欢迎来到并发编程的世界:如果处理不正确,很难预测会发生什么。别担心 —— 这就是我们编写测试的原因,当处理并发时,测试帮助我们预测可能发生的情况。
让我们困惑的是,原来的测试 WebsiteChecker
现在返回空的 map。哪里出问题了?
for
循环开始的 goroutines
没有足够的时间将结果添加结果到 results
map 中;WebsiteChecker
函数对于它们来说太快了,以至于它返回时仍为空的 map。package concurrency import "time" type WebsiteChecker func(string) bool func CheckWebsites(wc WebsiteChecker, urls []string) map[string]bool results := make(map[string]bool) for _, url := range urls go func() results[url] = wc(url) () time.Sleep(2 * time.Second) return results
现在当我们运行测试时获得的结果(如果没有得到 —— 参考上面的做法):
-------- FAIL: TestCheckWebsites (0.00s)
CheckWebsites_test.go:31: Wanted map[http://google.com:true http://blog.gypsydave5.com:true waat://furhurterwe.geds:false], got map[waat://furhurterwe.geds:false]
FAIL
exit status 1
FAIL github.com/gypsydave5/learn-go-with-tests/concurrency/v1 0.010s
这不是很好 - 为什么只有一个结果?我们可以尝试通过增加等待的时间来解决这个问题 —— 如果你愿意,可以试试。但没什么作用。这里的问题是变量 url
被重复用于 for
循环的每次迭代 —— 每次都会从 urls
获取新值。但是我们的每个 goroutine 都是 url
变量的引用 —— 它们没有自己的独立副本。所以他们 都 会写入在迭代结束时的 url
—— 最后一个 url。这就是为什么我们得到的结果是最后一个 url。
解决这个问题:
import ( "time" ) type WebsiteChecker func(string) bool func CheckWebsites(wc WebsiteChecker, urls []string) map[string]bool results := make(map[string]bool) for _, url := range urls go func(u string) results[u] = wc(u) (url) time.Sleep(2 * time.Second) return results
通过给每个匿名函数一个参数 url(u
),然后用 url
作为参数调用匿名函数,我们确保 u
的值固定为循环迭代的 url
值,重新启动 goroutine
。u
是 url
值的副本,因此无法更改。
现在,如果你幸运的话,你会得到:
PASS
ok github.com/gypsydave5/learn-go-with-tests/concurrency/v1 2.012s
但是,如果你不走运(如果你运行基准测试,这很可能会发生,因为你将发起多次的尝试)。
fatal error: concurrent map writes
goroutine 8 [running]:
runtime.throw(0x12c5895, 0x15)
/usr/local/Cellar/go/1.9.3/libexec/src/runtime/panic.go:605 +0x95 fp=0xc420037700 sp=0xc4200376e0 pc=0x102d395
runtime.mapassign_faststr(0x1271d80, 0xc42007acf0, 0x12c6634, 0x17, 0x0)
/usr/local/Cellar/go/1.9.3/libexec/src/runtime/hashmap_fast.go:783 +0x4f5 fp=0xc420037780 sp=0xc420037700 pc=0x100eb65
github.com/gypsydave5/learn-go-with-tests/concurrency/v3.WebsiteChecker.func1(0xc42007acf0, 0x12d3938, 0x12c6634, 0x17)
/Users/gypsydave5/go/src/github.com/gypsydave5/learn-go-with-tests/concurrency/v3/websiteChecker.go:12 +0x71 fp=0xc4200377c0 sp=0xc420037780 pc=0x12308f1
runtime.goexit()
/usr/local/Cellar/go/1.9.3/libexec/src/runtime/asm_amd64.s:2337 +0x1 fp=0xc4200377c8 sp=0xc4200377c0 pc=0x105cf01
created by github.com/gypsydave5/learn-go-with-tests/concurrency/v3.WebsiteChecker
/Users/gypsydave5/go/src/github.com/gypsydave5/learn-go-with-tests/concurrency/v3/websiteChecker.go:11 +0xa1
... many more scary lines of text ...
这看上去冗长、可怕,我们需要深呼吸并阅读错误:fatal error: concurrent map writes
。有时候,当我们运行我们的测试时,两个 goroutines 完全同时写入 results
map。Go 的 Maps 不喜欢多个事物试图一次性写入,所以就导致了 fatal error
。
这是一种 race condition(竞争条件),当软件的输出取决于事件发生的时间和顺序时,因为我们无法控制,bug 就会出现。因为我们无法准确控制每个 goroutine 写入结果 map 的时间,两个 goroutines 同一时间写入时程序将非常脆弱。
Go 可以帮助我们通过其内置的 race detector 来发现竞争条件。要启用此功能,请使用 race
标志运行测试:go test -race
你应该得到一些如下所示的输出:
==================
WARNING: DATA RACE
Write at 0x00c420084d20 by goroutine 8:
runtime.mapassign_faststr()
/usr/local/Cellar/go/1.9.3/libexec/src/runtime/hashmap_fast.go:774 +0x0
github.com/gypsydave5/learn-go-with-tests/concurrency/v3.WebsiteChecker.func1()
/Users/gypsydave5/go/src/github.com/gypsydave5/learn-go-with-tests/concurrency/v3/websiteChecker.go:12 +0x82
Previous write at 0x00c420084d20 by goroutine 7:
runtime.mapassign_faststr()
/usr/local/Cellar/go/1.9.3/libexec/src/runtime/hashmap_fast.go:774 +0x0
github.com/gypsydave5/learn-go-with-tests/concurrency/v3.WebsiteChecker.func1()
/Users/gypsydave5/go/src/github.com/gypsydave5/learn-go-with-tests/concurrency/v3/websiteChecker.go:12 +0x82
Goroutine 8 (running) created at:
github.com/gypsydave5/learn-go-with-tests/concurrency/v3.WebsiteChecker()
/Users/gypsydave5/go/src/github.com/gypsydave5/learn-go-with-tests/concurrency/v3/websiteChecker.go:11 +0xc4
github.com/gypsydave5/learn-go-with-tests/concurrency/v3.TestWebsiteChecker()
/Users/gypsydave5/go/src/github.com/gypsydave5/learn-go-with-tests/concurrency/v3/websiteChecker_test.go:27 +0xad
testing.tRunner()
/usr/local/Cellar/go/1.9.3/libexec/src/testing/testing.go:746 +0x16c
Goroutine 7 (finished) created at:
github.com/gypsydave5/learn-go-with-tests/concurrency/v3.WebsiteChecker()
/Users/gypsydave5/go/src/github.com/gypsydave5/learn-go-with-tests/concurrency/v3/websiteChecker.go:11 +0xc4
github.com/gypsydave5/learn-go-with-tests/concurrency/v3.TestWebsiteChecker()
/Users/gypsydave5/go/src/github.com/gypsydave5/learn-go-with-tests/concurrency/v3/websiteChecker_test.go:27 +0xad
testing.tRunner()
/usr/local/Cellar/go/1.9.3/libexec/src/testing/testing.go:746 +0x16c
==================
细节还是难以阅读 - 但 WARNING: DATA RACE
相当明确。阅读错误的内容,我们可以看到两个不同的 goroutines 在 map 上执行写入操作:
Write at 0x00c420084d20 by goroutine 8:.
正在写入相同的内存块
Previous write at 0x00c420084d20 by goroutine 7:
最重要的是,我们可以看到发生写入的代码行:
/Users/gypsydave5/go/src/github.com/gypsydave5/learn-go-with-tests/concurrency/v3/websiteChecker.go:12
和 goroutines 7 和 8 开始的代码行号:
/Users/gypsydave5/go/src/github.com/gypsydave5/learn-go-with-tests/concurrency/v3/websiteChecker.go:11
你需要知道的所有内容都会打印到你的终端上 - 你只需耐心阅读就可以了。
Channels
我们可以通过使用 channels 协调我们的 goroutines 来解决这个数据竞争。channels 是一个 Go 数据结构,可以同时接收和发送值。这些操作以及细节允许不同进程之间的通信。
在这种情况下,我们想要考虑父进程和每个 goroutine 之间的通信,goroutine 使用 url 来执行 WebsiteChecker
函数。
package concurrency type WebsiteChecker func(string) bool type result struct string bool func CheckWebsites(wc WebsiteChecker, urls []string) map[string]bool results := make(map[string]bool) resultChannel := make(chan result) for _, url := range urls go func(u string) resultChannel <- resultu, wc(u) (url) for i := 0; i < len(urls); i++ result := <-resultChannel results[result.string] = result.bool return results
除了 results
map 之外,我们现在还有一个 resultChannel
的变量,同样使用 make
方法创建。chan result
是 channel 类型的 —— result
的 channel。新类型的 result
是将 WebsiteChecker
的返回值与正在检查的 url 相关联 —— 它是一个 string
和 bool
的结构。因为我们不需要任何一个要命名的值,它们中的每一个在结构中都是匿名的;这在很难知道用什么命名值的时候可能很有用。
现在,当我们迭代 urls 时,不是直接写入 map
,而是使用 send statement 将每个调用 wc
的 result
结构体发送到 resultChannel
。这使用 <-
操作符,channel 放在左边,值放在右边:
// send statement
resultChannel <- resultu, wc(u)
下一个 for
循环为每个 url 迭代一次。 我们在内部使用 receive expression,它将从通道接收到的值分配给变量。这也使用 <-
操作符,但现在两个操作数颠倒过来:现在 channel 在右边,我们指定的变量在左边:
// receive expression
result := <-resultChannel
然后我们使用接收到的 result
更新 map。
results
map 的时间,确保每次写入一个结果。虽然 wc
的每个调用都发送给结果通道,但是它们在其自己的进程内并行发生,因为我们将结果通道中的值与接收表达式一起逐个处理一个结果。