秒懂 Golang 中的 条件变量(sync.Cond)

时间:2022-12-14 20:11:52

本篇文章面向的读者: 已经基本掌握Go中的 协程(goroutine)通道(channel)互斥锁(sync.Mutex)读写锁(sync.RWMutex) 这些知识。如果对这些还不太懂,可以先回去把这几个知识点解决了。

首先理解以下三点再进入正题:

  • Go中的一个协程 可以理解成一个独立的人,多个协程是多个独立的人
  • 多个协程都需要访问的 共享资源(比如共享变量) 可以理解成 多人要用的某种公共社会资源
  • 上锁 其实就是加入到某个共享资源的争抢组中上锁完成 就是从争抢组中被选出,得到了期待的共享资源;解锁 就是退出某个共享资源的争抢组。 

假如有这样一个现实场景:在一个公园中有一个公共厕所,这个厕所一次只能容纳一个人上厕所,同时这个厕所中有个放卷纸的位置,其一次只能放一卷纸,一卷纸的总长度是 5 米,而每个人上一次厕所需要用掉 1 米的纸。而当一卷纸用完后,公园管理员要负责给厕所加上一卷新纸,以便大家可以继续使用厕所。 那么对于这个单人公共厕所,大家只能排队上厕所,当每个人进到厕所的时候,当然会把厕所门锁好,以便任何人都进不来(包括管理员)。管理员若要进到厕所查看用纸情况并加卷纸,也需要排队(因为插队总是不文明对吧)。

那么怎么用 Golang 去模拟上述场景呢?

首先我们先不用 sync.Cond,看如何实现?那么请看下面这段代码:

package main

import (
	"fmt"
	"time"
	"sync"
)

var 卷纸 int
var m sync.Mutex
var wg sync.WaitGroup

func 上厕所(姓名 string){
	m.Lock()
	defer func(){
		m.Unlock()
		wg.Done()
	}()
	fmt.Printf("%s 进到厕所\t",姓名)
	if 卷纸 >= 1 {  // 进到厕所第一件事是看还有没有纸
		fmt.Printf("正在拉屎中...\n")
		time.Sleep(time.Second)
		卷纸 -= 1
		fmt.Printf("%s 已用完厕所,正在离开\n",姓名)
		return
	} 
	fmt.Printf("发现纸用完了,无奈先离开厕所\n")
}

func 加厕纸(){
	m.Lock()
	defer func(){
		m.Unlock()
		wg.Done()
	}()
	fmt.Printf("公园管理员 进到厕所\t")
	if 卷纸 <= 0 { // 管理员进到厕所是看纸有没有用完
		fmt.Printf("公园管理员 正在加新纸...\n")
		time.Sleep(time.Millisecond*500)
		卷纸 = 5
		fmt.Printf("公园管理员 已加上新厕纸,正在离开\n")
	}else{
		fmt.Printf("发现纸还没用完,先离开厕所\n")
	}
}

func main() {
	卷纸 = 5 // 厕所一开始就准备好了一卷纸,长度5米
	要排队上厕所的人 := [...]string{"老王","小李","老张","小刘","阿明","欣欣","西西","芳芳"}
	for _,谁 := range 要排队上厕所的人 {
		wg.Add(1)
		go 上厕所(谁)
	}
	wg.Add(1)
	go 加厕纸()
	wg.Wait()
}

/* 
输出(由于协程执行顺序的不可预测性,因此每次输出的顺序都可能不一样):

公园管理员 进到厕所     发现纸还没用完,先离开厕所
阿明 进到厕所   正在拉屎中...
阿明 已用完厕所,正在离开
老王 进到厕所   正在拉屎中...
老王 已用完厕所,正在离开
小刘 进到厕所   正在拉屎中...
小刘 已用完厕所,正在离开
小李 进到厕所   正在拉屎中...
小李 已用完厕所,正在离开
老张 进到厕所   正在拉屎中...
老张 已用完厕所,正在离开
欣欣 进到厕所   发现纸用完了,无奈先离开厕所
芳芳 进到厕所   发现纸用完了,无奈先离开厕所
西西 进到厕所   发现纸用完了,无奈先离开厕所
*/

  

上面的代码已经能看出一些效果,但还是有问题:最后三个人因为厕纸用完,都直接离开厕所后就没有后续了?应该是他们离开厕所后再次尝试排队,直到需求解决,就离开厕所不再参与排队了,否则要不断去排队上厕所。而公园管理员呢,他要一直去排队进到厕所里看还有没有纸,而不是看一次就再也不管了。 那么请看下面的完善代码:

