背景

在使用 Go 构建 Web 应用程序时,所有传入的 HTTP 请求都会被路由到对应处理逻辑的 Goroutine 中。如果应用程序在处理请求的时候,有读写同一块内存数据, 就存在竞态条件的风险。( Spanner 支持 读写锁定 的事务模式,单个逻辑时间点以原子方式执行一组读写,不存在竞态条件问题)

数据竞争

一个很常见的竞态条件场景就是银行账户余额的读写。考虑一种情况,有两个 Goroutine 尝试同时将钱存到同一个银行余额中,例如:

指令 Goroutine1 Goroutine2 银行存款余额
1 读取余额 <- 500元 500元
2 读取余额 <- 500元 500元
3 存入100元,写入银行账号 -> 600元 600元
4 存入50元,写入银行账号 -> 550元 550元

尽管进行了两次单独的存款,但由于第二个 Goroutine 相互对账号余额做更改,因此仅第二笔存款反映在最终余额中。

这种特定类型的竞态条件称为数据竞争。当两个或多个 Goroutine 尝试同时使用一条共享数据(在此示例中为银行余额)时,它们可能会触发,但是操作结果取决于调度程序执行其指令的顺序。

Go 官方博客 也列举了数据竞争导致的一些问题:

Race conditions are among the most insidious and elusive programming errors. They typically cause erratic and mysterious failures, often long after the code has been deployed to production. While Go’s concurrency mechanisms make it easy to write clean concurrent code, they don’t prevent race conditions. Care, diligence, and testing are required.

Go 提供了许多工具来帮助我们避免数据竞争问题。其中包括用于在 Goroutine 之间进行数据通信的 channel ; 用于在运行时监视对内存的非同步访问的 Race Detector,以及 AtomicSync 软件包中的各种“Lock”功能。这些功能之一是互斥锁,我们将在本文的其余部分中介绍。

Mutex

创建一个银行余额的简单 demo :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
package main

import "strconv"

var myBalance = &balance{amount: 50.00, currency: "CNY"}

type balance struct {
	amount   float64
	currency string
}

func (b *balance) Add(i float64) {
	// This is racy
	b.amount += i
}

func (b *balance) Balance() string {
	// This is racy
	return strconv.FormatFloat(b.amount, 'f', 2, 64) + " " + b.currency
}

我们知道,如果有使用此代码并调用多个线程myBalance.Add()myBalance.Balance() 频率足够高,那么在某个时间点的数据竞争很可能发生。

防止数据竞争的一种方法是确保如果一个 Goroutine 正在使用该myBalance变量,那么将阻止(或相互排斥)所有其他 Goroutine 同时使用它。

可以通过创建 Mutex 并在其周围的特定代码(临界区) 上加来做到这一点。当一个 Goroutine 持有该锁时,所有其他 Goroutine 均被阻止执行受同一互斥锁保护的任何代码行,并被迫等待直到锁被释放之后,才能继续执行。

以下是加锁之后的代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
import (
	"strconv"
	"sync"
)

var mu = &sync.Mutex{}
var myBalance = &balance{amount: 50.00, currency: "CNY"}

type balance struct {
	amount   float64
	currency string
}

func (b *balance) Add(i float64) {
	mu.Lock()
	b.amount += i
	mu.Unlock()
}

func (b *balance) Balance() string {
	mu.Lock()
	amt := b.amount
	cur := b.currency
	mu.Unlock()
	return strconv.FormatFloat(amt, 'f', 2, 64) + " " + cur
}

这里创建了一个新的 mutex(互斥锁),并将其分配给 mu 。然后我们使用 mu.Lock() 在这两段代码的 racy 部分之前立即创建一个锁,而 mu.Unlock() 则在之后立即释放锁。

有两个注意点:

  • 同一互斥变锁可以在整个代码中的多个位置使用。只要它是相同的 mutex(在我们的例子是mu),那么受其保护的临界区都不能同时执行。
  • 持有一个 mutex 并不能 “保护 “一个内存位置不被读取或更新。非临界区的代码 仍然可以在任何时候被访问并产生竞态条件。因此,需要确保代码中的所有存在数据竞争的点,都受到相同的 mutex 保护。

让我们整理一下 demo:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import (
	"strconv"
	"sync"
)

var myBalance = &balance{amount: 50.00, currency: "CNY"}

type balance struct {
	amount   float64
	currency string
	mu       sync.Mutex
}

func (b *balance) Add(i float64) {
	b.mu.Lock()
  defer b.mu.Unlock()
	b.amount += i
}

func (b *balance) Balance() string {
	b.mu.Lock()
	defer b.mu.Unlock()
	return strconv.FormatFloat(b.amount, 'f', 2, 64) + " " + b.currency
}

因为互斥锁仅在balance对象的上下文中使用,所以将其嵌入到balance结构中是有意义的。 有很多mutexes的大型代码库,比如 Go 的 HTTP Server,可以看到这种处理方法让锁定规则更加简洁和清晰。使用了 defer 对 mutex 进行解锁,确保在执行函数返回之前立即释放 mutex,这也是一个常见的做法。

RWMutex

如果同时进行的是唯一操作是读取共享数据,则不必担心数据竞争。

在上面的银行余额例子中,在 Balance() 函数上有一个完整的 mutex 锁并不是严格意义上的必要。我们可以同时对 myBalance 进行多次读取,只要不写任何东西就可以了。

我们可以使用 RWMutex 来实现这个目的. RWMutex 是一种读写排斥锁,允许任何数量的 Reader 或一个 Writer 持有该锁。在读和写很频繁的情况下,这往往比使用一个完整的 Mutex 更有效。

读锁可以通过 RLock()RUnlock() 进行加锁和解锁:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import (
	"strconv"
	"sync"
)

var myBalance = &balance{amount: 50.00, currency: "CNY"}

type balance struct {
	amount   float64
	currency string
	mu       sync.RWMutex
}

func (b *balance) Add(i float64) {
	b.mu.Lock()
  defer b.mu.Unlock()
	b.amount += i
}

func (b *balance) Balance() string {
	b.mu.RLock()
	defer b.mu.RUnlock()
	return strconv.FormatFloat(b.amount, 'f', 2, 64) + " " + b.currency
}

上面这个例子, 只在 Add()操作的时候, 对 amount 加上写锁,而在 Balance()的时候, 对 amount 加上读锁, 实现更快的读取效率, 同时保证 amount 不会存在数据竞争的问题。

总结

本文讲述了一个常见的账户余额的数据竞争场景, 并引入了 Go 语言在解决并发过程中常见的解决方法: Mutex, 包括互斥锁读写互斥锁 , 通过互斥访问来临界区的数据, sync.Mutex 的加锁和解锁来保证改语句同一时刻只被一个线程访问; 通过 sync.RwMutex 来解决 Mutex 在高读写场景下性能较低的问题。解决数据竞争问题,除了上面互斥锁,还可以通过 atomic cas 指令来实现乐观锁。

参考

  1. Spanner的事务
  2. Concurrency with Shared Variables in Go Language
  3. Dancing with Go’s Mutexes
  4. Go Mutex Tutorial