共享资源并发访问,sync.Mutex解决Data Race
欢迎小伙伴们观看我的文章,若文中有不正确的地方,小伙伴们可以在评论区处指出,希望能够与小伙伴们一起成长与进步,谢谢!
一、并发问题
并发问题一直是老生常谈的问题,在许多的场景下,都会涉及到并发问题,例如竞态问题,当多个goroutine
下并发访问并操作同一个资源,例如计数器,可能导致计数器不准确;在业务场景中,并发同时更新用户的账户信息,导致用户账户透支等。
如果没有对并发问题进行一定的控制,带来的后果可能会很严重,这些问题该如何解决?在Go中,可以使用Mutex
。
互斥锁是并发控制的一个常见基本手段,为了避免竞态问题,可以使用互斥锁来对临界区进行保护。
二、临界区
在并发编程中,如果某一块程序逻辑会被并发访问或修改,为了避免并发操作导致的无法预料的后果,这一部分程序逻辑需要被保护起来,这部分被保护的程序,即称为临界区。
简单来说,临界区其实就是被共享的资源,比如对数据库的访问、对某个共享数据结构的操作吗、对I/O
设备的使用、对一个连接池中的连接调用等等,都可以看做是一个临界区。
在多个线程并发访问临界区造成的一些访问或操作错误,这并不是我们想看到的结果,因此使用互斥锁,限定临界区只能由一个线程持有,可以避免一些错误的发生。
使用互斥锁,当一个线程获取到互斥锁,持有临界区时,如果其他线程想要进入临界区,就会返回失败或者进行等待,直到持有临界区的线程退出临界区,释放互斥锁,这样等待的线程才能够获取到互斥锁,进入临界区。
由上图可以看到,互斥锁通过限定临界区,能够很好解决资源访问等竞态问题。
三、Mutex
在Go标准库中,提供了Mutex
来实现互斥锁。Mutex
是使用最广泛的同步原语。
同步原语是一组基本的、低级别的同步和通信机制。同步原语允许程序员在多线程或多协程的环境中安全地共享资源、同步操作和传递消息。
同步原语的使用场景:
- 共享资源:并发情况下读写共享资源,会出现数据竞态的问题,在Go中可以使用互斥锁
Mutex
、读写锁RWMutex
等并发原语来保护临界区。- 任务编排:多个
goroutine
按照一定的执行顺序执行,多个goroutine
之间存在互相等待或者互相依赖的顺序关系,在Go中可以使用WaitGroup
或者Channel
来实现任务编排。- 消息传递:不同的
goroutine
之间在某些场景下需要进行数据交流,在Go中可以使用Channel
来实现消息传递。
在Go中的sync包中提供了一系列的同步原语,其中Mutex
实现了这个接口Locker接口,sync包中Locker接口的定义如下:
type Locker interface {
Lock()
Unlock()
}
Locker锁接口的方法集定义了两个方法,一个是获取锁(Lock)
和释放锁(Unlock)
两个方法。而Mutex
实现了该两个方法,即实现了Locker
接口。
互斥锁Mutex
实现了Lock方法与Unlock方法,其基本使用为:进入临界区之前调用 Lock 方法,退出临界区的时候调用 Unlock 方法;
func (m *Mutex) Lock()
func (m *Mutex) Unlock()
当某个goroutine
调用互斥锁Mutex
的Lock
方法,获取到互斥锁时,其他goroutine
请求锁则会阻塞在Lock
方法的调用上,直到获取锁的goroutine
释放互斥锁,此时其他goroutine
则可以获得互斥锁。
举个例子:
package main
import (
"fmt"
"sync"
)
var count = 0
var wg sync.WaitGroup
func incr() {
defer wg.Done()
for i := 0; i < 10000; i++ {
count++
}
}
func main() {
wg.Add(10)
for i := 0; i < 10; i++ {
go incr()
}
wg.Wait()
fmt.Println(count)
}
上述代码的执行结果打印,使用 sync.WaitGroup
来等待所有的 goroutine
执行完毕后,再输出最终的结果。预期结果为100000
,但执行多次,结果却是例如54186、40948等不同的结果。
原因在于:count++
并非原子操作,该操作其实分为几个步骤:
- 读取
count
变量 - 对变量值
+1
- 将结果保存到
count
由于并非原子操作,则可能存在并发问题。例如,可能存在多个goroutine
同时读取到了相同的count
的值,并将其+1
后将结果写回到count
变量中,而此时并发goroutine
之间可能会存在结果覆盖,从而导致count
变量的结果不如预期。
Go 提供了一个检测并发访问共享资源是否有问题的工具:Go race detector
。在代码运行的时候,race detector
就能监控到对共享变量的非同步访问,出现 race 的时候,就会打印出警告信息。
在编译(compile)、测试(test)或者运行(run)
Go 代码的时候,加上 race 参数,就有可能发现并发问题。例如上述代码,在控制台输入go run -race incr.go
执行上述代码,则会输出警告信息。
通过警告信息可以发现,goroutine 8
对内存地址0x00000025e510
进行读操作(代码中的第14行,即count++
),goroutine 7对内存地址0x00000025e510
进行写操作(代码中的第14行,即count++
)。
race detector
固然方便,但该工具只能在实际对内存地址进行读写访问的时候才能探测,并不能在编译的时候发现 data race
的问题,并且在运行时,只有触发了 data race 之后,才能检测到,如果碰巧没有触发,是检测不出来的。开启race
的程序部署在线上,会比较影响程序的性能。
上述例子存在数据竞争的问题,通过使用Mutex
,可以轻松的解决数据竞争的问题。
首先,可以明确上述例子的共享资源为count
变量,临界区为count++
,只需要在临界区前,设置goroutine
需要获取到互斥锁才能能够访问,在退出临界区时释放互斥锁,则可以解决数据竞争的问题。
package main
import (
"fmt"
"sync"
)
var count = 0
var wg sync.WaitGroup
var lock sync.Mutex
func incr() {
defer wg.Done()
for i := 0; i < 10000; i++ {
lock.Lock()
count++
lock.Unlock()
}
}
func main() {
wg.Add(10)
for i := 0; i < 10; i++ {
go incr()
}
wg.Wait()
fmt.Println(count)
}
多次执行上述代码,可以发现每次的执行结果都为100000
,且没有出现data race
的警告。
Mutex
的零值是还没有 goroutine
等待的未加锁的状态,具体来说,就是Mutex
声明后,其零值表示该锁处于未加锁的状态,并且没有任何goroutine
正在等待获取该锁。
Mutex
不需要额外的初始化,直接声明变量(var mu sync.Mutex
)即可使用。
Mutex
还可以将其嵌入到struct结构体中使用,例如:
type Count struct {
sync.Mutex
Count uint64
}
上述结构体定义,通过匿名成员变量的方式将sync.Mutex
嵌入到struct结构体中,初始化该结构体时,无需初始化Mutex
成员变量即可使用,通过使用上述结构体,将例子改造如下:
package main
import (
"fmt"
"sync"
)
var wg sync.WaitGroup
var counter Counter
type Counter struct {
sync.Mutex
Count uint64
}
func incr() {
defer wg.Done()
for i := 0; i < 10000; i++ {
counter.Lock()
counter.Count++
counter.Unlock()
}
}
func main() {
wg.Add(10)
for i := 0; i < 10; i++ {
go incr()
}
wg.Wait()
fmt.Println(counter.Count)
}
同时,还可以通过为该结构体定义方法,将获取锁、释放锁、计数加一的逻辑封装成一个方法,对外不需要暴露锁等逻辑,改造后的代码如下:
package main
import (
"fmt"
"sync"
)
var wg sync.WaitGroup
var counter Counter
type Counter struct {
sync.Mutex
count uint64
}
// Incr 加1的方法,内部使用互斥锁保护
func (c *Counter) Incr() {
c.Lock()
c.count++
c.Unlock()
}
// Count 得到计数器的值,也需要锁保护
func (c *Counter) Count() uint64 {
c.Lock()
defer c.Unlock()
return c.count
}
// 操作方法
func operator() {
defer wg.Done()
for i := 0; i < 10000; i++ {
counter.Incr()
}
}
func main() {
wg.Add(10)
for i := 0; i < 10; i++ {
go operator()
}
wg.Wait()
fmt.Println(counter.Count())
}
上述代码中,获取Counter
结构体的计数器值count
也需要加锁的原因在于,在CPU
上的写操作,不一定能够及时同步到另一个CPU
核中,通过加锁的方式,来保证写操作完成后,才读取计数器值count
。
四、总结
在程序设计的过程中,并发问题一直是需要仔细考虑的问题,多线程下对于共享资源的操作,往往有可能会出现数据竞争的并发问题。计数器的例子展示出了数据竞争的并发问题,通过Go语言提供的Mutex
互斥锁的同步原语来锁定临界区,解决数据竞争的并发问题,了解并使用Mutex
。
转载自:https://juejin.cn/post/7317957468961619983