go核心07-并发:如何使用共享变量?

207 阅读7分钟

Go 也并没有彻底放弃基于共享内存的并发模型,而是在提供 CSP 并发模型原语的同时,还通过标准库的 sync 包,提供了针对传统的、基于共享内存并发模型的低级同步原语,包括:互斥锁(sync.Mutex)、读写锁(sync.RWMutex)、条件变量(sync.Cond)等,并通过 atomic 包提供了原子操作原语等等。显然,基于共享内存的并发模型在 Go 语言中依然有它的“用武之地”。

sync 包低级同步原语可以用在哪?

首先是需要高性能的临界区(critical section)同步机制场景。

这里,关于 sync.Mutex 和 channel 各自实现的临界区同步机制,我做了一个简单的性能基准测试对比,通过对比结果,我们可以很容易看出两者的性能差异:

var cs = 0 // 模拟临界区要保护的数据
var mu sync.Mutex
var c = make(chan struct{}, 1)

func criticalSectionSyncByMutex() {
    mu.Lock()
    cs++
    mu.Unlock()
}

func criticalSectionSyncByChan() {
    c <- struct{}{}
    cs++
    <-c
}

func BenchmarkCriticalSectionSyncByMutex(b *testing.B) {
    for n := 0; n < b.N; n++ {
        criticalSectionSyncByMutex()
    }
}

func BenchmarkCriticalSectionSyncByMutexInParallel(b *testing.B) {
    b.RunParallel(func(pb *testing.PB) {
        for pb.Next() {
            criticalSectionSyncByMutex()
        }
    })
}

func BenchmarkCriticalSectionSyncByChan(b *testing.B) {
    for n := 0; n < b.N; n++ {
        criticalSectionSyncByChan()
    }
}

func BenchmarkCriticalSectionSyncByChanInParallel(b *testing.B) {
    b.RunParallel(func(pb *testing.PB) {
        for pb.Next() {
            criticalSectionSyncByChan()
        }
    })
}

运行这个对比测试(Go 1.17),我们得到:

$go test -bench .
goos: darwin
goarch: amd64
... ...
BenchmarkCriticalSectionSyncByMutex-8               88083549          13.64 ns/op
BenchmarkCriticalSectionSyncByMutexInParallel-8     22337848          55.29 ns/op
BenchmarkCriticalSectionSyncByChan-8                28172056          42.48 ns/op
BenchmarkCriticalSectionSyncByChanInParallel-8       5722972         208.1 ns/op
PASS

通过这个对比实验,我们可以看到,无论是在单 Goroutine 情况下,还是在并发测试情况下,sync.Mutex实现的同步机制的性能,都要比 channel 实现的高出三倍多。

因此,通常在需要高性能的临界区(critical section)同步机制的情况下,sync 包提供的低级同步原语更为适合。

第二种就是在不想转移结构体对象所有权,但又要保证结构体内部状态数据的同步访问的场景。

基于 channel 的并发设计,有一个特点:在 Goroutine 间通过 channel 转移数据对象的所有权。所以,只有拥有数据对象所有权(从 channel 接收到该数据)的 Goroutine 才可以对该数据对象进行状态变更。

如果你的设计中没有转移结构体对象所有权,但又要保证结构体内部状态数据在多个 Goroutine 之间同步访问,那么你可以使用 sync 包提供的低级同步原语来实现,比如最常用的sync.Mutex。

sync 包中同步原语使用的注意事项

// $GOROOT/src/sync/mutex.go
type Mutex struct {
    state int32
    sema  uint32
}

我们看到,Mutex 的定义非常简单,由两个整型字段 state 和 sema 组成:

  • state:表示当前互斥锁的状态;
  • sema:用于控制锁状态的信号量。

初始情况下,Mutex 的实例处于 Unlocked 状态(state 和 sema 均为 0)。对 Mutex 实例的复制也就是两个整型字段的复制。一旦发生复制,原变量与副本就是两个单独的内存块,各自发挥同步作用,互相就没有了关联。

互斥锁(Mutex)还是读写锁(RWMutex)?

  • 尽量减少在锁中的操作。这可以减少其他因 Goroutine 阻塞而带来的损耗与延迟。
  • 一定要记得调用 Unlock 解锁。忘记解锁会导致程序局部死锁,甚至是整个程序死锁,会导致严重的后果。

写锁与 Mutex 的行为十分类似,一旦某 Goroutine 持有写锁,其他 Goroutine 无论是尝试加读锁,还是加写锁,都会被阻塞在写锁上。

