Go语言中使用sync包控制并发

sync.Mutex 和 sync.RWMutex

如果同一块内存被多个 goroutine 同时访问,就会产生不知道谁先访问也无法预料最后结果的情况。这就是资源竞争,这块内存可以称为共享的资源。例如下面的代码

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
var sum = 0
func main() {
	for i := 1; i < 5; i++ {
		go add(i)
	}
	time.Sleep(2 * time.Second)
	fmt.Println("和为:", sum)
}
func add(i int) {
	fmt.Println("add前sum=", sum)
	sum += i
	fmt.Println("add ", i, "后sum=", sum)
}

运行结果:
add前sum= 0
add  2 后sum= 2
add前sum= 0
add前sum= 0
add  4 后sum= 7
add  1 后sum= 3
add前sum= 2
add  3 后sum= 10
和为: 10

通过上面的代码我们发现我们创建的4个协程再执行add中的语句时是有可能同时执行的,这就是资源竞争,并没有得到想要结果。那我们有没有什么办法能在同一时刻只有一个协程去读取sum呢?这就要用到sync包了。看下面的代码

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
var sum = 0
var mutex sync.Mutex

func main() {
	for i := 1; i < 5; i++ {
		go add(1)
	}
	time.Sleep(2 * time.Second)
	fmt.Println("和为:", sum)
}
func add(i int) {
	mutex.Lock()
	defer mutex.Unlock()
	fmt.Println("add前sum=", sum)
	sum += i
	fmt.Println("add ", i, "后sum=", sum)
}
运行结果
add前sum= 0
add  2 后sum= 2
add前sum= 2
add  1 后sum= 3
add前sum= 3
add  4 后sum= 7
add前sum= 7
add  3 后sum= 10
和为: 10

通过上面的代码我们发现虽然协程执行的顺序并不是按照for循环中的顺序来(给出的顺序是2143 只是一种结果,如果按照for循环的顺序来应该是1234),但是没一个协程在执行add函数中的语句时别的协程是没有参与进来的,这就是互斥锁。 但是这样每次读写共享资源都要加锁,会导致性能低下,这该怎么解决呢?

现在我们分析读写这个特殊场景,有以下几种情况:

  • 写的时候不能同时读,因为这个时候读取的话可能读到脏数据(不正确的数据);
  • 读的时候不能同时写,因为也可能产生不可预料的结果;
  • 读的时候可以同时读,因为数据不会改变,所以不管多少个 goroutine 读都是并发安全的。

所以就可以通过读写锁 sync.RWMutex 来优化这段代码,提升性能。

1
2
3
4
5
6
7
8
var mutex sync.RWMutex
func add(i int) {
	mutex.RLock()
	defer mutex.RUnlock()
	fmt.Println("add前sum=", sum)
	sum += i
	fmt.Println("add ", i, "后sum=", sum)
}

这样多个 goroutine 可以同时读数据,不再相互等待。

sync.Mutex 和 sync.RWMutex 都是悲观锁

sync.WaitGroup

细心的你可能会发现每次运行程序后输出结果前都会等待2秒,这是因为为了等待所有的协程都执行结束,不然main函数结束了程序就退出了,那有些协程可能还没开始就结束了。

但是每次都等2秒或者说如果2s的时间不够那怎么办呢?是不是应该让所有协程执行结束后就输出结果然后程序结束。

这就要用到sync包中的WaitGroup,我们先看下WaitGroup的描述。

A WaitGroup waits for a collection of goroutines to finish. The main goroutine calls Add to set the number of goroutines to wait for. Then each of the goroutines runs and calls Done when finished. At the same time, Wait can be used to block until all goroutines have finished. A WaitGroup must not be copied after first use.

大概意思就是说在协程执行前执行一下Add方法,协程临近结束前执行一下Done方法。 在主程序中执行Wait方法就会阻塞程序,直到所有的协程都完成。 举个🌰

