【golang】1、用 double check 正确的锁临界区

如果写到并发的程序,就要考虑加锁。而加锁很容易出现 bug,且极难排查。本文以 golang 语言为例,介绍怎样正确地锁住临界区。

在这里插入图片描述

一、错误的互斥锁示例

我们以一段测试代码为例,初始化一个 map,我们并发的调用 f 函数,希望用 map 做去重。并且为了减小锁的粒度,我们用了读写锁,读操作互相不阻塞,而写操作互相阻塞,代码如下:

package main

import (
	"github.com/datager/codes/gocodes/dg/utils/signal"
	"sync"
	"time"
)

var (
	mp    = map[string]struct{
    
    }{
    
    }
	mutex = sync.RWMutex{
    
    }
)

func main() {
    
    
	go f("goroutine1:", "k")
	go f("goroutine2:", "k")
	signal.WaitForExit()
}

func f(tag string, k string) {
    
    
	mutex.RLock()
	_, exist := mp[k]
	mutex.RUnlock()
	if exist {
    
    
		println(tag, "already exist")
		return
	}

	// business logic
	time.Sleep(time.Second)

	mutex.Lock()
	mp[k] = struct{
    
    }{
    
    }
	println(tag, "because not exist, so put into it, now len is", len(mp))
	mutex.Unlock()
}

然而,打印结果如下,我们并发地调用了函数 f,并且用了相同的参数 k,但却并未起到去重的作用,如下结果的第一行 len = 1,而第二行的 len 居然不为 2:

goroutine2: because not exist, so put into it, now len is 1
goroutine1: because not exist, so put into it, now len is 1

这是因为 RLock、RUnlockLock、Unlock 之间的代码并未受临界区保护:假设以 goroutine1 与 goroutine2 经过 RLock 均得到 !exist 的结果为起点,当 goroutine1 先 Lock 并 UnLock 后,goroutine2 才拿到 Lock 的话,goroutine 2 就会覆盖 goroutine1 的结果,导致 mp[k] = struct{}{} 被错误地执行了2次。

根源是:读是为了写,如果有读操作,且有写操作,则应将读写操作绑定作为一个临界区。而不应该有2个临界区。

二、粗暴的临界区

所以,粗暴地方式,就如上文所说,将读写操作绑定作为一个临界区,代码如下:

package main

import (
	"sync"
	"time"
)

var (
	mp    = map[string]struct{
    
    }{
    
    }
	mutex = sync.Mutex{
    
    }
)

func main() {
    
    
	go f("goroutine1:", "k")
	go f("goroutine2:", "k")
	signal.WaitForExit()
}

func f(tag string, k string) {
    
    
	mutex.Lock()
	_, exist := mp[k]
	if exist {
    
    
		println(tag, "already exist")
		mutex.Unlock()
		return
	}

	// business logic
	time.Sleep(time.Second)

	mp[k] = struct{
    
    }{
    
    }
	println(tag, "because not exist, so put into it, now len is", len(mp))
	mutex.Unlock()
}

运行后,正确的得到了输出,效果如下:

goroutine1: because not exist, so put into it, now len is 1
goroutine2: already exist

三、double check 来提升性能

然而,为了性能,我们可以用常见的 double check 方式,来处理临界区。

即先无锁检查,再锁 + 判断 + 处理 + 解锁。

通过最先的无锁检查,可以避免没必要的临界区锁定,从而提升性能。代码如下:

package main

import (
	"github.com/datager/codes/gocodes/dg/utils/signal"
	"sync"
	"time"
)

var (
	mp    = map[string]struct{
    
    }{
    
    }
	mutex = sync.Mutex{
    
    }
)

func main() {
    
    
	go f("goroutine1:", "k")
	go f("goroutine2:", "k")
	signal.WaitForExit()
}

func f(tag string, k string) {
    
    
	_, firstExist := mp[k]
	if firstExist {
    
    
		println(tag, "already exist from first check")
		return
	}

	mutex.Lock()
	_, exist := mp[k]
	if exist {
    
    
		println(tag, "already exist from second check")
		mutex.Unlock()
		return
	}

	// business logic
	time.Sleep(time.Second)

	mp[k] = struct{
    
    }{
    
    }
	println(tag, "because not exist, so put into it, now len is", len(mp))
	mutex.Unlock()
}

正确地输出了结果,其中 goroutine1 加锁了,而后续拿到锁的 goroutine2 在无需拿锁的情况下就得到了 firstExist 的结果并提前 return,在结果如下:

goroutine1: because not exist, so put into it, now len is 1
goroutine2: already exist from second check

四、总结

本文总结了锁临界区的常见问题,因为读是为了写,所以需要将读和写放在同一个临界区中来保证正确性,并且为了性能可以用 double check 的方式减少锁的次数。

猜你喜欢

转载自blog.csdn.net/jiaoyangwm/article/details/127242263
今日推荐