package main

import (
	"fmt"
	"sync"
	"time"
)

var (
	卷纸    int
	m     sync.Mutex
	wg    sync.WaitGroup
	厕所的排队 chan string
)

func 上厕所(姓名 string) {
	m.Lock() // 该语句的调用只说明本执行体(可理解成该姓名所指的那个人)加入到了厕所资源的争抢组中;
	         // 而该语句的完成调用,才代表了从争抢组中脱颖而出,抢到了厕所;在完成调用之前,会一直阻塞在这里(可理解为这个人正在争抢中)
	defer func() {
		m.Unlock()
		wg.Done()
	}()
	fmt.Printf("%s 进到厕所\t", 姓名)
	if 卷纸 >= 1 { // 进到厕所第一件事是看还有没有纸
		fmt.Printf("正在拉屎中...\n")
		time.Sleep(time.Second)
		卷纸 -= 1
		fmt.Printf("%s 已用完厕所,正在离开\n", 姓名)
		return
	}
	fmt.Printf("发现纸用完了,无奈先离开厕所\n")
	厕所的排队 <- 姓名 // 再次加入厕所排队,期望下次可以成功如厕
}

func 加厕纸() {
	m.Lock()
	defer m.Unlock()
	fmt.Printf("公园管理员 进到厕所\t")
	if 卷纸 <= 0 { // 管理员进到厕所是看纸有没有用完
		fmt.Printf("公园管理员 正在加新纸...\n")
		time.Sleep(time.Millisecond * 500)
		卷纸 = 5
		fmt.Printf("公园管理员 已加上新厕纸,正在离开\n")
	} else {
		fmt.Printf("发现纸还没用完,先离开厕所\n")
	}
}

func main() {
	卷纸 = 5                                                                // 厕所一开始就准备好了一卷纸,长度5米
	要上厕所的人 := [...]string{"老王", "小李", "老张", "小刘", "阿明", "欣欣", "西西", "芳芳"} // 这里只是举几个人名例子,假设此处有源源不断的人去上厕所(读者可以随意改造人名来源)
	厕所的排队 = make(chan string, len(要上厕所的人))
	for _, 谁 := range 要上厕所的人 {
		厕所的排队 <- 谁
	}
	go func() { // 在这个执行体中,会不断从 厕所排队 中把人加入到 对厕所资源的争抢组中
		for 谁 := range 厕所的排队 {
			wg.Add(1)
			go 上厕所(谁)
		}
	}()
	wg.Add(1)
	go func() { // 在这个执行体中,代表公园管理员的个人时间线,他会每隔一段时间去加入争抢组进到厕所,检查纸还有没有
		for {
			time.Sleep(time.Millisecond * 1200)
			加厕纸()
		}
	}()
	wg.Wait()
}

/*
输出:

老王 进到厕所   正在拉屎中...
老王 已用完厕所,正在离开
芳芳 进到厕所   正在拉屎中...
芳芳 已用完厕所,正在离开
阿明 进到厕所   正在拉屎中...
阿明 已用完厕所,正在离开
小刘 进到厕所   正在拉屎中...
小刘 已用完厕所,正在离开
欣欣 进到厕所   正在拉屎中...
欣欣 已用完厕所,正在离开
小李 进到厕所   发现纸用完了,无奈先离开厕所
老张 进到厕所   发现纸用完了,无奈先离开厕所
西西 进到厕所   发现纸用完了,无奈先离开厕所
公园管理员 进到厕所     公园管理员 正在加新纸...
公园管理员 已加上新厕纸,正在离开
西西 进到厕所   正在拉屎中...
西西 已用完厕所,正在离开
小李 进到厕所   正在拉屎中...
小李 已用完厕所,正在离开
老张 进到厕所   正在拉屎中...
老张 已用完厕所,正在离开
公园管理员 进到厕所     发现纸还没用完,先离开厕所
公园管理员 进到厕所     发现纸还没用完,先离开厕所
公园管理员 进到厕所     发现纸还没用完,先离开厕所
*/

  

上面这个代码在功能上基本是完善了,成功模拟了上述 多人上公厕 的场景。但仔细一想,这个场景其实有些地方是不合常理的:如果有个人进到厕所发现没纸,难道他会出来紧接着再去排队吗?如果排了三次五次甚至十次还是没有纸,还要这样不断地反复排队进去出来又排队?而公园管理员,要是这样不断反复排队进厕所查看,那么他这一天其他啥事都干不了。