1
2
3
4
5
6
7
8
func main() {
	for i := 1; i < 10000; i++ {
		go func(i int) {
			fmt.Println(i)
		}(i)
	}
	time.Sleep(1 * time.Nanosecond)
}

上面的代码在一个for循环中执行了10000个匿名函数的协程,协程的内容是输出i 然后程序延迟1毫微秒结束,1毫微秒是时间内能输出多少个数字呢?大概率到不到10000个,那怎么才能让他在正好输出10000个数字后就停止呢?使用WaitGroup,看下面的代码改造

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
var wg sync.WaitGroup
func main() {
	for i := 1; i < 10000; i++ {
		wg.Add(1)
		go func(i int) {
			fmt.Println(i)
			wg.Done()
		}(i)
	}
	wg.Wait()
}

这样就能在输出10000个数字后结束程序

sync.Cond

现在我们可以在找到所有协程同时完成的时间点了那有没有办法让所有的协程同时开始呢? 在 Go 语言中,sync.WaitGroup 用于最终完成的场景,关键点在于一定要等待所有协程都执行完毕。而 sync.Cond 可以用于发号施令,一声令下所有协程都可以开始执行,关键点在于协程开始的时候是等待的,要等待 sync.Cond 唤醒才能执行。

sync.Cond 从字面意思看是条件变量,它具有阻塞协程和唤醒协程的功能,所以可以在满足一定条件的情况下唤醒协程,但条件变量只是它的一种使用场景。

下面我以 5 个人赛跑为例来演示 sync.Cond 的用法。在这个示例中有一个裁判,裁判要先等这 5 个人准备就绪,然后一声发令枪响,这 5 个人就可以开始跑了,如下所示:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
//5个人赛跑,1个裁判发号施令
func race() {
	cond := sync.NewCond(&sync.Mutex{})
	var wg1, wg2 sync.WaitGroup
	wg1.Add(5)
	wg2.Add(5)
	rand.Seed(time.Now().Unix())
	for i := 1; i <= 5; i++ {
		go func(num int) {
			log.Println(num, "号已经就位")
			wg1.Done()
			cond.L.Lock()
			log.Println(num, "号等待发令枪响")
			cond.Wait() //等待发令枪响
			cond.L.Unlock()
			go func() {
				log.Println(num, "号开始跑……")
				time.Sleep(time.Second * time.Duration(3+rand.Intn(4)))
				log.Println(num, "号跑完了")
				wg2.Done()
			}()

		}(i)
	}
	//等待所有goroutine都进入wait状态
	wg1.Wait()
	go func() {
		log.Println("裁判已经就位,准备发令枪")
		log.Println("比赛开始,大家准备跑")
		cond.Broadcast() //发令枪响
	}()
	//防止函数提前返回退出
	wg2.Wait()
	log.Println("所有人都跑完了")
}

1.通过 sync.NewCond 函数生成一个 *sync.Cond,用于阻塞和唤醒协程;

2.然后启动 5 个协程模拟 5 个人,准备就位后调用 cond.Wait() 方法阻塞当前协程等待发令枪响,这里需要注意的是调用 cond.Wait() 方法时要加锁;

3.wg1是等待所有人准备完毕后使用 cond.Broadcast() 发号施令,wg2是等待所有人都跑完程序结束;

4.sync.Cond 有三个方法,它们分别是:

①Wait,阻塞当前协程,直到被其他协程调用 Broadcast 或者 Signal 方法唤醒,使用的时候需要加锁,使用 sync.Cond 中的锁即可,也就是 L 字段。

②Signal,唤醒一个等待时间最长的协程。

③Broadcast,唤醒所有等待的协程。

注意:在调用 Signal 或者 Broadcast 之前,要确保目标协程处于 Wait 阻塞状态,不然会出现死锁问题。

sync.Cond 和 Java 的等待唤醒机制很像,它的三个方法 Wait、Signal、Broadcast 就分别对应 Java 中的 wait、notify、notifyAll。