写锁与 Mutex 的行为十分类似,一旦某 Goroutine 持有写锁,其他 Goroutine 无论是尝试加读锁,还是加写锁,都会被阻塞在写锁上。

  • 并发量较小的情况下,Mutex 性能最好;随着并发量增大,Mutex 的竞争激烈,导致加锁和解锁性能下降;
  • RWMutex 的读锁性能并没有随着并发量的增大,而发生较大变化,性能始终恒定在 40ns 左右;
  • 在并发量较大的情况下,RWMutex 的写锁性能和 Mutex、RWMutex 读锁相比,是最差的,并且随着并发量增大,RWMutex 写锁性能有继续下降趋势。

由此,我们就可以看出,读写锁适合应用在具有一定并发量且读多写少的场合。在大量并发读的情况下,多个 Goroutine 可以同时持有读锁,从而减少在锁竞争中等待的时间。

条件变量

sync.Cond是传统的条件变量原语概念在 Go 语言中的实现。我们可以把一个条件变量理解为一个容器,这个容器中存放着一个或一组等待着某个条件成立的 Goroutine。当条件成立后,这些处于等待状态的 Goroutine 将得到通知,并被唤醒继续进行后续的工作。这与百米飞人大战赛场上,各位运动员等待裁判员的发令枪声的情形十分类似。

这里我们先看一个用sync.Mutex 实现对条件轮询等待的例子:

type signal struct{}

var ready bool

func worker(i int) {
  fmt.Printf("worker %d: is working...\n", i)
  time.Sleep(1 * time.Second)
  fmt.Printf("worker %d: works done\n", i)
}

func spawnGroup(f func(i int), num int, mu *sync.Mutex) <-chan signal {
  c := make(chan signal)
  var wg sync.WaitGroup

  for i := 0; i < num; i++ {
    wg.Add(1)
    go func(i int) {
      for {
        mu.Lock()
        if !ready {
          mu.Unlock()
          time.Sleep(100 * time.Millisecond)
          continue
        }
        mu.Unlock()
        fmt.Printf("worker %d: start to work...\n", i)
        f(i)
        wg.Done()
        return
      }
    }(i + 1)
  }

  go func() {
    wg.Wait()
    c <- signal(struct{}{})
  }()
  return c
}

func main() {
  fmt.Println("start a group of workers...")
  mu := &sync.Mutex{}
  c := spawnGroup(worker, 5, mu)

  time.Sleep(5 * time.Second) // 模拟ready前的准备工作
  fmt.Println("the group of workers start to work...")

  mu.Lock()
  ready = true
  mu.Unlock()

  <-c
  fmt.Println("the group of workers work done!")
}

我们用sync.Cond对上面的例子进行改造,改造后的代码如下:

type signal struct{}

var ready bool

func worker(i int) {
  fmt.Printf("worker %d: is working...\n", i)
  time.Sleep(1 * time.Second)
  fmt.Printf("worker %d: works done\n", i)
}

func spawnGroup(f func(i int), num int, groupSignal *sync.Cond) <-chan signal {
  c := make(chan signal)
  var wg sync.WaitGroup

  for i := 0; i < num; i++ {
    wg.Add(1)
    go func(i int) {
      groupSignal.L.Lock()
      for !ready {
        groupSignal.Wait()
      }
      groupSignal.L.Unlock()
      fmt.Printf("worker %d: start to work...\n", i)
      f(i)
      wg.Done()
    }(i + 1)
  }

  go func() {
    wg.Wait()
    c <- signal(struct{}{})
  }()
  return c
}

func main() {
  fmt.Println("start a group of workers...")
  groupSignal := sync.NewCond(&sync.Mutex{})
  c := spawnGroup(worker, 5, groupSignal)

  time.Sleep(5 * time.Second) // 模拟ready前的准备工作
  fmt.Println("the group of workers start to work...")

  groupSignal.L.Lock()
  ready = true
  groupSignal.Broadcast()
  groupSignal.L.Unlock()

  <-c
  fmt.Println("the group of workers work done!")
}

我们运行这个示例程序,得到:

start a group of workers...
the group of workers start to work...
worker 2: start to work...
worker 2: is working...
worker 3: start to work...
worker 3: is working...
worker 1: start to work...
worker 1: is working...
worker 4: start to work...
worker 5: start to work...
worker 5: is working...
worker 4: is working...
worker 4: works done
worker 2: works done
worker 3: works done
worker 1: works done
worker 5: works done
the group of workers work done!

