临界资源安全的问题:
临界资源:
指并发环境中多个 进程/线程/协程 可以共享(都可以调用)的资源/变量,如果在并发环境中处理不当,就会造成一些 严重、问题
func main() { //临界资源 a := 10 go func() { a = 100 fmt.Println("goroutine a:", a) }() a = 99 fmt.Println("main goroutine a:", a) time.Sleep(1) } #输出: main goroutine a: 99 goroutine a: 100
临界资源安全问题:
并发本身并不复杂,但是有了 临界资源的竞争问题,就使得我们开发出来的并发程序变的复杂起来。应为会引起很多莫名其妙的问题。如果多个 goruotine 在访问同一个数据资源的时候,其中一个线程修改了数据,那么这个数据就被修改了,对于其他的 goroutine 来讲,这个数值很可能是不对的:
例如: 我们通过抢购牛奶,一共 10 箱,6个用户一直再抢
// 临界资源 10箱牛奶 var milk = 10 func main() { go saleMilks("用户A") go saleMilks("用户B") go saleMilks("用户C") go saleMilks("用户D") go saleMilks("用户E") go saleMilks("用户F") time.Sleep(6 * time.Second) } func saleMilks(name string) { rand.Seed(time.Now().UnixNano()) for { if milk > 0 { //模拟逻辑处理:先判断临界资源 然后逻辑处理消耗时间 释放CPU资源,别的 goroutine 可以继续执行 time.Sleep(time.Duration(rand.Intn(1000)) * time.Millisecond) fmt.Println(name, "售出:", milk) milk-- } else { fmt.Println("售罄, 没有奶了") break } } } // 程序输出: 用户D 售出: 10 用户B 售出: 10 用户F 售出: 10 用户E 售出: 10 用户C 售出: 10 用户A 售出: 10 用户F 售出: 4 用户F 售出: 3 用户B 售出: 2 用户E 售出: 1 售罄, 没有票了 用户A 售出: 0 售罄, 没有票了 用户B 售出: -1 售罄, 没有票了 用户F 售出: -2 售罄, 没有票了 用户C 售出: -3 售罄, 没有票了 用户D 售出: -4 售罄, 没有票了 Process finished with the exit code 0
很显然出现了 负数超卖的情况:
- 判断临界资源是否 大于 0
- 做相应处理。有可以是 写入 读取数据库,等其他操作。没有等待响应返回的时候,下一个 goroutine 已经开始执行了
- 这个时候 就有可能 发生 临界值不一直的情况
临界资源安全问题的解决:
想要解决临界资源安全问题,很多编程语言的方案都是同步,通过上锁的方式 某一时刻,只能容许一个 goroutine 来访问这个共享数据,当前 goroutine 访问完毕,解锁后 其他 goroutine 才能来访问。
我们可以借助于 sync 包下的 sync.Mutex 锁操作:
package main import ( "fmt" "math/rand" "sync" "time" ) // 临界资源 10箱牛奶 var milk = 10 var wg sync.WaitGroup var mutex sync.Mutex //创建锁头 func main() { wg.Add(6) //这里设置个数的意义不大,应为有锁的存在 saleMilks 只容许 一个 goroutine 访问 go saleMilks("用户A") go saleMilks("用户B") go saleMilks("用户C") go saleMilks("用户D") go saleMilks("用户E") go saleMilks("用户F") //time.Sleep(6 * time.Second) wg.Wait() fmt.Println("All tasks completed") } func saleMilks(name string) { defer wg.Done() // 在goroutine中使用Mutex保护共享资源 mutex.Lock() defer mutex.Unlock() rand.Seed(time.Now().UnixNano()) for { if milk > 0 { //模拟逻辑处理:先判断临界资源 然后逻辑处理消耗时间 释放CPU资源,别的 goroutine 可以继续执行 time.Sleep(time.Duration(rand.Intn(1000)) * time.Millisecond) fmt.Println(name, "售出:", milk) milk-- } else { fmt.Println(name, "售罄, 没有奶了") break } } } /// 输出: 这里看到 一直是 用户A 拿到资源,应为第一个 goroutine 就是用户A,只带他 执行完毕 自他的 goroutine 才继续执行, 从侧面也印证了 资源是一直 锁的状态, 没有释放锁之前 其他goroutine 进不来 用户A 售出: 10 用户A 售出: 9 用户A 售出: 8 用户A 售出: 7 用户A 售出: 6 用户A 售出: 5 用户A 售出: 4 用户A 售出: 3 用户A 售出: 2 用户A 售出: 1 用户A 售罄, 没有奶了 用户F 售罄, 没有奶了 用户C 售罄, 没有奶了 用户D 售罄, 没有奶了 用户E 售罄, 没有奶了 用户B 售罄, 没有奶了 All tasks completed
go 中临界资源的核心思路:
上面 锁 的思路在 go 语言中是不提倡的,只是使用上 相对更便捷一些!在 go 的并发编程中有一个很经典的话: 不要以共享内存的方式去通信, 而要以通信的方式共享内存。 在 go 语言中并不鼓励 用锁的方式共享资源,而是鼓励通过 channel 的方式共享状态或者共享状态 变化在各个 goroutine 之间传递(以通信的方式去共享内存),这样同样能像锁 一样保证在同一时刻只有一个 goroutine 访问共享资源。
当然 在主流的编程语言中为了保证多线程之间的共享数据安全性和一致性,都会提供一套基本的同步工具集,如锁 条件变量 原子操作等等。 go语言标准库 也豪不意外提供了这些机制,使用方式也和其他语言差不多。
使用通道实现锁
// 临界资源 10箱牛奶 var milk = 10 var ch = make(chan struct{}, 1) // 创建一个有缓冲的通道作为信号量,容量为1,表示只能有一个goroutine可以通过 func main() { go saleMilks("用户A") go saleMilks("用户B") go saleMilks("用户C") go saleMilks("用户D") go saleMilks("用户E") go saleMilks("用户F") time.Sleep(6 * time.Second) fmt.Println("All tasks completed") } func saleMilks(name string) { ch <- struct{}{} // 发送信号,获取锁 // 释放锁 rand.Seed(time.Now().UnixNano()) for { if milk > 0 { //模拟逻辑处理:先判断临界资源 然后逻辑处理消耗时间 释放CPU资源,别的 goroutine 可以继续执行 time.Sleep(time.Duration(rand.Intn(1000)) * time.Millisecond) fmt.Println(name, "售出:", milk) milk-- } else { fmt.Println(name, "售罄, 没有奶了") break } } <-ch // 释放锁,从通道接收数据 } ///输出: 用户A 售出: 10 用户A 售出: 9 用户A 售出: 8 用户A 售出: 7 用户A 售出: 6 用户A 售出: 5 用户A 售出: 4 用户A 售出: 3 用户A 售出: 2 用户A 售出: 1 用户A 售罄, 没有奶了 用户B 售罄, 没有奶了 用户D 售罄, 没有奶了 用户E 售罄, 没有奶了 用户F 售罄, 没有奶了 用户C 售罄, 没有奶了 All tasks completed