同步

同步

你可以在这里找到本章的所有代码

我们想做一个可以安全并发使用的计数器。

我们将从一个不安全的计数器开始,并验证它在单线程环境中的行为是否正常。

然后我们会用多个 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 都尝试过 IncCounter

尝试运行测试

=== 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,当我们将某个对象公开时,其他代码也可以将自己与之结合。我们总是希望避免不必要的耦合。

如果你的类型的调用者开始调用这些方法,那么暴露 LockUnlock 往好里说就是混淆,往坏里说就是对你的软件有潜在的危害。

显示该 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

本文章首发在 LearnKu.com 网站上。
上一篇 下一篇
讨论数量: 0
发起讨论 只看当前版本


暂无话题~