共计 3499 个字符,预计需要花费 9 分钟才能阅读完成。
前言
先回顾一下,在 C 或者其它编程语言的并发编程中,次要存在两种 通信(IPC):
- 过程间通信:管道、音讯队列、信号等
- 线程间通信:互斥锁、条件变量等
利用以上通信伎俩采取的同步措施,最终是为了达到以下两种目标:
- 管制流程治理,更好的协同工作
- 维持共享数据一致性,并发平安
Go 语言中除了保留了传统的同步反对,还提供了特有的 CSP 并发编程模型。
传统同步
互斥量
接下来通过一个“做累加”的示例程序,展现 竞争状态(race condition)。
不加锁
开启 5000 个 goroutine,让每个 goroutine 给 counter 加 1,最终在所有 goroutine 都实现工作时 counter 的值应该为 5000,先试下不加锁的示例程序体现如何
func TestDemo1(t *testing.T) {
counter := 0
for i := 0; i < 5000; i++ {go func() {counter++}()}
time.Sleep(1 * time.Second)
t.Logf("counter = %d", counter)
}
后果
=== RUN TestDemo1
a1_test.go:18: counter = 4663
--- PASS: TestDemo1 (1.00s)
PASS
多试几次,后果始终是小于 5000 的不定值。
竞争状态下程序行为的图像示意
加锁
将刚刚的代码稍作改变
func TestDemo2(t *testing.T) {
var mut sync.Mutex // 申明锁
counter := 0
for i := 0; i < 5000; i++ {go func() {mut.Lock() // 加锁
counter++
mut.Unlock() // 解锁}()}
time.Sleep(1 * time.Second)
t.Logf("counter = %d", counter)
}
后果
=== RUN TestDemo2
a1_test.go:35: counter = 5000
--- PASS: TestDemo2 (1.01s)
PASS
counter = 5000,返回的后果对了。
这就是 互斥锁 ,在代码上创立一个 临界区(critical section),保障串行操作(同一时间只有一个 goroutine 执行临界区代码)。
阻塞
那么互斥锁是怎么串行的呢?把每一步的执行过程打印进去看下
func TestDemo3(t *testing.T) {
var mut sync.Mutex
counter := 0
go func() {mut.Lock()
log.Println("goroutine B Lock")
counter = 1
log.Println("goroutine B counter =", counter)
time.Sleep(5 * time.Second)
mut.Unlock()
log.Println("goroutine B Unlock")
}()
time.Sleep(1 * time.Second)
mut.Lock()
log.Println("goroutine A Lock")
counter = 2
log.Println("goroutine A counter =", counter)
mut.Unlock()
log.Println("goroutine A Unlock")
}
后果
=== RUN TestDemo3
2020/09/30 22:14:00 goroutine B Lock
2020/09/30 22:14:00 goroutine B counter = 1
2020/09/30 22:14:05 goroutine B Unlock
2020/09/30 22:14:05 goroutine A Lock
2020/09/30 22:14:05 goroutine A counter = 2
2020/09/30 22:14:05 goroutine A Unlock
--- PASS: TestDemo3 (5.00s)
PASS
通过每个操作记录下来的工夫能够看出,goroutine A 的 Lock 始终阻塞到了 goroutine B 的 Unlock。
解锁
这时候有个疑难,那 goroutine B 上的锁,goroutine A 能解锁吗?批改一下方才的代码,试一下
func TestDemo5(t *testing.T) {
var mut sync.Mutex
counter := 0
go func() {mut.Lock()
log.Println("goroutine B Lock")
counter = 1
log.Println("goroutine B counter =", counter)
time.Sleep(5 * time.Second)
//mut.Unlock()
//log.Println("goroutine B Unlock")
}()
time.Sleep(1 * time.Second)
mut.Unlock()
log.Println("goroutine A Unlock")
counter = 2
log.Println("goroutine A counter =", counter)
time.Sleep(2 * time.Second)
}
后果
=== RUN TestDemo5
2020/09/30 22:15:03 goroutine B Lock
2020/09/30 22:15:03 goroutine B counter = 1
2020/09/30 22:15:04 goroutine A Unlock
2020/09/30 22:15:04 goroutine A counter = 2
--- PASS: TestDemo5 (3.01s)
PASS
测试通过,未报错,counter 的值也被胜利批改,证实 B 上的锁,是能够被 A 解开的。
再进一步,goroutine A 不解锁,间接批改曾经被 goroutine B 锁住的 counter 的值能够吗?试一下
func TestDemo6(t *testing.T) {
var mut sync.Mutex
counter := 0
go func() {mut.Lock()
log.Println("goroutine B Lock")
counter = 1
log.Println("goroutine B counter =", counter)
time.Sleep(5 * time.Second)
mut.Unlock()
log.Println("goroutine B Unlock")
}()
time.Sleep(1 * time.Second)
//log.Println("goroutine A Unlock")
//mut.Unlock()
counter = 2
log.Println("goroutine A counter =", counter)
time.Sleep(10 * time.Second)
}
后果
=== RUN TestDemo6
2020/09/30 22:15:43 goroutine B Lock
2020/09/30 22:15:43 goroutine B counter = 1
2020/09/30 22:15:44 goroutine A counter = 2
2020/09/30 22:15:48 goroutine B Unlock
--- PASS: TestDemo6 (11.00s)
PASS
测试通过,未报错,证实 B 上的锁,A 能够不必解锁间接改。
延长
锁的两种通常解决形式
- 一种是没有获取到锁的线程就始终循环期待判断该资源是否曾经开释锁,这种锁叫做 自旋锁,它不必将线程阻塞起来(NON-BLOCKING);
- 还有一种解决形式就是把本人阻塞起来,期待从新调度申请,这种叫做互斥锁。
饥饿模式
当互斥锁一直地试图取得一个永远无奈取得的锁时,它可能会遇到饥饿问题。
在版本 1.9 中,Go 通过增加一个新的饥饿模式来解决先前的问题,所有期待锁定超过一毫秒的 goroutine,也称为有界期待,将被标记为饥饿。当标记为饥饿时,解锁办法当初将把锁间接移交给第一位期待着。
读写锁
读写锁和下面的多也差不多,有这么几种状况
- 在写锁已被锁定的状况下试图锁定写锁,会阻塞以后的 goroutine。
- 在写锁已被锁定的状况下试图锁定读锁,会阻塞以后的 goroutine。
- 在读锁已被锁定的状况下试图锁定写锁,会阻塞以后的 goroutine。
- 在读锁已被锁定的状况下试图锁定读锁,不会阻塞以后的 goroutine。
panic 谬误
无论是互斥锁还是读写锁在程序运行时肯定是成对的,不然就会引发不可复原的 panic。
总结
- 锁肯定要用对中央,特地是要留神 Lock 产生的阻塞对性能的影响。
- 在各种程序的逻辑分支下,都要确保锁的成对呈现。
- 读写锁是对互斥锁的一个扩大,进步了程序的可读性。
- 临界区是须要每个 goroutine 被动恪守的,说白了就是每个 goroutine 的代码都存在 Lock。
文章示例代码