1
头图

大家好,今天将梳理出的 Go语言并发知识内容,分享给大家。 请多多指教,谢谢。

本次《Go语言并发知识》内容共分为三个章节,本文为第三章节。

本章节内容

  • 基本同步原语
  • 常见的锁类型
  • 扩展内容

基本同步原语

Go 语言在 sync 包中提供了用于同步的一些基本原语,包括常见的互斥锁 Mutex 与读写互斥锁 RWMutex 以及 OnceWaitGroup。这些基本原语的主要作用是提供较为基础的同步功能,本次仅对 Mutex展开介绍,剩余其他原语将在后续并发章节中使用。

qPYtXT.jpg

Mutex 是什么

Mutex 是 golang 标准库的互斥锁,主要用来处理并发场景下共享资源的访问冲突问题。

Mutex 互斥锁在 sync 包中,它由两个字段 statesema 组成,state 表示当前互斥锁的状态,而 sema 真正用于控制锁状态的信号量,这两个加起来只占 8 个字节空间的结构体就表示了 Go 语言中的互斥锁。

type Mutex struct {
    state int32
    sema  uint32
}

互斥锁的作用,就是同步访问共享资源。互斥锁这个名字来自互斥(mutual exclusion)的概念,互斥锁用于在代码上创建一个临界区,保证同一个时间只有一个 goroutine 可以执行这个临界区代码。

package main

import (
    "fmt"
    "runtime"
    "sync"
)

var (
    counter int
    wg sync.WaitGroup
    mutex sync.Mutex // 定义代码临界区
)

func main() {
    wg.Add(2)
    go incCounter()
    go incCounter()
    wg.Wait()
    fmt.Println("counter:", counter)
}

func incCounter() {
    defer wg.Done()
    for count := 0; count < 2; count++ {
        mutex.Lock() // 临界区, 同一时刻只允许一个 goroutine 进入
        {
            value := counter
            runtime.Gosched() // goroutine退出,返回队列
            value++
            counter = value
        }
        mutex.Unlock() // 释放锁
    }
}

Lock()Unlock() 函数调用定义的临界区里被保护起来。 使用大括号只是为了让临界区看起来更清晰,并不是必需的。同一时刻只有一个 goroutine 可以进入临界区,直到调用 Unlock() 函数之后,其他 goroutine 才能进入临界区。

Mutex 几种状态

  • mutexLocked — 表示互斥锁的锁定状态;
  • mutexWoken — 表示从正常模式被从唤醒;
  • mutexStarving — 当前的互斥锁进入饥饿状态;
  • waitersCount — 当前互斥锁上等待的 Goroutine 个数;

正常模式和饥饿模式

sync.Mutex 有两种模式 — 正常模式和饥饿模式。

在正常模式中,锁的等待者会按照先进先出的顺序获取锁。但是刚被唤起的 Goroutine 与新创建的 Goroutine 竞争时,大概率会获取不到锁,为了减少这种情况的出现,一旦 Goroutine 超过 1ms 没有获取到锁,它就会将当前互斥锁切换饥饿模式,防止部分 Goroutine 被 "饿死"。

饥饿模式是在 Go 语言在 1.9 中通过提交 sync: make Mutex more fair 引入的优化,引入的目的是保证互斥锁的公平性。

在饥饿模式中,互斥锁会直接交给等待队列最前面的 Goroutine。新的 Goroutine 在该状态下不能获取锁、也不会进入自旋状态,它们只会在队列的末尾等待。如果一个 Goroutine 获得了互斥锁并且它在队列的末尾或者它等待的时间少于 1ms,那么当前的互斥锁就会切换回正常模式。

常见锁类型

死锁、活锁与饥饿

关于这三种锁模式,已经在 [Golang 基础之并发知识 (一)]() 文章中进行了简单说明,上文中针对饥饿模式进行一次补充。

死锁,作为最常见的锁,这里在进行一次补充。

死锁可以理解为完成一项任务的资源被两个(或多个)不同的协程分别占用了,导致它们全都处于等待状态不能完成下去。在这种情况下,如果没有外部干预,程序将永远不会恢复。

// 死锁案例
package main

import (
    "fmt"
    "sync"
    "time"
)
type value struct {
    mu sync.Mutex
    value int
}

var wg sync.WaitGroup

func main() {
    printSum := func(v1, v2 *value) {
        defer wg.Done()
        v1.mu.Lock() // 加锁
        defer v1.mu.Unlock() // 释放锁

        time.Sleep(1 * time.Second)
        v2.mu.Lock()
        defer v2.mu.Unlock()
        fmt.Printf("sum=%v\n", v1.value+v2.value)
    }

    var a, b value
    wg.Add(2)
    go printSum(&a, &b) // 协程1
    go printSum(&b, &a) // 协程2
    wg.Wait()
}

输出

fatal error: all goroutines are asleep - deadlock!

