五、《go 并发编程》互斥锁

173 阅读6分钟
提到互斥锁,我们先来抽象一个场景

现在有个厕所,只有一个坑,多个人来到厕所,想方便方便,但是,只有一个坑 于是,只能依次进去,同时进去之后,为了防止后面的人趁你没方便完又进来方便,就会把厕所门锁上,搞定之后,就会开锁,出来腾出位置,让第二个人进去方便,第二个进去后进行前面一样的上锁、解锁的动作 以上的行为,在程序里理解就称为 互斥锁

再来看互斥锁的说明

在并发程序中,会存在临界资源安全问题。就是`多个协程来访问共享的数据资源,那么这个共享资源是不安全的。为了解决协程同步的问题我们使用了channel,但是Go语言也提供了传统的同步工具。

什么是锁呢?就是某个协程(线程)在访问某个资源时先锁住,防止其它协程的访问,等访问完毕解锁后其他协程再来加锁进行访问。一般用于处理并发中的临界资源问题。

Go语言包中的 sync 包提供了两种锁类型:sync.Mutex 和 sync.RWMutex。

Mutex 是最简单的一种锁类型,互斥锁,同时也比较暴力,当一个 goroutine 获得了 Mutex 后,其他 goroutine 就只能乖乖等到这个 goroutine 释放该 Mutex。

每个资源都对应于一个可称为 “互斥锁” 的标记,这个标记用来保证在任意时刻,只能有一个协程(线程)访问该资源。其它的协程只能等待

互斥锁是传统并发编程对共享资源进行访问控制的主要手段,它由标准库sync中的Mutex结构体类型表示。sync.Mutex类型只有两个公开的指针方法,Lock和Unlock。Lock锁定当前的共享资源,Unlock进行解锁。

在使用互斥锁时,一定要注意:对资源操作完成后,一定要解锁,否则会出现流程执行异常,死锁等问题。通常借助defer。锁定后,立即使用defer语句保证互斥锁及时解锁。

官方的建议

建议使用channel来处理同步,但是也提供这种传统的同步工具,所以选型上,还是要根据业务

看下代码
package main

import (
	"fmt"
	"math/rand"
	"sync"
	"time"
)

// wc 的坑位数量 默认为零值,也就是 = 0
var pit int

// 定义一个锁的对象
var mutex sync.Mutex

func main() {
	// 这里模拟 5 个人去上WC
	go wc("甲")
	go wc("乙")
	go wc("丙")
	go wc("丁")
	go wc("卯")
	// 主 goroutine 让出时间片让子 goroutine 去抢占执行
	time.Sleep(3 * time.Second)
	fmt.Println("打扫。。。")
}

func wc(human string) {
	// 增加随机种子
	rand.Seed(time.Now().UnixNano())

	// 这个动作,在程序里可以理解为,当程序开始运行时,第一个随机的子 goroutine 访问时就开始上锁
	// 抽象理解可以为第一个上 wc 的人
	fmt.Println(human, "尝试开门")
	if pit == 0 {
		fmt.Println(human, "进门,开始办事")
		// 坑位已占
		pit = 1
		fmt.Println(human, "办事中。。。")
		time.Sleep(time.Duration(rand.Intn(100)) * time.Microsecond)
		fmt.Println(human, "办完出门")
		// 虚坑以待。。
		pit = 0
	} else {
		fmt.Println(human, "发现有人")
	}
}
没加锁的结果
# 上面的逻辑,倒不会产生一个坑被多个人占的现象,但是仔细观察,会发现只有随机 1~2 个人成功执行了"办事",其它人尝试开门没成功后,并没有执行"办事",最后程序结束
乙 尝试开门
乙 进门,开始办事
卯 尝试开门
卯 发现有人
乙 办事中。。。
甲 尝试开门
甲 发现有人
丙 尝试开门
丙 发现有人
乙 办完出门
丁 尝试开门
丁 进门,开始办事
丁 办事中。。。
丁 办完出门
打扫。。。

互斥锁处理
package main

import (
	"fmt"
	"math/rand"
	"sync"
	"time"
)

// wc 的坑位数量 默认为零值,也就是 = 0
var pit int

// 定义一个锁的对象
var mutex sync.Mutex

func main() {
	// 这里模拟 5 个人去上WC
	go wc("甲")
	go wc("乙")
	go wc("丙")
	go wc("丁")
	go wc("卯")
	// 主 goroutine 让出时间片让子 goroutine 去抢占执行
	time.Sleep(3 * time.Second)
	fmt.Println("打扫。。。")
}

func wc(human string) {
	// 增加随机种子
	rand.Seed(time.Now().UnixNano())

	// 这个动作,在程序里可以理解为,当程序开始运行时,第一个随机的子 goroutine 访问时就开始上锁
	// 抽象理解可以为第一个上 wc 的人
	mutex.Lock()
	fmt.Println(human, "尝试开门")
	if pit == 0 {
		fmt.Println(human, "进门,上锁")
		// 坑位已占
		pit = 1
		fmt.Println(human, "办事中。。。")
		time.Sleep(time.Duration(rand.Intn(100)) * time.Microsecond)
		fmt.Println(human, "办完出门")
		// 虚坑以待。。
		pit = 0
	} else {
		fmt.Println(human, "发现有人")
	}
	mutex.Unlock()
}
上锁结果
甲 尝试开门
甲 进门,上锁
甲 办事中。。。
甲 办完出门
卯 尝试开门
卯 进门,上锁
卯 办事中。。。
卯 办完出门
丁 尝试开门
丁 进门,上锁
丁 办事中。。。
丁 办完出门
丙 尝试开门
丙 进门,上锁
丙 办事中。。。
丙 办完出门
乙 尝试开门
乙 进门,上锁
乙 办事中。。。
乙 办完出门
打扫。。。
再看前面 临界资源安全问题的例子出现超卖问题,使用互斥锁
package main

import (
	"fmt"
	"math/rand"
	"sync"
	"time"
)

// 商品数量
var commodity int = 10
var m sync.Mutex

func main() {
	// 为了加快程序的响应,这里启动 4 个 goroutine 来处理卖的动作
	go buy("①")
	go buy("②")
	go buy("③")
	go buy("④")
	time.Sleep(3 * time.Second)
}

func buy(user string) {
	// 增加随机种子
	rand.Seed(time.Now().UnixNano())
	for {
		m.Lock()
		// 当商品数量足够时
		if commodity > 0 {
			// 这里模拟购买的处理时间,毕竟真实的场景受到服务器资源、IO处理、网络因素的影响
			// 处理的时长都不会完全一致
			time.Sleep(time.Duration(rand.Intn(100)) * time.Microsecond)
			commodity--
			fmt.Println(user, "窗口卖走1个,剩余:", commodity)
		} else {
      // 条件不满足时也要解锁
      mutex.Unlock()
			fmt.Println("哦吼,售罄卖完了。。。")
			// 结束循环
			break
		}
		m.Unlock()
	}
}
购买例子执行结果
④ 窗口卖走1个,剩余: 9
④ 窗口卖走1个,剩余: 8
④ 窗口卖走1个,剩余: 7
④ 窗口卖走1个,剩余: 6
④ 窗口卖走1个,剩余: 5
④ 窗口卖走1个,剩余: 4
④ 窗口卖走1个,剩余: 3
④ 窗口卖走1个,剩余: 2
④ 窗口卖走1个,剩余: 1
④ 窗口卖走1个,剩余: 0
哦吼,售罄卖完了。。。
结合同步等待组,去除主 goroutine 的睡眠
package main

import (
	"fmt"
	"math/rand"
	"sync"
	"time"
)

// 商品数量
var commodity int = 10
var m sync.Mutex
// 同步等待组
var w sync.WaitGroup

func main() {
	// 为了加快程序的响应,这里启动 4 个 goroutine 来处理卖的动作
  
  // 设置 4 个等待组
	w.Add(4)
	go buy("①")
	go buy("②")
	go buy("③")
	go buy("④")
  
  // 进入阻塞
	w.Wait()
}

func buy(user string) {
	// 增加随机种子
	rand.Seed(time.Now().UnixNano())
  // 运行结束,等待组 -1
	defer w.Done()
	for {
		m.Lock()
		// 当商品数量足够时
		if commodity > 0 {
			// 这里模拟购买的处理时间,毕竟真实的场景受到服务器资源、IO处理、网络因素的影响
			// 处理的时长都不会完全一致
			time.Sleep(time.Duration(rand.Intn(100)) * time.Microsecond)
			commodity--
			fmt.Println(user, "窗口卖走1个,剩余:", commodity)
		} else {
			m.Unlock()
			fmt.Println("哦吼,售罄卖完了。。。")
			// 结束循环
			break
		}
		m.Unlock()
	}
}