所以更合理实际的情况应该是:如果一个人进到厕所发现没纸,他应该先去在旁边歇着或在附近干别的,当公园管理员加完纸后,会通过喇叭吆喝一声:“新纸已加上”。这样,附近所有因为没厕纸而歇着的人就会听到这个通知,此时,他们再去尝试排队进厕所;而公园管理员也不用不断去排队进厕所检查纸用完了没有,因为经过升级,厕所加装了一个功能,有一个纸用尽的报警按钮装在纸盒旁边,当上完厕所的人发现纸用完的时候,他会先按下这个报警按钮,再离开厕所。这个报警的声音在整个公园的各处都可以听到,所以管理员无论在哪里干啥,他都能收到这个纸用尽的报警信号,然后他才去进厕所加纸。

其实这种被动通知的模式就是 sync.Cond 的核心思想,它会减少资源消耗,达到更优的效果,下面就是改良为 sync.Cond 的实现代码:

package main

import (
	"fmt"
	"math"
	"strconv"
	"sync"
	"time"
)

var (
	卷纸   int
	m    sync.Mutex
	cond = sync.NewCond(&m)
)

func 上厕所(姓名 string) {
	m.Lock() // 该语句的调用只说明本执行体(可理解成该姓名所指的那个人)加入到了厕所资源的争抢组中;
	         // 而该语句的完成调用,才代表了从争抢组中脱颖而出,抢到了厕所;在完成调用之前,会一直阻塞在这里(可理解为这个人正在争抢中)
	defer m.Unlock()
	fmt.Printf("%s 进到厕所\t", 姓名)
	for 卷纸 < 1 { // 进到厕所第一件事是看还有没有纸
		fmt.Printf("发现纸用完了,先离开厕所在附近歇息等待信号\n")
		cond.Wait() // 该语句的调用 相当于调用了 m.Unlock() 也就是退出了争抢组,而是先歇着等待纸加上的信号;
		            // 当收到纸加上的信号后,该语句会自动执行 m.Lock(),也就是会重新加入到厕所的争抢组中;
		            // 该语句的完成调用说明已经再次成功争抢到了厕所;
		fmt.Printf("%s 等到了厕纸已加的信号,并去再次抢到了厕所\t", 姓名)
	}
	fmt.Printf("正在拉屎中...\n")
	time.Sleep(time.Second)
	卷纸 -= 1
	fmt.Printf("%s 已用完厕所\t", 姓名)
	if 卷纸 < 1 { // 注意这里:在他用完厕所离开前,他需要看是不是纸已经用完了,如果用完了,就按下纸用尽的报警按钮,给公园管理员发送信号
		cond.Broadcast() // 想想,这里为什么不用 Signal() ?因为 Signal 只能通知到一个等待者,这样就有可能通知不到 公园管理员。可以试着把这里换成 Signal() 试下
		fmt.Printf("发现厕纸已用完,并按下了报警\t")
	}
	fmt.Printf("正在离开厕所\n")
}

func 加厕纸() {
	m.Lock()
	defer m.Unlock()
	fmt.Printf("公园管理员 进到厕所\t")
	for 卷纸 > 0 { // 管理员进到厕所是看纸有没有用完
		fmt.Printf("发现纸还没用完,先离开厕所在等纸用尽的报警消息\n")
		cond.Wait() // 如果纸没用完,就先去干其他工作,等纸用尽的报警消息
		fmt.Printf("公园管理员 等到了纸用尽的报警消息,并再次抢到了厕所\n")
	}
	fmt.Printf("公园管理员 正在加新纸...\n")
	time.Sleep(time.Millisecond * 500)
	卷纸 = 5
	cond.Broadcast() // 注意:公园管理员加完新纸后,要通过喇叭喊一声 “纸已加上” 的消息通知所有 因没纸而等待上厕所的人
	fmt.Printf("公园管理员 已加上新厕纸,并通过喇叭通知了该消息,并正在离开厕所\n")
}

