同步
同步
我们想做一个可以安全并发使用的计数器。
我们将从一个不安全的计数器开始,并验证它在单线程环境中的行为是否正常。
然后我们会用多个 goroutine 来测试它的不安全性并修复它。
首先写测试
我们希望 API 提供一个方法来递增计数器,然后检索它的值。
func TestCounter(t *testing.T) {
t.Run("incrementing the counter 3 times leaves it at 3", func(t *testing.T) {
counter := Counter{}
counter.Inc()
counter.Inc()
counter.Inc()
if counter.Value() != 3 {
t.Errorf("got %d, want %d", counter.Value(), 3)
}
})
}
尝试运行测试
./sync_test.go:9:14: undefined: Counter
为要运行的测试编写最少的代码,并检查失败的测试输出
让我们来定义 Counter
.
type Counter struct {
}
再试一次,将会出现以下错误
./sync_test.go:14:10: counter.Inc undefined (type Counter has no field or method Inc)
./sync_test.go:18:13: counter.Value undefined (type Counter has no field or method Value)
为了最终进行测试,我们可以定义这些方法
func (c *Counter) Inc() {
}
func (c *Counter) Value() int {
return 0
}
现在应该可以运行了,并且会失败,失败信息如下
=== RUN TestCounter
=== RUN TestCounter/incrementing_the_counter_3_times_leaves_it_at_3
--- FAIL: TestCounter (0.00s)
--- FAIL: TestCounter/incrementing_the_counter_3_times_leaves_it_at_3 (0.00s)
sync_test.go:27: got 0, want 3
写足够的代码让测试通过
对于我们这样的 Go 专家来说,这应该是微不足道的. 我们需要在数据类型中为计数器保留一些状态,然后在每次 Inc
调用时增加它
type Counter struct {
value int
}
func (c *Counter) Inc() {
c.value++
}
func (c *Counter) Value() int {
return c.value
}
重构
没有太多要重构的地方,但是考虑到我们将围绕 Counter
编写更多的测试,我们将编写一个小的断言函数 assertCount
,这样测试就会读得更清楚一些。
t.Run("incrementing the counter 3 times leaves it at 3", func(t *testing.T) {
counter := Counter{}
counter.Inc()
counter.Inc()
counter.Inc()
assertCounter(t, counter, 3)
})
func assertCounter(t *testing.T, got Counter, want int) {
t.Helper()
if got.Value() != want {
t.Errorf("got %d, want %d", got.Value(), want)
}
}
下一步
这很简单,但现在我们有一个要求,即在并发环境中使用它必须是安全的. 我们将需要编写一个失败的测试来运行它。
首先写测试
t.Run("it runs safely concurrently", func(t *testing.T) {
wantedCount := 1000
counter := Counter{}
var wg sync.WaitGroup
wg.Add(wantedCount)
for i:=0; i<wantedCount; i++ {
go func(w *sync.WaitGroup) {
counter.Inc()
w.Done()
}(&wg)
}
wg.Wait()
assertCounter(t, counter, wantedCount)
})
这将循环通过我们的 wantedCount
并调用一个 goroutine 来调用 counter.Inc()
。
我们使用 sync.WaitGroup,这是同步并发进程的一种方便的方式。
一个 WaitGroup 等待一组 goroutine 的完成。主 goroutine 调用 Add 来设置等待的 goroutine 的数量。然后每一个goroutines 运行并在完成时调用 Done。同时,可以使用 Wait 来阻塞,直到所有的 goroutines 完成。
通过等待 wg.Wait()
结束后再进行断言,我们可以确定所有的 goroutines 都尝试过 Inc
的 Counter
。
尝试运行测试
=== RUN TestCounter/it_runs_safely_in_a_concurrent_envionment
--- FAIL: TestCounter (0.00s)
--- FAIL: TestCounter/it_runs_safely_in_a_concurrent_envionment (0.00s)
sync_test.go:26: got 939, want 1000
FAIL
测试 可能 会因为不同的数字而失败,但是它证明了当多个 goroutine 试图同时改变计数器的值时,它是无效的。
写足够的代码让测试通过
一个简单的解决方案是在我们的 Counter
中添加一个锁,一个 Mutex。
Mutex 是互斥锁。互斥锁的零值是一个解锁的互斥锁。
type Counter struct {
mu sync.Mutex
value int
}
func (c *Counter) Inc() {
c.mu.Lock()
defer c.mu.Unlock()
c.value++
}
这意味着如果他们是第一个,那么任何叫 Inc
的 goroutine 都将获得 Counter
的锁。所有其他 goroutines 将不得不等待它 Unlock
之前获得访问。
如果你现在重新运行这个测试,它现在应该通过了,因为每个 goroutine 都必须等到轮到它的时候才进行更改。
我还看到过其他的例子。互斥锁被嵌入到 struct 中
你可以看到如下例子
type Counter struct {
sync.Mutex
value int
}
可以认为,它可以使代码更加优雅。
func (c *Counter) Inc() {
c.Lock()
defer c.Unlock()
c.value++
}
这 看起来 很好,但是编程是一门非常主观的学科,这是非常糟糕和错误的。
有时人们忘记了嵌入类型意味着该类型的方法成为 公共接口的一部分;你通常不希望这样。请记住,我们应该非常小心地使用我们的公共 API,当我们将某个对象公开时,其他代码也可以将自己与之结合。我们总是希望避免不必要的耦合。
如果你的类型的调用者开始调用这些方法,那么暴露 Lock
和 Unlock
往好里说就是混淆,往坏里说就是对你的软件有潜在的危害。
显示该 API 的用户如何错误地更改锁的状态
这似乎是个坏主意
复制互斥锁(mutex)
我们的测试通过了,但是我们的代码仍然有点危险
如果你在你的代码上运行 go vet
,你应该会得到如下错误
sync/v2/sync_test.go:16: call of assertCounter copies lock value: v1.Counter contains sync.Mutex
sync/v2/sync_test.go:39: assertCounter passes lock by value: v1.Counter contains sync.Mutex
看看 sync.Mutex 的文档就知道为什么了
互斥锁在第一次使用后不能被复制
当我们将 Counter
(按值传递) 给 assertCounter
时,它将尝试创建一个互斥对象的副本。
为了解决这个问题,我们应该传递一个指针到我们的 Counter
,所以改变 assertCounter
的签名。
func assertCounter(t *testing.T, got *Counter, want int)
我们的测试将不再编译,因为我们试图通过一个 Counter
而不是一个 *Counter
。为了解决这个问题,我倾向于创建一个构造函数,它向 API 的读者显示,最好不要自己初始化类型。
func NewCounter() *Counter {
return &Counter{}
}
在您的测试中初始化 Counter
时使用这个函数。
结束
我们已经介绍了 sync 包 中的一些内容
-
Mutex
允许我们在数据中添加锁 -
Waitgroup
是等待 goroutines 完成工作的一种方式
何时在通道和 goroutines 上使用锁?
我们之前已经在并发章节中讨论过 goroutines,这让我们可以编写安全的并发代码,那么为什么要使用锁呢? go wiki 有一个专门针对这个主题的文章 互斥或通道。
一个常见的新手错误是过度使用 channels 和 goroutines,仅仅因为它是可能的,或者因为它很有趣。不要害怕使用 sync。如果互斥锁是你解决问题的最佳方法。Go是务实的,它允许您使用能够最好地解决问题的工具,而不是强迫您使用一种风格的代码。
改写:
-
在传递数据所有权时使用通道
-
使用互斥锁来管理状态
go vet
请记住在构建脚本中使用 go vet,因为它可以提醒您注意代码中的一些细微 bug,以免它们影响到可怜的用户。
不要使用嵌入,因为它很方便
-
考虑一下嵌入对公共 API 的影响。
-
您 真的 想要公开这些方法并让人们将自己的代码耦合到里面吗?
-
对于互斥锁,这可能会以非常不可预测和奇怪的方式带来潜在的灾难性后果,想象一下,一些邪恶的代码在不应该解锁互斥锁的时候解锁了它;这将导致一些非常奇怪的 bug,很难跟踪。
原文地址 Learn Go with Tests