Go中的并发是困难的

我明白标题可能有些令人困惑,因为一般来说,Go被认为在并发方面有很好的内置支持。然而,我并不认为在Go中编写并发软件是容易的。让我向您展示我是什么意思。

使用全局变量

第一个例子是我们在项目中遇到的问题。直到最近,sarama库(用于Apache Kafka的Go库)中包含了以下代码(位于sarama/version.go):

package sarama

import "runtime/debug"

var v string

func version() string {
    if v == "" {
        bi, ok := debug.ReadBuildInfo()
        if ok {
            v = bi.Main.Version
        } else {
            v = "dev"
        }
    }
    return v
}

乍一看,这看起来没问题,对吧?如果版本没有在全局设置中,它要么基于构建信息,要么被分配为静态值(dev)。否则,版本将按原样返回。当我们运行这段代码时,它似乎按预期工作。

然而,当并发调用version函数时,全局变量v可能会被多个goroutine同时访问,导致潜在的数据竞争。这些问题很难跟踪,因为它们只在运行时在恰当的条件下才会发生。

解决方案

这个问题在#2171中得到修复,通过使用sync.Once,根据文档的解释,它是“执行一次且仅执行一次操作的对象”。这意味着我们可以使用它来设置版本,以便后续对version函数的调用将返回结果。修复代码如下所示:

package sarama

import (
    "runtime/debug"
    "sync"
)

var (
    v     string
    vOnce sync.Once
)

func version() string {
    vOnce.Do(func() {
        bi, ok := debug.ReadBuildInfo()
        if ok {
           v = bi.Main.Version
        } else {
           v = "dev"
        }
    })
    return v
}

尽管我认为在这种情况下,也可以在不使用sync包的情况下通过使用init函数来设置变量v一次来进行修复。由于在Go运行init函数后变量v不会改变,所以应该是没问题的。

如何预防

您可以在测试期间或在使用go run时使用data race detector(自Go 1.1起可用)。当它检测到潜在的数据竞争时,它会打印一个警告。为了展示这是如何工作的,我稍微修改了一下代码来触发数据竞争:

package main
import (
    "fmt"
    "runtime/debug"
)
var v string
func version() string {
    if v == "" {
        bi, ok := debug.ReadBuildInfo()
        if ok {
            v = bi.Main.Version
        } else {
            v = "dev"
        }
    }
    return v
}
func main() {
    go func() {
        version()
    }()
    fmt.Println(version())
}

现在我们可以使用-race标志来启用数据竞争检测器来运行它:

➜ go run -race .                               
==================
WARNING: DATA RACE
Write at 0x000104a16b90 by main goroutine:
  main.version()
      main.go:14 +0x78
  main.main()
      main.go:27 +0x30Previous read at 0x000104a16b90 by goroutine 7:
  main.version()
      main.go:11 +0x2c
  main.main.func1()
      main.go:24 +0x24Goroutine 7 (finished) created at:
  main.main()
      main.go:23 +0x2c
==================
(devel)
Found 1 data race(s)
exit status 66

正如你所看到的,检测到了数据竞争。如果我们分析输出,可以看到我们同时对变量v进行读写操作。这就是我们所说的数据竞争。之所以称为数据竞争,是因为两个goroutine正在"竞争"访问相同的数据。

Go中的并发是困难的_第1张图片

从sync包中复制结构体

我在GitHub上找到了一些实际的例子,但没有一个足够重要以至于在这里提及。相反,我将基于我制作的一个示例来解释。所以,下面是例子的说明:

package main
import "sync"
type User struct {
    lock sync.RWMutex
    Name string
}
func doSomething(u User) {
    u.lock.RLock()
    defer u.lock.RUnlock()
    // do something with `u`
}
func main() {
    u := User{Name: "John"}
    doSomething(u)
}

