共计 3512 个字符,预计需要花费 9 分钟才能阅读完成。
背景
在应用 Go 构建 Web 应用程序时,所有传入的 HTTP 申请都会被路由到对应解决逻辑的 Goroutine 中。如果应用程序在解决申请的时候,有读写同一块内存数据,就存在竞态条件的危险。(Spanner 反对 读写锁定 的事务模式,单个逻辑工夫点以原子形式执行一组读写, 不存在竞态条件问题)
<!–more–>
数据竞争
一个很常见的竞态条件场景就是银行账户余额的读写。思考一种状况,有两个 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,以及 Atomic 和 Sync 软件包中的各种“Lock”性能。这些性能之一是互斥锁,咱们将在本文的其余部分中介绍。
Mutex
创立一个银行余额的简略 demo:
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 均被阻止执行受同一互斥锁爱护的任何代码行,并被迫期待直到锁被开释之后,能力继续执行。
以下是加锁之后的代码:
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:
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()
进行加锁和解锁:
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 指令来实现乐观锁。
参考
- Spanner 的事务
- Concurrency with Shared Variables in Go Language
- Dancing with Go’s Mutexes
- Go Mutex Tutorial