我们看到,sync.Cond实例的初始化,需要一个满足实现了sync.Locker接口的类型实例,通常我们使用sync.Mutex。

条件变量需要这个互斥锁来同步临界区,保护用作条件的数据。加锁后,各个等待条件成立的 Goroutine 判断条件是否成立,如果不成立,则调用sync.Cond的 Wait 方法进入等待状态。Wait 方法在 Goroutine 挂起前会进行 Unlock 操作。

原子操作(atomic operations)

atomic 包封装了 CPU 实现的部分原子操作指令,为用户层提供体验良好的原子操作函数,因此 atomic 包中提供的原语更接近硬件底层,也更为低级,它也常被用于实现更为高级的并发同步技术,比如 channel 和 sync 包中的同步原语。

atomic 包提供了两大类原子操作接口,一类是针对整型变量的,包括有符号整型、无符号整型以及对应的指针类型;另外一类是针对自定义类型的。因此,第一类原子操作接口的存在让 atomic 包天然适合去实现某一个共享整型变量的并发同步。我们再看一个例子:

var n1 int64

func addSyncByAtomic(delta int64) int64 {
  return atomic.AddInt64(&n1, delta)
}

func readSyncByAtomic() int64 {
  return atomic.LoadInt64(&n1)
}

var n2 int64
var rwmu sync.RWMutex

func addSyncByRWMutex(delta int64) {
  rwmu.Lock()
  n2 += delta
  rwmu.Unlock()
}

func readSyncByRWMutex() int64 {
  var n int64
  rwmu.RLock()
  n = n2
  rwmu.RUnlock()
  return n
}

func BenchmarkAddSyncByAtomic(b *testing.B) {
  b.RunParallel(func(pb *testing.PB) {
    for pb.Next() {
      addSyncByAtomic(1)
    }
  })
}

func BenchmarkReadSyncByAtomic(b *testing.B) {
  b.RunParallel(func(pb *testing.PB) {
    for pb.Next() {
      readSyncByAtomic()
    }
  })
}

func BenchmarkAddSyncByRWMutex(b *testing.B) {
  b.RunParallel(func(pb *testing.PB) {
    for pb.Next() {
      addSyncByRWMutex(1)
    }
  })
}

func BenchmarkReadSyncByRWMutex(b *testing.B) {
  b.RunParallel(func(pb *testing.PB) {
    for pb.Next() {
      readSyncByRWMutex()
    }
  })
}

们分别在 cpu=2、 8、16、32 的情况下运行上述性能基准测试,得到结果如下:

goos: darwin
goarch: amd64
... ...
BenchmarkAddSyncByAtomic-2       75426774          17.69 ns/op
BenchmarkReadSyncByAtomic-2      1000000000           0.7437 ns/op
BenchmarkAddSyncByRWMutex-2      39041671          30.16 ns/op
BenchmarkReadSyncByRWMutex-2     41325093          28.48 ns/op

BenchmarkAddSyncByAtomic-8       77497987          15.25 ns/op
BenchmarkReadSyncByAtomic-8      1000000000           0.2395 ns/op
BenchmarkAddSyncByRWMutex-8      17702034          67.16 ns/op
BenchmarkReadSyncByRWMutex-8     29966182          40.37 ns/op

BenchmarkAddSyncByAtomic-16        57727968          20.39 ns/op
BenchmarkReadSyncByAtomic-16       1000000000           0.2536 ns/op
BenchmarkAddSyncByRWMutex-16       15029635          78.61 ns/op
BenchmarkReadSyncByRWMutex-16      29722464          40.28 ns/op

BenchmarkAddSyncByAtomic-32        58010497          20.40 ns/op
BenchmarkReadSyncByAtomic-32       1000000000           0.2402 ns/op
BenchmarkAddSyncByRWMutex-32       11748312          93.15 ns/op
BenchmarkReadSyncByRWMutex-32      29845912          40.54 ns/op

通过这个运行结果,我们可以得出一些结论:

  • 读写锁的性能随着并发量增大的情况,与前面讲解的 sync.RWMutex 一致;
  • 利用原子操作的无锁并发写的性能,随着并发量增大几乎保持恒定;
  • 利用原子操作的无锁并发读的性能,随着并发量增大有持续提升的趋势,并且性能是读锁的约 200 倍。

atomic 包更适合一些对性能十分敏感、并发量较大且读多写少的场合。

此文章为3月Day25学习笔记,内容来源于极客时间《Tony Bai · Go 语言第一课》。