Golang 基础之并发知识 (三)

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

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

本章节内容

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

基本同步原语

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

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. 添加休眠时间 以造成死锁

实质上,我们创建了两个不能一起运转的齿轮: 我们的第一个打印总和调用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 并发编程实战课》
  • 23
    点赞
  • 19
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值