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

前言

先回顾一下,在 C 或者其它编程语言的并发编程中,主要存在两种通讯(IPC):git

  • 进程间通讯:管道、消息队列、信号等
  • 线程间通讯:互斥锁、条件变量等

利用以上通讯手段采起的同步措施,最终是为了达到如下两种目的:github

  • 维持共享数据一致性,并发安全
  • 控制流程管理,更好的协同工做

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)
}

结果spa

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

文章示例代码

相关文章
相关标签/搜索