互斥是并发编程中最关键的概念之一。当我们使用 goruntine 和channels 进行并发编程时,如果两个 goruntine 尝试同时访问同一个内存位置的同一数据会发生竞争,有时候会产生意想不到的结果,通常很难调试,不符合日常要求,出现错误甚至很难修复。
生活场景
假设在生活中可能会发生的例子:有一个银行系统,我们可以从银行余额中存款和取款。在一个单线程的同步程序中,这个操作很简单。我们可以通过少量的单元测试有效地保证它每次都能按计划工作。
然而,如果我们开始引入多个线程,在 Go 语言中使用多个 goroutine,我们可能会开始在我们的代码中看到问题。
- 假如有一个余额为 1000 元的客户。
- 客户将 500 元存入他的账户。
- 一个 goroutine 会看到这个交易,读取价值为 1000 ,并继续将 500 添加到现有的余额中。(此时应该是 1500 的余额)
- 然而,在同一时刻,他拿 800 元来还分期付款的 iphone 13.
- 第二个程序在第一个程序能够增加 500 元的额外存款之前,读取了 1000 元的账户余额,并继续从他的账户中扣除 800 元。(1000 - 800 = 200)
- 第二天,客户检查了他的银行余额,发现他的账户余额减少到了 200 元,因为第二个程序没有意识到第一笔存款,并在存款完成前做了扣除操作。
这就是一个线程竞赛的例子,如果我们不小心落入这样的代码,我们的并发程序就会出现问题。
互斥锁和读写锁
互斥锁,英文名 Mutex,顾名思义,就是相互排斥,是保护程序中临界区的一种方式。
而临界区是程序中需要独占访问共享资源的区域。互斥锁提供了一种安全的方式来表示对这些共享资源的独占访问。
为了使用资源,channel 通过通信共享内存,而 Mutex 通过开发人员的约定同步访问共享内存。
让我们看一个没有 Mutex 的并发编程示例
package main
import (
"fmt"
"sync"
)
type calculation struct {
sum int
}
func main() {
test := calculation{}
test.sum = 0
wg := sync.WaitGroup{}
for i := 0; i < 500; i++ {
wg.Add(1)
go dosomething(&test, &wg)
}
wg.Wait()
fmt.Println(test.sum)
}
func dosomething(test *calculation, wg *sync.WaitGroup) {
test.sum++
wg.Done()
}
第一次结果为:491
第二次结果:493
[Running] go run "e:\Coding Workspaces\LearningGoTheEasiestWay\concurrency\mutex\v0\main.go"
493
在上面的例子中,我们声明了一个名为 test 的计算结构体,并通过 for 循环产生了多个 GoRoutines,将 sum 的值加 1。(如果你对 GoRoutines 和 WaitGroup 不熟悉,请参考之前的教程)。 我们可能期望 for 循环后 sum 的值应该是 500。然而,这可能不是真的。 有时,您可能会得到小于 500(当然永远不会超过 500)的结果。 这背后的原因是两个 GoRoutine 有一定的概率在相同的内存位置操作相同的变量,从而导致这种意外结果。 这个问题的解决方案是使用互斥锁。
使用 Mutex
package main
import (
"fmt"
"sync"
)
type calculation struct {
sum int
mutex sync.Mutex
}
func main() {
test := calculation{}
test.sum = 0
wg := sync.WaitGroup{}
for i := 0; i < 500; i++ {
wg.Add(1)
go dosomething(&test, &wg)
}
wg.Wait()
fmt.Println(test.sum)
}
func dosomething(test *calculation, wg *sync.WaitGroup) {
test.mutex.Lock()
test.sum++
test.mutex.Unlock()
wg.Done()
}
结果为:
[Running] go run "e:\Coding Workspaces\LearningGoTheEasiestWay\concurrency\mutex\v0.1\main.go"
500
在第二个示例中,我们在结构中添加了一个互斥锁属性,它是一种类型的 sync.Mutex。然后我们使用互斥锁的 Lock() 和 Unlock() 来保护 test.sum 当它被并发修改时,即 test.sum++。
请记住,使用互斥锁并非没有后果,因为它会影响应用程序的性能,因此我们需要适当有效地使用它。 如果你的 GoRoutines 只读取共享数据而不写入相同的数据,那么竞争条件就不会成为问题。 在这种情况下,您可以使用 RWMutex 代替 Mutex 来提高性能时间。
Defer 关键字
对 Unlock() 使用 defer 关键字通常是一个好习惯。
func dosomething(test *calculation) error{
test.mutex.Lock()
defer test.mutex.Unlock()
err1 :=...
if err1 != nil {
return err1
}
err2 :=...
if err2 != nil {
return err2
}
// ... do more stuff ...
return nil
}
在这种情况下,我们有多个 if err!=nil 这可能会导致函数提前退出。 通过使用 defer,无论函数如何返回,我们都可以保证释放锁。 否则,我们需要将 Unlock() 放在函数可能返回的每个地方。 然而,这并不意味着我们应该一直使用 defer。 让我们再看一个例子。
func dosomething(test *calculation){
test.mutex.Lock()
defer test.mutex.Unlock()
// modify the variable which requires mutex protect
test.sum =...
// perform a time consuming IO operation
http.Get()
}
在这个例子中,mutex 不会释放锁,直到耗时的函数(这里是 http.Get())完成。 在这种情况下,我们可以在 test.sum=... 行之后解锁互斥锁,因为这是我们操作变量的唯一地方。
总结
很多时候 Mutex 并不是单独使用的,而是嵌套在 Struct 中使用,作为结构体的一部分,如果嵌入的 struct 有多个字段,我们一般会把 Mutex 放在要控制的字段上面,然后使用空格把字段分隔开来。
甚至可以把获取锁、释放锁、计数加一的逻辑封装成一个方法。