Go 实现并发安全的3种方法

当我们的程序串行运行时,一个变量同时只会有一个协程去访问,但是当有多个协程的时候,我们无法确定地说一个事件先于另一个事件发生,我们说这两个事件是并发的。并发可以让程序更高效地工作,尤其是现今 CPU 普遍有多个核心的情况下。但是并发也会带来竟态的问题。

非并发安全

假设我们有一个银行程序,允许多个用户同时操作同一个账户,简单的方法调用会导致多个协程同时操作存款余额 balance,导致预想不到的情况。

package main

import (
	"fmt"
	"sync"
	"time"
)

type Bank1 struct {
	balance int
}

func (b *Bank1) Deposit(amount int) {
	b.balance += amount
}

func (b *Bank1) Balance() int {
	return b.balance
}

使用 sync.RWMutex

第一种解决方法,我们还是允许多个协程访问同一个变量,但是使用同步锁,确保一个时刻只有一个协程能够操作 balance

package main

import (
	"fmt"
	"sync"
	"time"
)

type Bank2 struct {
	balance int
	mu      sync.RWMutex
}

func (b *Bank2) Deposit(amount int) {
	b.mu.Lock()
	defer b.mu.Unlock()
	b.balance += amount
}

func (b *Bank2) Balance() int {
	b.mu.RLock()
	defer b.mu.RUnlock()
	return b.balance
}

使用 channel

第二种方案,就是确保只有一个协程能够访问操作 balance,其他协程通过 channel 发起请求和接受结果。

package main

import (
	"fmt"
	"sync"
	"time"
)

type Bank3 struct {
	balance int
	dc      chan int
	bc      chan int
}

func (b *Bank3) init() {
	go func() {
		for {
			select {
			case amount := <-b.dc:
				b.balance += amount
			case b.bc <- b.balance:
			}
		}
	}()
}

func (b *Bank3) Deposit(amount int) {
	b.dc <- amount
}

func (b *Bank3) Balance() int {
	return <-b.bc
}

使用原子操作

还有一种方案就是借助 sync/atomic 包,将对账户余额 balance 的访问变成原子操作。

package main

import (
	"fmt"
	"sync"
	"sync/atomic"
	"time"
)

type Bank4 struct {
	balance int64
}

func (b *Bank4) Deposit(amount int64) {
	atomic.AddInt64(&b.balance, amount)
}

func (b *Bank4) Balance() int64 {
	return atomic.LoadInt64(&b.balance)
}

运行与性能

我们使用 go test 去测试是否存在竟态和比较性能。

package main

import (
	"sync"
	"testing"
)

func BenchmarkBank1(b *testing.B) {
	var wg sync.WaitGroup
	bank := Bank1{}
	for i := 0; i < b.N; i++ {
		wg.Add(1)
		go func(i int) {
			bank.Deposit(100)
			bank.Balance()
			wg.Done()
		}(i)
	}
	wg.Wait()
	//fmt.Printf("Bank1 Final: %d\n", bank.Balance())
}


func BenchmarkBank2(b *testing.B) {
	var wg sync.WaitGroup
	bank := Bank2{}
	for i := 0; i < b.N; i++ {
		wg.Add(1)
		go func(i int) {
			bank.Deposit(100)
			bank.Balance()
			wg.Done()
		}(i)
	}
	wg.Wait()
	//fmt.Printf("Bank2 Final: %d\n", bank.Balance())
}

func BenchmarkBank3(b *testing.B) {
	var wg sync.WaitGroup
	bank := Bank3{
		dc: make(chan int),
		bc: make(chan int),
	}
	bank.init()
	for i := 0; i < b.N; i++ {
		wg.Add(1)
		go func(i int) {
			bank.Deposit(100)
			bank.Balance()
			wg.Done()
		}(i)
	}
	wg.Wait()
	//fmt.Printf("Bank3 Final: %d\n", bank.Balance())
}

func BenchmarkBank4(b *testing.B) {
	var wg sync.WaitGroup
	bank := Bank4{}
	for i := 0; i < b.N; i++ {
		wg.Add(1)
		go func(i int) {
			bank.Deposit(100)
			bank.Balance()
			wg.Done()
		}(i)
	}
	wg.Wait()
	//fmt.Printf("Bank4 Final: %d\n", bank.Balance())
}

竟态

在程序运行时,使用竟态检测器(race detector)去检查是否存在竟态,使用方法就是简单地把 -race 命令行参数添加到 go buildgo rungo test 中,可以验证4种方案中只有第一种存在竟态情况:

safe_bank $ go test -race -bench=.
goos: darwin
goarch: amd64
pkg: safe_bank
BenchmarkBank1-8   	==================
WARNING: DATA RACE
Read at 0x00c0000180f0 by goroutine 21:
  safe_bank.BenchmarkBank1.func1()
      /Users/pheynix/Documents/go_projects/safe_bank/bank1.go:30 +0x3f

Previous write at 0x00c0000180f0 by goroutine 20:
  safe_bank.BenchmarkBank1.func1()
      /Users/pheynix/Documents/go_projects/safe_bank/bank1.go:30 +0x55

Goroutine 21 (running) created at:
  safe_bank.BenchmarkBank1()
      /Users/pheynix/Documents/go_projects/safe_bank/bank_test.go:13 +0xee
  testing.(*B).runN()
      /usr/local/go/src/testing/benchmark.go:190 +0x162
  testing.(*B).launch()
      /usr/local/go/src/testing/benchmark.go:320 +0x156

Goroutine 20 (finished) created at:
  safe_bank.BenchmarkBank1()
      /Users/pheynix/Documents/go_projects/safe_bank/bank_test.go:13 +0xee
  testing.(*B).runN()
      /usr/local/go/src/testing/benchmark.go:190 +0x162
  testing.(*B).launch()
      /usr/local/go/src/testing/benchmark.go:320 +0x156
==================
--- FAIL: BenchmarkBank1-8
    benchmark.go:196: race detected during execution of benchmark
...

性能

为了比较各个方案的性能表现,我们去掉 -race 命令:

safe_bank $ go test -bench=.
goos: darwin
goarch: amd64
pkg: safe_bank
BenchmarkBank1-8   	 3091497	       391 ns/op
BenchmarkBank2-8   	 1291694	      1221 ns/op
BenchmarkBank3-8   	  500413	      2138 ns/op
BenchmarkBank4-8   	 2992161	       404 ns/op
PASS
ok  	safe_bank	7.620s

可以看到,sync/atomic 方案表现优异,sync.RWMutex 方案次之,使用 channel 的方案表现最差。

最后

实际项目中,我们应该根据实际情况,综合地使用这几种方案,而不是执着于使用其中一种。

文中代码已上传Github

评论

退出登录