基于共享变量的并发 | 青训营

47 阅读5分钟

并发是指让程序可以同时执行多个任务,提高效率和性能。Go 语言支持两种并发编程的方式:goroutine 和 channel。goroutine 是一种轻量级的线程,可以在一个程序中创建成千上万个。channel 是一种类似于管道的数据结构,可以让一个 goroutine 向另一个 goroutine 发送或接收数据。

除了 goroutine 和 channel,Go 语言还提供了另一种并发编程的方式:基于共享变量的并发。这种方式是指多个 goroutine 之间通过访问和修改同一个变量来进行通信和同步。这种方式相比于 channel,有以下几个优点:

  • 更简单,不需要创建和管理 channel,只需要使用普通的变量即可。
  • 更灵活,可以使用任何类型的变量,不受 channel 的类型限制。
  • 更高效,不需要进行数据的复制和传输,只需要操作内存中的变量即可。

但是,基于共享变量的并发也有以下几个缺点:

  • 更危险,如果多个 goroutine 同时对同一个变量进行读写操作,可能会导致数据的不一致或丢失,这种情况称为竞态条件(race condition)。
  • 更复杂,为了避免竞态条件,需要使用一些同步机制来保证对共享变量的访问是互斥或原子的,这些同步机制包括互斥锁(mutex)、原子操作(atomic)、条件变量(condition)等。
  • 更难调试,如果出现竞态条件或死锁(deadlock)等问题,很难找到原因和解决方案。

竞态条件

竞态条件是指多个 goroutine 同时对同一个变量进行读写操作,导致最终结果取决于执行顺序或时间差,而不是程序逻辑。竞态条件会使程序的行为变得不可预测和不可重现,可能会引发一些严重的错误或漏洞。

例如,假设有两个 goroutine 同时对一个整数变量 n 进行加一操作:

var n int

func increment() {
    n++ // 读取n的值,加一,然后写回n
}

func main() {
    go increment() // 在一个新的goroutine中执行increment函数
    go increment() // 在另一个新的goroutine中执行increment函数
    time.Sleep(time.Second) // 等待一秒
    fmt.Println(n) // 打印n的值
}

理想情况下,n 的值应该是 2,因为两个 goroutine 都对 n 加了一。但是,在实际运行中,n 的值可能是 1 或者 2,取决于两个 goroutine 的执行顺序和时间差。这就是竞态条件的例子。

为了检测程序是否存在竞态条件,可以使用 go run -race 命令来运行程序,并查看输出结果。如果存在竞态条件,会显示相关的信息和警告。例如:

==================
WARNING: DATA RACE
Read at 0x00c0000160b8 by goroutine 7:
  main.increment()
      /tmp/sandbox557066213/prog.go:7 +0x3a

Previous write at 0x00c0000160b8 by goroutine 6:
  main.increment()
      /tmp/sandbox557066213/prog.go:7 +0x4c

Goroutine 7 (running) created at:
  main.main()
      /tmp/sandbox557066213/prog.go:11 +0x68

Goroutine 6 (finished) created at:
  main.main()
      /tmp/sandbox557066213/prog.go:10 +0x4a
==================
1
Found 1 data race(s)
exit status 66

同步机制

为了避免竞态条件,需要使用一些同步机制来保证对共享变量的访问是互斥或原子的。

  • 互斥是指在同一时刻,只有一个 goroutine 可以访问共享变量,其他的 goroutine 需要等待。
  • 原子是指对共享变量的操作是不可分割的,不会被其他的 goroutine 打断或干扰。

Go 语言提供了以下几种同步机制:

  • 互斥锁(mutex):互斥锁是一种最常用的同步机制,可以保证在同一时刻,只有一个 goroutine 可以访问共享变量。Go 语言提供了 sync.Mutex 类型来实现互斥锁,可以使用 LockUnlock 方法来加锁和解锁。例如:
var (
    n int
    mu sync.Mutex // 定义一个互斥锁
)

func increment() {
    mu.Lock() // 加锁
    n++ // 读取n的值,加一,然后写回n
    mu.Unlock() // 解锁
}

func main() {
    go increment() // 在一个新的goroutine中执行increment函数
    go increment() // 在另一个新的goroutine中执行increment函数
    time.Sleep(time.Second) // 等待一秒
    fmt.Println(n) // 打印n的值
}

使用互斥锁后,n 的值就一定是 2,因为两个 goroutine 需要轮流访问 n,不会发生竞态条件。

  • 原子操作(atomic):原子操作是指对共享变量的操作是不可分割的,不会被其他的 goroutine 打断或干扰。Go 语言提供了 sync/atomic 包来实现原子操作,可以使用 AddInt32AddInt64 等函数来对整数变量进行原子地加减操作。例如:
var n int64

func increment() {
    atomic.AddInt64(&n, 1) // 原子地将n加一
}

func main() {
    go increment() // 在一个新的goroutine中执行increment函数
    go increment() // 在另一个新的goroutine中执行increment函数
    time.Sleep(time.Second) // 等待一秒
    fmt.Println(n) // 打印n的值
}

使用原子操作后,n 的值也一定是 2,因为两个 goroutine 对 n 的操作是不可分割的,不会发生竞态条件。

  • 条件变量(condition):条件变量是一种用于等待或通知某个条件成立的同步机制。Go 语言提供了 sync.Cond 类型来实现条件变量,可以使用 WaitSignalBroadcast 方法来等待或通知某个条件。例如:
var (
    n int
    mu sync.Mutex // 定义一个互斥锁
    cond = sync.NewCond(&mu) // 定义一个条件变量,并绑定互斥锁
)

func producer() {
    for i := 0; i < 10; i++ {
        mu.Lock() // 加锁
        n = i // 生产一个数字
        cond.Signal() // 发送信号,通知消费者有新的数字可用
        mu.Unlock() // 解锁
        time.Sleep(time.Millisecond * 500) // 等待一段时间
    }
}

func consumer() {
    for i := 0; i < 10; i++ {
        mu.Lock() // 加锁
        for n == -1 { // 如果没有新的数字可用,就等待信号
            cond.Wait()
        }
        fmt.Println(n) // 消费一个数字,并打印它
        n = -1
        mu.Unlock() // 解锁
    }
}

func main() {
    n = -1 // 初始化n为-1,表示没有新的数字可用
    go producer() // 在一个新的goroutine中执行producer函数
    go consumer() // 在另一个新的goroutine中执行consumer函数
    time.Sleep(time.Second * 6) // 等待一段时间
}

使用条件变量后,可以实现生产者和消费者之间的同步,避免无效的等待或浪费。