User结构体包含两个属性:读/写锁和一个字符串。当调用doSomething函数时,变量u会被复制到栈上(也称为按值传递),包括其字段。这是一个问题,因为sync包的文档中指出:

sync包提供了基本的同步原语,如互斥锁。除了Once和WaitGroup类型外,大多数都是为低级库例程使用的。更高级的同步最好通过通道和通信来完成。

不应复制包含此包中定义的类型的值。

当评估doSomething函数时,运行RLock/RUnlock不会影响User结构体中的原始锁,这个锁无效。

解决方案

改用锁的指针。指针会被复制,并指向相同的值。更新后的版本如下所示:

type User struct {
    lock *sync.RWMutex
    Name string
}

读锁验证

使用copylock分析器来在复制sync包中的类型时显示警告。最简单的方法是在发布代码之前运行go vet。在原始代码上运行这个命令会得到以下输出:

➜ go vet .
# data-synchronization
./main.go:10:20: doSomething passes lock by value: data-synchronization.User contains sync.RWMutex
./main.go:20:14: call of doSomething copies lock value: data-synchronization.User contains sync.RWMutex

使用 time.After

在GitHub上搜索时,我发现了Hashicorp的Raft实现中的一个pull request,我们可以使用它来演示以下问题。让我们首先展示代码(位于api.go文件中):

var timer <-chan time.Time
if timeout > 0 {
    timer = time.After(timeout)
}

// Perform the restore.
restore := &userRestoreFuture{
    meta:   meta,
    reader: reader,
}
restore.init()
select {
case <-timer:
    return ErrEnqueueTimeout
case <-r.shutdownCh:
    return ErrRaftShutdown
case r.userRestoreCh <- restore:
    // If the restore is ingested then wait for it to complete.
    if err := restore.Error(); err != nil {
        return err
    }
}

这段代码来自Restore方法。select语句等待以下情况之一发生:计时器(用于定义超时)、关闭通道或还原操作完成时。看起来很简单,那问题在哪里呢?

time.After函数的工作原理如下:

func After(d Duration) <-chan Time {
    return NewTimer(d).C
}

因此,它只是time.NewTimer的简写形式,但它“泄露”了计时器(因为没有调用timer.Stop)。文档对此的说明如下:

After等待持续时间过去,然后在返回的通道上发送当前时间。它等价于NewTimer(d).C。直到计时器触发后,底层计时器才会被垃圾回收器回收。如果效率是一个问题,可以使用NewTimer并在不再需要计时器时调用Timer.Stop。

我真的不明白为什么一个有意“泄露”计时器的函数(可能会导致潜在的长期分配,取决于持续时间)最终出现在标准库中…

解决方案

我们可以手动创建计时器,而不是使用time.After。具体如下所示:

var timerCh <-chan time.Time
if timeout > 0 {
    timer := time.NewTimer(timeout)
    defer timer.Stop()
    timerCh = timer.C
}
// Perform the restore.
restore := &userRestoreFuture{
    meta:   meta,
    reader: reader,
}
restore.init()
select {
case <-timerCh:
    return ErrEnqueueTimeout
case <-r.shutdownCh:
    return ErrRaftShutdown
case r.userRestoreCh <- restore:
    // If the restore is ingested then wait for it to complete.
    if err := restore.Error(); err != nil {
        return err
    }
}

当函数执行完毕时,即使计时器没有触发,它也会被清理。

如何预防

我不会在任何代码库中使用time.After。除了节省一两行代码外,它没有实质性的优势,而且可能会引发很多问题,特别是当它在代码的热点路径中使用时。

结论

使用Go的内置并发支持可以快速编写并发软件。然而,它将确保数据正确同步和正确使用标准库中的工具的责任留给用户。这加上Go的简洁性,使得编写稳定、无bug的并发软件变得困难。

如果你喜欢我的文章,点赞,关注,转发!

你可能感兴趣的:(计算机那些事,计算机科学,计算机那点事,golang,开发语言,java,信息与通信)