在Go语言中,不仅有channel这类比较易用且高级的同步机制,还有sync.Mutex 、sync.WaitGroup等比较原始的同步机制。通过它们,我们可以更加灵活的控制数据的同步和多协程的并发。
资源竞争
在一个goroutine中,如果分配的内存没有被其他goroutine访问,之后在该goroutine中是哟和哪个,那么不存在资源竞争问题。但如果同一块内容被多个goroutine同时访问,就会产生不知道谁先访问也就无法预料结果。这就是资源竞争,这块内存也可以称为共享资源。
// 共享资源
sum := 0
func main () {
// 开启100个协程 sum+10
for i:=0; i <100; i++ {
go add(10)
}
// 防止提前退出
tiem.Sleep(2 * time.Second)
fmt.Println("和为:", sum)
}
func add(i int) {
sum += i
}
// 示例中,结果可能是990或者980,并不是预期的1000。导致这种情况发送的核心原因是sum并不是并发安全的,因为同时有多个协程交叉执行 sum += i,产生不可预料的结果。
小技巧:使用go build、go run、go test这些Go语言工具链提供的命令式,添加-race标记可以帮我们检查Go语言代码是否存在资源竞争。
同步原语
sync.Mutex
互斥锁,顾名思义,指的是同一时刻只有一个协程执行某段代码,其它协程都要等待该协程执行完才能继续执行。 改造上述示例,声明一个互斥锁mutex,修改add函数,访问共享资源的代码片段就并发安全了。
sum:= 0
mutex := sync.Mutex
func add(i int) {
mutex.Lock()
defer mutex.Unlock()
sum += i
}
// 被解锁保护的sum += i 代码片段又称为临界区。在同步原语设计中,临界区指的是一个访问共享资源的代码片段,而这些共享资源又有无法同时被多个协程访问的特征。当有协程进入临界区段时,其它协程必须等待,这样就保证了临界区的并发安全。
互斥锁的使用非常简单,它只有两个方法Lock()和Unlock(),代表加锁和解锁。当一个协程获得Mutex锁后,其它协程只能等到Mutex锁释放后才能再次获得锁。
Muetx 的 Lock 和 Unlock 方法总是成对出现,而且要确保Lock 获得锁后,一定执行Unlock释放锁,所以在函数或方法中会 采用defer 语句释放锁。 这样可以确保所以丁被释放,不会被遗忘。
sync.RWMutex
sync.Muetx对共享资源进行了加锁,保证并发安全。如果读取操作也是多个协程呢?
func main() {
for i := 0; i < 100; i ++ {
go add(10)
}
for i := 1; i <10 ; i++ {
go fmt.Println("和为:", getSum())
}
time.Sleep(2 * time.Second)
}
func getSum() int {
b := sum
return b
}
// 开启了10个协程,同时读取sum的值。因为getSum函数没有任何加锁控制,所以它不是并发安全的,即一个goroutine在执行sum+=i 操作时,另一个goroutine可能在执行 b:=sum 操作,这就会导致读取的sum值是一个过期的值,结果不可预期, 要解决资源竞争问题,可以使用互斥锁sync.Mutex
func getSum() int {
mutex.Lock()
defer mutex.Unlock()
b := sum
return b
}
因为add 和 getSum函数使用的是同一个sync.Muetx,所以它们的操作是互斥的,也就是一个goroutine进行修改操作时,另一个goroutine读取操作会等待,直到修改操作完毕。
我们解决了goroutine同时读写的资源竞争问题,但是又遇到了另一个问题——性能。因为每次读写共享资源都要加锁,所以性能底下,如何解决呢?
读写这个特殊的场景,有以下几种情况:
1. 写的时候不能同时读,因为这个时候读取的话可能读到脏数据
2. 读的时候不能同时写,因为也可能产生不可预料的结果
3. 读的时候可以同时读,因为数据不会改变,所以不管多少个goroutine读都是并发安全的
所以可以通过sync.RWMutex 来优化此段代码,提升性能。改用读写锁,来实现我们想要的结果
var mutex snyx.RWMutex
func getSum() int {
// 获取读写锁
mutex.RLock()
defer mutex.RUnlock()
b := sum
return b
}
// 这样性能会有很大提升,因为多个goroutine可以同时读取数据,不再相互等待。
sync.WaitGroup
我们上述的示例中使用了time.Sleep函数, 是为了防止主函数main() 返回,一旦main函数范湖了, 程序也就退出了。 提示:一个函数或方法的返回(return) 也就意味着当前函数执行完毕。
如我们我们执行100个add协程和10个getSum协程,不知道什么时候执行完毕,所以设置了比较长的等待时间,但是存在一个问题,如果这110个协程很快就执行完毕,main函数应该提前返回,但是还要等待一会才能返回,会产生性能问题。但是如果等待时间截止时,协程没有执行完毕,程序会提前退出,导致有协程没有执行完,产生不可预知的结果。
如何解决这个问题呢? 有没有办法监听所有协程的执行,一旦全部执行完毕,程序马上退出,这样既可保证所有协程执行完毕,又可以及时退出节省时间,提升性能。Channel可以解决这个问题,不过很复杂。Go语言提供了更简洁的方法,它就是 sync.WaitGroup。
func run() {
var wg sync.WaitGroup
wg.Add(110)
for i := 0; i< 100; i ++ {
go func() {
// 计数器减1
defer wg.Done()
add(10)
}()
}
for i := 1; i < 10 ; i++ {
go func() {
defer wg.Done()
fmt.Println("sum is", getSum())
}()
}
// 一直等待,直到计数器值为0
wg.Wait()
}
sync.WaitGroup使用比较简单,一共分为三步:
- 声明一个sync.WaitGroup,然后通过Add方法设置计数器的值,需要跟踪多少个协程就设置多少
- 在每个协程执行完毕时调用Done方法,让计数器减1,告诉sync.WaiGroup 该协程已经执行完毕
- 最后调用Wait方法一直等待,直到计数器值为0,也就是所有跟踪的协程都执行完毕
通过sync.WaitGroup可以很好地跟踪协程。在协程执行完毕后,整个函数才会执行完毕,时间不多不少,正好是协程执行的时间。
sync.WaitGroup适合协调多个协程共同做一件事情的场景,比如下载一个文件,假设使用10个协程,每个协程写在1/10,只有10个协程都下载好了整个文件才下载好了。这就是我们经常听说的多线程下载,通过多个线程共同做一件事情,显著提高效率。小提示我们可以把Go语言的协程理解为平常说的线程,从用户体验上并无不可,但是从技术实现上,它们是不一样的。
sync.Once
在实际工作中,可能会有这样的需求:让代码只执行一次,哪怕是高并发情况下,比如创建一个单例。针对这种情形,Go语言为我们提供了sync.Once来保证代码只执行一次。
func main() {
doOnce()
}
func doOnce() {
var once sync.Once
onceBody := func() {
fmt.Println("Only once")
}
// 用于等待协程执行完毕
done := make(chan bool)
// 启动10个协程执行once.Do(onceBody)
for i := 1; i < 10; i++ {
go func(){
go func() {
// 把要执行的函数(方法)作为参数传给once.Do方法即可
once.Do(onceBody)
done <- true
}
}()
}
for i := 10; i < 10 ; i++{
<- done
}
}
这是Go语言自带的一个示例,虽然启动了10个协程来执行onceBody函数,但是因为用了once.Do方法,所以函数onceBody只会执行一次。也就是在高并发的情况下,sync.Once也会保证onceBody函数只执行一次。
sync.Once 适用于创建某个对象的单例、只加载一次的资源等只执行一次的场景。、
sync.Cond
在Go语言中,sync.WaitGroup用于最终完成的场景,关键点在于一定要等待所有协程都执行完毕。而sync.Cond 可以用于发号施令,一声令下所有协程都可以开始执行,关键点在于协程开始的时候是等待的,要等待sync.Cond唤醒才能执行。
sync.Cond从字面意思看是条件变量,它具有阻塞协程和唤醒协程的功能,所有可以在满足一定条件的情况下唤醒协程,但条件变量只是它的一种使用场景。
// 10个人赛跑,1人裁判发号施令
func race() {
cond := sync.NewCond(&sync.Mutex{})
var wg sync.WaitGroup
wg.Add(11)
for i := 1; i < 10; i++ {
go func(num int) {
defer wg.Done()
fmt.Println(num, "号已就位")
cond.L.Lock()
cond.Wait() // 等待发令枪响
fmt.Println(num, "号开始跑")
cond.L.Unlock()
}(i)
}
time.Sleep(2 * time.Second)
go func(){
defer wg.Done()
fmt.Println("裁判已就位,准备发令枪")
fmt.Println("比赛开始,大家开始跑")
cond.Broadcast() // 发令枪响
}()
wg.wait()
}
大概步骤:
- 通过sync.NewCond函数生成一个*sync.Cond,用于阻塞和唤醒协程
- 然后启动10个协程模拟10个人,准备就位后调用cond.Wait()方法阻塞当前协程等待发令枪响,这里需要注意的是调用cond.Wait()方法是要加锁
- time.Sleep 用于等待所有人都进入wait状态,这样裁判才能调用cond.Broadcast()发号施令
- 裁判准备完毕后,可以调用cond.Broadcast通知所有人开始跑了
sync.Cond 有三个方法,分别是:
- Wait,阻塞当前协程,知道被其他协程调用Broadcast 或者 Signal 方法唤醒,使用的时候需要加锁,使用sync.Cond中的锁即可,也就是L字段
- **Signal **,唤醒一个等待时间最长的协程
- Broadcast,唤醒所有等待的协程
注意:在调用signal 或者Broadcast之前,要确保目标协程处于Wait阻塞状态,不然会出现死锁问题。
小结:我们了解了Go语言的同步原语使用,通过它们可以更灵活的控制多协程的并发。从使用上讲,Go语言还是更推荐channel这种更高级别的并发控制方式,因为它更简洁,也更容易理解和使用。同步原语通常用于更复杂的并发控制,如果追求更灵活的控制方式和性能,可以使用它们。