互斥是并发编程中最关键的概念之一。当我们使用 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 放在要控制的字段上面,然后使用空格把字段分隔开来。
甚至可以把获取锁、释放锁、计数加一的逻辑封装成一个方法。