死锁的三个动作

  1. 试图访问带锁的部分
  2. 试图调用defer关键字释放锁
  3. 添加休眠时间 以造成死锁

qC0Q61.png

实质上,我们创建了两个不能一起运转的齿轮: 我们的第一个打印总和调用a锁定,然后尝试锁定b,但与此同时,我们打印总和的第二个调用锁定了b并尝试锁定a。 两个goroutine都无限地等待着彼此。

自旋锁

介绍

自旋锁是指当一个线程在获取锁的时候,如果锁已经被其他线程获取,那么该线程将循环等待,然后不断地判断是否能够被成功获取,直到获取到锁才会退出循环。

获取锁的线程一直处于活跃状态,但是并没有执行任何有效的任务,使用这种锁会造成 busy-waiting

它是为实现保护共享资源而提出的一种锁机制。其实,自旋锁与互斥锁比较类似,它们都是为了解决某项资源的互斥使用。无论是互斥锁,还是自旋锁,在任何时刻,最多只能由一个保持者,也就说,在任何时刻最多只能有一个执行单元获得锁。但是两者在调度机制上略有不同。对于互斥锁,如果资源已经被占用,资源申请者只能进入睡眠状态。但是自旋锁不会引起调用者睡眠,如果自旋锁已经被别的执行单元保持,调用者就一直循环在那里看是否该自旋锁的保持者已经释放了锁,“自旋”一词就是因此而得名。

自旋锁与互斥锁
  • 自旋锁与互斥锁都是为了实现保护资源共享的机制。
  • 无论是自旋锁还是互斥锁,在任意时刻,都最多只能有一个保持者。
  • 获取互斥锁的线程,如果锁已经被占用,则该线程将进入睡眠状态;获取自旋锁的线程则不会睡眠,而是一直循环等待锁释放。
总结
  • 自旋锁:线程获取锁的时候,如果锁被其他线程持有,则当前线程将循环等待,直到获取到锁。
  • 自旋锁等待期间,线程的状态不会改变,线程一直是用户态并且是活动的(active)。
  • 自旋锁如果持有锁的时间太长,则会导致其它等待获取锁的线程耗尽CPU。
  • 自旋锁本身无法保证公平性,同时也无法保证可重入性。
  • 基于自旋锁,可以实现具备公平性和可重入性质的锁。

读写锁

读写锁即针对读写操作的互斥锁。 它与普通的互斥锁最大的不同,就是可以分别针对读操作和写操作进行锁定和解锁操作。读写锁遵循的访问控制规则有所不同。读写锁控制下的多个写操作之间都是互斥的,并且写操作与读操作之间也都是互斥的。

但是,多个读操作之间却不存在互斥关系。在这样的互斥策略之下,读写锁可以在大大降低因使用锁造成的性能损耗的情况下,完成对共享资源的访问控制。

Go语言中的读写锁由结构体类型 sync.RWMutex 表示。 与互斥锁一样, sync.RWMutex 类型的零值就已经是可用的读写锁实例了。

// 类型方法集
func (*RWMutex) Lock()
func (*RWMutex) Unlock()
func (*RWMutex) RLock()
func (*RWMutex) RUnlock()

扩展内容

不公平的锁

不公平的锁可被看成是饥饿的一种不太严重的表现形式,当某些线程争抢同一把锁时,其中一部分线程在绝大多数时间都可获取到锁,另一部分线程则遭遇不公平对待。这在带有共享高速缓存或者NUMA内存 的机器中可能出现,如果CPU 0释放了一把其他CPU都 想获取的锁,因为CPU 0与CPU 1共享内部连接,所以CPU 1相较于CPU 2到7更容易抢到锁。

反之亦然,如果一段时间后CPU 0又开始争抢该锁,那么CPU 1释放锁时CPU 0也更容易获取锁,导致锁绕过了CPU 2到 7,只在CPU 0和1之间换手。

低效率的锁

锁是由原子操作和内存屏障实现,并且常常带来高速缓存未命中。 这些指令代价都比较昂贵,粗略地说开销比简单指令高两个数量级。这可能是锁的一个严重问题,如果用锁来保护一条指令,你很可能在以百倍的速度带来开销。对于相同的代码,即使假设扩展性非常完美,也需要100个CPU才能跟上一个执行不加锁版本的CPU。

不过一旦持有了锁,持有者可以不受干扰地访问被锁保护的代码。 获取锁可能代价高昂,但是一旦持有,特别是对较大的临界区来说,CPU的高速缓存反而是高效的性能加速器。

技术文章持续更新,请大家多多关注呀~~

搜索微信公众号,关注我【 帽儿山的枪手 】


参考材料

《Go语言设计与实现》书籍
《Concurrency in Go》书籍
《Go 并发编程实战》书籍
《Go 语言实战》书籍
晁岳攀老师(鸟窝)的《Go 并发编程实战课》
《深入理解并行编程》书籍


帽儿山的枪手
71 声望18 粉丝