func main() {
	卷纸 = 5  // 厕所一开始就准备好了一卷纸,长度5米
	要上厕所的人 := [...]string{"老王", "小李", "老张", "小刘", "阿明", "欣欣", "西西", "芳芳"} // 上厕所的人名模板
	go func() { // 在这个执行体中,代表厕所及厕所队列的时间线,厕所永远运营下去
		for i := 0; i < math.MaxInt; i++ { // 此循环通过编号加上上面的姓名模板来 创建源源不断 上厕所的人
			for _, 人名模板 := range 要上厕所的人 {
				谁 := 人名模板 + strconv.Itoa(i)
				go 上厕所(谁)
				time.Sleep(time.Millisecond * 500) // 平均每半秒有一个人去上厕所
			}
			fmt.Printf("\n====================>> 屏幕停止输出后,请按Enter键继续 <<====================\n\n")
			fmt.Scanln()
		}
	}()
	go func() { // 在这个执行体中,代表公园管理员的个人时间线,管理员永不退休
		for {
			// 注意:相比上个版本,此处不用再加 Sleep 函数了,因为 加厕纸() 函数中的 cond.Wait() 会在有纸的时候等待信号
			加厕纸()
		}
	}()
	end := make(chan bool)
	<-end
}

/*
输出:

公园管理员 进到厕所     发现纸还没用完,先离开厕所在等纸用尽的报警消息
老王0 进到厕所  正在拉屎中...
老王0 已用完厕所        正在离开厕所
小李0 进到厕所  正在拉屎中...
小李0 已用完厕所        正在离开厕所
老张0 进到厕所  正在拉屎中...
老张0 已用完厕所        正在离开厕所
小刘0 进到厕所  正在拉屎中...
小刘0 已用完厕所        正在离开厕所
阿明0 进到厕所  正在拉屎中...

====================>> 屏幕停止输出后,请按Enter键继续 <<====================

阿明0 已用完厕所        发现厕纸已用完,并按下了报警    正在离开厕所
欣欣0 进到厕所  发现纸用完了,先离开厕所在附近歇息等待信号
西西0 进到厕所  发现纸用完了,先离开厕所在附近歇息等待信号
芳芳0 进到厕所  发现纸用完了,先离开厕所在附近歇息等待信号
公园管理员 等到了纸用尽的报警消息,并再次抢到了厕所
公园管理员 正在加新纸...
公园管理员 已加上新厕纸,并通过喇叭通知了该消息,并正在离开厕所
公园管理员 进到厕所     发现纸还没用完,先离开厕所在等纸用尽的报警消息
欣欣0 等到了厕纸已加的信号,并去再次抢到了厕所  正在拉屎中...
欣欣0 已用完厕所        正在离开厕所
芳芳0 等到了厕纸已加的信号,并去再次抢到了厕所  正在拉屎中...
芳芳0 已用完厕所        正在离开厕所
西西0 等到了厕纸已加的信号,并去再次抢到了厕所  正在拉屎中...
西西0 已用完厕所        正在离开厕所

老王1 进到厕所  正在拉屎中...
老王1 已用完厕所        正在离开厕所
小李1 进到厕所  正在拉屎中...
小李1 已用完厕所        发现厕纸已用完,并按下了报警    正在离开厕所
老张1 进到厕所  发现纸用完了,先离开厕所在附近歇息等待信号
公园管理员 等到了纸用尽的报警消息,并再次抢到了厕所
公园管理员 正在加新纸...
公园管理员 已加上新厕纸,并通过喇叭通知了该消息,并正在离开厕所
公园管理员 进到厕所     发现纸还没用完,先离开厕所在等纸用尽的报警消息
小刘1 进到厕所  正在拉屎中...
小刘1 已用完厕所        正在离开厕所
阿明1 进到厕所  正在拉屎中...

====================>> 屏幕停止输出后,请按Enter键继续 <<====================
*/

  

用了 sync.Cond 的代码显然要精简了很多,而且还节省了计算资源,只会在收到通知的时候 才去抢公共厕所,而不是不断地反复去抢公共厕所。通过这个对现实场景的模拟,我们就很容易从使用者的角度理解 sync.Cond 是什么,它的字面意思就是 “条件”,这就已经点出了这东西的核心要义,就是满足条件才执行,条件是什么,信号其实就是条件,当一个执行体收到信号之后,它才去争抢共享资源,否则就会挂起等待(这种等待底层其实会让出线程,所以这种等待并不会空耗资源),比起不断轮寻去抢资源,这种方式要节省得多。

最后留给读者一个思考的问题:就是上面最后一版的代码,为什么 当纸用完后按报警按钮通知 公园管理员 要用 sync.Broadcast() 方法去广播通知?不是只通知管理员一个人吗,单独通知他不就行了,用 sync.Signal() 为什么不行?