Go并发编程之传统同步—(1)互斥锁

前言

先回顾一下,在 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 的不定值。
竞争状态下程序行为的图像表示
image

加锁

将刚刚的代码稍作改动

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。
image

解锁

这时候有个疑问,那 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。

总结

  1. 锁一定要用对地方,特别是要注意Lock产生的阻塞对性能的影响。
  2. 在各种程序的逻辑分支下,都要确保锁的成对出现。
  3. 读写锁是对互斥锁的一个扩展,提高了程序的可读性。
  4. 临界区是需要每个 goroutine 主动遵守的,说白了就是每个 goroutine 的代码都存在 Lock。

文章示例代码

Sown专栏地址:https://segmentfault.com/blog/sown

阅读 2.9k

推荐阅读
Sown
用户专栏

学习笔记

238 人关注
8 篇文章
专栏主页