一.前言

我们反复提到了goroutine的创建时简单的。 但是仍然要小心, 习惯总是会导致我们可能写出一些bug.对于语言规范没有定义的内容不要做任何的假设。

需要通过同步语义来控制代码的执行顺序 这一点很重要。 这些包提供了一些基础的同步语义,但是在实际的并发编程当中,我们应该使用 channel 来进行同步控制。

二. Mutex

2.1 案例

上一篇文章中有这样一个例子

package main

import (
"fmt"
"sync"
) var wg sync.WaitGroup
var counter int func main() {
// 多跑几次来看结果
for i := 0; i < 100000; i++ {
run()
}
fmt.Printf("Final Counter: %d\n", counter)
} func run() {
// 开启两个 协程,操作
for i := 1; i <= 2; i++ {
wg.Add(1)
go routine(i)
}
wg.Wait()
} func routine(id int) {
for i := 0; i < 2; i++ {
value := counter
value++
counter = value
}
wg.Done()
}

测试后我们会发现,每次执行的结果都不一样, 那么如何运用Mutex进行修改呢?

package main

import (
"fmt"
"sync"
) var wg sync.WaitGroup
var counter int
var mu sync.Mutex func main() {
// 多跑几次来看结果
for i := 0; i < 100000; i++ {
run()
}
fmt.Printf("Final Counter: %d\n", counter)
} func run() {
for i := 1; i <= 2; i++ {
wg.Add(1)
go routine(i)
}
wg.Wait()
fmt.Printf("Final Counter: %d\n", counter)
} func routine(id int) {
for i := 0; i < 2; i++ {
// 加锁
mu.Lock()
counter++
// 解锁
mu.Unlock()
}
wg.Done()
}

这里主要的目的就是为了保护我们临界区的数据,通过锁来进行保证。锁的使用非常的简单,但是还是有几个需要注意的点

  • 锁的范围要尽量的小,不要搞很多大锁
  • 用锁一定要解锁,小心产生死锁

三. 实现原理

3.1 锁的实现模式

  • Barging: 这种模式是为了提高吞吐量,当锁被释放时,它会唤醒第一个等待者,然后把锁给第一个等待者或者给第一个请求锁的人

  • Handoff: 当锁释放的时候, 锁会一直持有直到第一个等待者准备好获取锁。 它降低了吞吐量,因为锁被持有, 即使另一个 goroutine 准备获取它。这种模式可以解决公平性的问题,因为在 Barging 模式下可能会存在被唤醒的 goroutine 永远也获取不到锁的情况,毕竟一直在 cpu 上跑着的 goroutine 没有上下文切换会更快一些。缺点就是性能会相对差一些

  • Spining:自旋在等待队列为空或者应用程序重度使用锁时效果不错。Parking 和 Unparking goroutines 有不低的性能成本开销,相比自旋来说要慢得多。但是自旋是有成本的,所以在 go 的实现中进入自旋的条件十分的苛刻。

3.2 Go Mutex 实现原理

3.2.1 加锁

  1. 首先如果当前锁处于初始化状态就直接用CAS方法尝试获取锁,这是 Fast Path
  2. 如果失败就进入 Slow Path
    • 会首先判断当前能不能进入自旋状态,如果可以就进入自旋,最多自旋 4 次
    • 自旋完成之后,就会去计算当前的锁的状态
    • 然后尝试通过 CAS 获取锁
    • 如果没有获取到就调用 runtime_SemacquireMutex 方法休眠当前 goroutine 并且尝试获取信号量
    • goroutine 被唤醒之后会先判断当前是否处在饥饿状态,(如果当前 goroutine 超过 1ms 都没有获取到锁就会进饥饿模式)
      1. 如果处在饥饿状态就会获得互斥锁,如果等待队列中只存在当前 Goroutine,互斥锁还会从饥饿模式中退出
      2. 如果不在,就会设置唤醒和饥饿标记、重置迭代次数并重新执行获取锁的循环

CAS 方法在这里指的是 atomic.CompareAndSwapInt32(addr, old, new) bool 方法,这个方法会先比较传入的地址的值是否是 old,如果是的话就尝试赋新值,如果不是的话就直接返回 false,返回 true 时表示赋值成功

饥饿模式是 Go 1.9 版本之后引入的优化,用于解决公平性的问题[10]

3.2.2 解锁

解锁的流程相对于加锁简单很多,这里直接上图,过程不过多赘述

四. 源码分析

4.1 Mutex基本结构

Mutex是个结构体,源码如下

type Mutex struct {
state int32
sema uint32
}

Mutex 结构体由 state sema 两个 4 字节成员组成,其中 state 表示了当前锁的状态, sema 是用于控制锁的信号量

state 字段的最低三位表示三种状态,分别是 mutexLocked mutexWoken mutexStarving ,剩下的用于统计当前在等待锁的 goroutine 数量

  • mutexLocked 表示是否处于锁定状态
  • mutexWoken 表示是否处于唤醒状态
  • mutexStarving 表示是否处于饥饿状态

4.2 加锁

互斥锁加锁逻辑如下

  • 当调用 Lock 方法的时候,会先尝试走 Fast Path,也就是如果当前互斥锁如果处于未加锁的状态,尝试加锁,只要加锁成功就直接返回

    func (m *Mutex) Lock() {
    // Fast path: grab unlocked mutex.
    if atomic.CompareAndSwapInt32(&m.state, 0, mutexLocked) {
    return
    }
    // Slow path (outlined so that the fast path can be inlined)
    m.lockSlow()
    }
  • 否则的话就进入 slow path

    func (m *Mutex) lockSlow() {
    var waitStartTime int64 // 等待时间
    starving := false // 是否处于饥饿状态
    awoke := false // 是否处于唤醒状态
    iter := 0 // 自旋迭代次数
    old := m.state
    for {
    // Don't spin in starvation mode, ownership is handed off to waiters
    // so we won't be able to acquire the mutex anyway.
    if old&(mutexLocked|mutexStarving) == mutexLocked && runtime_canSpin(iter) {
    // Active spinning makes sense.
    // Try to set mutexWoken flag to inform Unlock
    // to not wake other blocked goroutines.
    if !awoke && old&mutexWoken == 0 && old>>mutexWaiterShift != 0 &&
    atomic.CompareAndSwapInt32(&m.state, old, old|mutexWoken) {
    awoke = true
    }
    runtime_doSpin()
    iter++
    old = m.state
    continue
    }
  • lockSlow 方法中可以看到,有一个大的for 循环,不断的尝试去获取互斥锁,在循环的内部,第一步就是判断能否自旋状态。

    进入自旋状态的判断比较苛刻,具体需要满足什么条件呢? runtime_canSpin 源码见下方

    • 当前互斥锁的状态是非饥饿状态,并且已经被锁定了
    • 自旋次数不超过 4 次
    • cpu 个数大于一,必须要是多核 cpu
    • 当前正在执行当中,并且队列空闲的 p 的个数大于等于一
    // Active spinning for sync.Mutex.
    //go:linkname sync_runtime_canSpin sync.runtime_canSpin
    //go:nosplit
    func sync_runtime_canSpin(i int) bool {
    // 自旋次数不超过4
    // cpu个数大于1--所以必须是多核CPU
    // 队列空闲的p的个数大于等于1
    if i >= active_spin || ncpu <= 1 || gomaxprocs <= int32(sched.npidle+sched.nmspinning)+1 {
    return false
    }
    if p := getg().m.p.ptr(); !runqempty(p) {
    return false
    }
    return true
    }
  • 如果可以进入自旋状态之后就会调用 runtime_doSpin 方法进入自旋, doSpin 方法会调用 procyield(30) 执行三十次 PAUSE 指令

    TEXT runtime·procyield(SB),NOSPLIT,$0-0
    MOVL cycles+0(FP), AX
    again:
    PAUSE
    SUBL $1, AX
    JNZ again
    RET

    为什么使用 PAUSE 指令呢?

    PAUSE 指令会告诉 CPU 我当前处于处于自旋状态,这时候 CPU 会针对性的做一些优化,并且在执行这个指令的时候 CPU 会降低自己的功耗,减少能源消耗

    if !awoke && old&mutexWoken == 0 && old>>mutexWaiterShift != 0 &&
    atomic.CompareAndSwapInt32(&m.state, old, old|mutexWoken) {
    awoke = true
    }
  • 在自旋的过程中会尝试设置 mutexWoken 来通知解锁,从而避免唤醒其他已经休眠的 goroutine 在自旋模式下,当前的 goroutine 就能更快的获取到锁

    new := old
    // Don't try to acquire starving mutex, new arriving goroutines must queue.
    if old&mutexStarving == 0 {
    new |= mutexLocked
    }
    if old&(mutexLocked|mutexStarving) != 0 {
    new += 1 << mutexWaiterShift
    }
    // The current goroutine switches mutex to starvation mode.
    // But if the mutex is currently unlocked, don't do the switch.
    // Unlock expects that starving mutex has waiters, which will not
    // be true in this case.
    if starving && old&mutexLocked != 0 {
    new |= mutexStarving
    }
    if awoke {
    // The goroutine has been woken from sleep,
    // so we need to reset the flag in either case.
    if new&mutexWoken == 0 {
    throw("sync: inconsistent mutex state")
    }
    new &^= mutexWoken
    }
  • 自旋结束之后就会去计算当前互斥锁的状态,如果当前处在饥饿模式下则不会去请求锁,而是会将当前 goroutine 放到队列的末端

    if atomic.CompareAndSwapInt32(&m.state, old, new) {
    if old&(mutexLocked|mutexStarving) == 0 {
    break // locked the mutex with CAS
    }
    // If we were already waiting before, queue at the front of the queue.
    queueLifo := waitStartTime != 0
    if waitStartTime == 0 {
    waitStartTime = runtime_nanotime()
    }
    runtime_SemacquireMutex(&m.sema, queueLifo, 1)
    starving = starving || runtime_nanotime()-waitStartTime > starvationThresholdNs
    old = m.state
    if old&mutexStarving != 0 {
    // If this goroutine was woken and mutex is in starvation mode,
    // ownership was handed off to us but mutex is in somewhat
    // inconsistent state: mutexLocked is not set and we are still
    // accounted as waiter. Fix that.
    if old&(mutexLocked|mutexWoken) != 0 || old>>mutexWaiterShift == 0 {
    throw("sync: inconsistent mutex state")
    }
    delta := int32(mutexLocked - 1<<mutexWaiterShift)
    if !starving || old>>mutexWaiterShift == 1 {
    // Exit starvation mode.
    // Critical to do it here and consider wait time.
    // Starvation mode is so inefficient, that two goroutines
    // can go lock-step infinitely once they switch mutex
    // to starvation mode.
    delta -= mutexStarving
    }
    atomic.AddInt32(&m.state, delta)
    break
    }
    awoke = true
    iter = 0
    }

状态计算完成之后就会尝试使用 CAS 操作获取锁,如果获取成功就会直接退出循环

如果获取失败,则会调用 runtime_SemacquireMutex(&m.sema, queueLifo, 1) 方法保证锁不会同时被两个 goroutine 获取。runtime_SemacquireMutex 方法的主要作用是:

  • 不断调用尝试获取锁
  • 休眠当前goroutine
  • 等待信号量, 唤醒 goroutine

goroutine 被唤醒后就会去判断当前是否处于饥饿模式,如果当前等待超过1ms 就会进入饥饿模式

  • 饥饿模式下: 会获得互斥锁,如果等待队列中只存在当前Goroutine, 互斥锁还会从饥饿模式中退出
  • 正常模式下: 会设置唤醒和饥饿标识, 重置迭代次数并重新执行获取锁的循环

4.3 解锁

加锁比解锁简单多了,原理直接参考源码的注释

// 解锁没有绑定关系,可以一个 goroutine 锁定,另外一个 goroutine 解锁
func (m *Mutex) Unlock() {
// Fast path: 直接尝试设置 state 的值,进行解锁
new := atomic.AddInt32(&m.state, -mutexLocked)
// 如果减去了 mutexLocked 的值之后不为零就会进入慢速通道,这说明有可能失败了,或者是还有其他的 goroutine 等着
if new != 0 {
// Outlined slow path to allow inlining the fast path.
// To hide unlockSlow during tracing we skip one extra frame when tracing GoUnblock.
m.unlockSlow(new)
}
} func (m *Mutex) unlockSlow(new int32) {
// 解锁一个没有锁定的互斥量会报运行时错误
if (new+mutexLocked)&mutexLocked == 0 {
throw("sync: unlock of unlocked mutex")
}
// 判断是否处于饥饿模式
if new&mutexStarving == 0 {
// 正常模式
old := new
for {
// 如果当前没有等待者.或者 goroutine 已经被唤醒或者是处于锁定状态了,就直接返回
if old>>mutexWaiterShift == 0 || old&(mutexLocked|mutexWoken|mutexStarving) != 0 {
return
}
// 唤醒等待者并且移交锁的控制权
new = (old - 1<<mutexWaiterShift) | mutexWoken
if atomic.CompareAndSwapInt32(&m.state, old, new) {
runtime_Semrelease(&m.sema, false, 1)
return
}
old = m.state
}
} else {
// 饥饿模式,走 handoff 流程,直接将锁交给下一个等待的 goroutine,注意这个时候不会从饥饿模式中退出
runtime_Semrelease(&m.sema, true, 1)
}
}

通过源码注解,总结解锁需要注意以下

  1. 解锁一个没有锁定的互斥量会报运行时错误

五. 读写锁(RWMutex)

读写锁相对于互斥锁来说粒度更细,使用读写锁可以并发读,但是不能并发读写,或者并发写写

5.1 案例

大部分的业务应用都是读多写少的场景,这个时候使用读写锁的性能就会比互斥锁要好一些,例如下面的这个例子,是一个配置读写的例子,我们分别使用读写锁和互斥锁实现

  1. 读写锁
// RWMutexConfig 读写锁实现
type RWMutexConfig struct {
rw sync.RWMutex
data []int
} // Get get config data
func (c *RWMutexConfig) Get() []int {
c.rw.RLock()
defer c.rw.RUnlock()
return c.data
} // Set set config data
func (c *RWMutexConfig) Set(n []int) {
c.rw.Lock()
defer c.rw.Unlock()
c.data = n
}
  1. 互斥锁

// MutexConfig 互斥锁实现
type MutexConfig struct {
data []int
mu sync.Mutex
} // Get get config data
func (c *MutexConfig) Get() []int {
c.mu.Lock()
defer c.mu.Unlock()
return c.data
} // Set set config data
func (c *MutexConfig) Set(n []int) {
c.mu.Lock()
defer c.mu.Unlock()
c.data = n
}

并发基准测试,测试两种锁的性能

type iConfig interface {
Get() []int
Set([]int)
} func bench(b *testing.B, c iConfig) {
b.RunParallel(func(p *testing.PB) {
for p.Next() {
c.Set([]int{100})
c.Get()
c.Get()
c.Get()
c.Set([]int{100})
c.Get()
c.Get()
}
})
} func BenchmarkMutexConfig(b *testing.B) {
conf := &MutexConfig{data: []int{1, 2, 3}}
bench(b, conf)
} func BenchmarkRWMutexConfig(b *testing.B) {
conf := &RWMutexConfig{data: []int{1, 2, 3}}
bench(b, conf)
}

执行测试结果如下

root@failymao:/mnt/d/gopath/src/Go_base/daily_test/mutex# go test -race -bench=.
goos: linux
goarch: amd64
pkg: Go_base/daily_test/mutex
BenchmarkMutexConfig-8 179932 5820 ns/op
BenchmarkRWMutexConfig-8 279578 3939 ns/op
PASS
ok Go_base/daily_test/mutex 3.158s

可以看到首先是没有 data race 问题,其次读写锁的性能几乎是互斥锁的一倍

5.2 源码解析

5.2.1 基本结构

type RWMutex struct {
w Mutex // 复用互斥锁
writerSem uint32 // 信号量,用于写等待读
readerSem uint32 // 信号量,用于读等待写
readerCount int32 // 当前执行读的 goroutine 数量
readerWait int32 // 写操作被阻塞的准备读的 goroutine 的数量
}

由于复用了互斥锁的代码,读写锁的源码很简单

5.2.2 读锁

加锁

func (rw *RWMutex) RLock() {
// 直接对读的goroutine记录加1,并判断当前执行的读的goroutines数量是否为空
if atomic.AddInt32(&rw.readerCount, 1) < 0 {
// A writer is pending, wait for it.
runtime_SemacquireMutex(&rw.readerSem, false, 0)
}
}

首先是读锁, atomic.AddInt32(&rw.readerCount, 1) 调用这个原子方法,对当前在读的数量加一,如果返回负数,那么说明当前有其他写锁,这时候就调用 runtime_SemacquireMutex 休眠 goroutine 等待被唤醒

解锁

func (rw *RWMutex) RUnlock() {
if r := atomic.AddInt32(&rw.readerCount, -1); r < 0 {
// Outlined slow-path to allow the fast-path to be inlined
rw.rUnlockSlow(r)
}
}

解锁的时候对正在读的操作减一,如果返回值小于 0 那么说明当前有在写的操作,这个时候调用 rUnlockSlow 进入慢速通道

func (rw *RWMutex) rUnlockSlow(r int32) {
if r+1 == 0 || r+1 == -rwmutexMaxReaders {
race.Enable()
throw("sync: RUnlock of unlocked RWMutex")
}
// A writer is pending.
if atomic.AddInt32(&rw.readerWait, -1) == 0 {
// The last reader unblocks the writer.
runtime_Semrelease(&rw.writerSem, false, 1)
}
}

5.2.3 写锁

写锁

func (rw *RWMutex) Lock() {
// First, resolve competition with other writers.
rw.w.Lock()
// Announce to readers there is a pending writer.
r := atomic.AddInt32(&rw.readerCount, -rwmutexMaxReaders) + rwmutexMaxReaders
// Wait for active readers.
if r != 0 && atomic.AddInt32(&rw.readerWait, r) != 0 {
runtime_SemacquireMutex(&rw.writerSem, false, 0)
}
}

首先调用互斥锁的 lock,获取到互斥锁之后

  • atomic.AddInt32(&rw.readerCount, -rwmutexMaxReaders) 调用这个函数阻塞后续的读操作
  • 如果计算之后当前仍然有其他 goroutine 持有读锁,那么就调用 runtime_SemacquireMutex 休眠当前的 goroutine 等待所有的读操作完成

解锁

func (rw *RWMutex) Unlock() {
// Announce to readers there is no active writer.
r := atomic.AddInt32(&rw.readerCount, rwmutexMaxReaders)
if r >= rwmutexMaxReaders {
race.Enable()
throw("sync: Unlock of unlocked RWMutex")
}
// Unblock blocked readers, if any.
for i := 0; i < int(r); i++ {
runtime_Semrelease(&rw.readerSem, false, 0)
}
}

解锁的操作,会先调用 atomic.AddInt32(&rw.readerCount, rwmutexMaxReaders) 将恢复之前写入的负数,然后根据当前有多少个读操作在等待,循环唤醒

六.参考

  1. https://mojotv.cn/go/golang-muteex-starvation
  2. https://lailin.xyz/post/go-training-week3-sync.html
  3. https://draveness.me/golang/docs/part3-runtime/ch06-concurrency/golang-sync-primitives/

Go并发编程--Mutex/RWMutex的更多相关文章

  1. go并发编程 WaitGroup, Mutex

    1.背景 记录一下,方便后续写代码直接使用. 需要注意几点: chan 默认支持多协程工作,不需要加锁. 其他变量操作需要使用锁保护(map多协程并发写会panic, 并且无法捕获). 启动gorou ...

  2. Go并发编程(四)

        并发基础   多进程  多线程 基于回调的非阻塞/异步IO     协程  协程  与传统的系统级线程和进程相比,协程的最大优势在于其“轻量级”,可以轻松创建上百万个而不会导致系统资源衰竭, ...

  3. Go语言 7 并发编程

    文章由作者马志国在博客园的原创,若转载请于明显处标记出处:http://www.cnblogs.com/mazg/ Go学习群:415660935 今天我们学习Go语言编程的第七章,并发编程.语言级别 ...

  4. 【Go】并发编程

    Go语言宣扬用通讯的方式共享数据. Go语言以独特的并发编程模型傲视群雄,与并发编程关系最紧密的代码包就是sync包,意思是同步.同步的用途有两个,一个是避免多个线程在同一时刻操作同一个数据块,另一个 ...

  5. Go并发编程

    概述 简而言之,所谓并发编程是指在一台处理器上"同时"处理多个任务. 随着硬件的发展,并发程序变得越来越重要.Web服务器会一次处理成千上万的请求.平板电脑和手机app在渲染用户画 ...

  6. Go语言中的并发编程

    并发是编程里面一个非常重要的概念,Go语言在语言层面天生支持并发,这也是Go语言流行的一个很重要的原因. Go语言中的并发编程 并发与并行 并发:同一时间段内执行多个任务(你在用微信和两个女朋友聊天) ...

  7. go语言并发编程

    引言 说到go语言最厉害的是什么就不得不提到并发,并发是什么?,与并发相关的并行又是什么? 并发:同一时间段内执行多个任务 并行:同一时刻执行多个任务 进程.线程与协程 进程: 进程是具有一定独立功能 ...

  8. Go语言系列之并发编程

    Go语言中的并发编程 并发与并行 并发:同一时间段内执行多个任务(宏观上并行,微观上并发). 并行:同一时刻执行多个任务(宏观和微观都是并行). Go语言的并发通过goroutine实现.gorout ...

  9. [ 高并发]Java高并发编程系列第二篇--线程同步

    高并发,听起来高大上的一个词汇,在身处于互联网潮的社会大趋势下,高并发赋予了更多的传奇色彩.首先,我们可以看到很多招聘中,会提到有高并发项目者优先.高并发,意味着,你的前雇主,有很大的业务层面的需求, ...

随机推荐

  1. 自制计算器 v1.1

    之前的v1.0版本功能还不够完善,这个版本一并做了修改. 代码,每个sub表示哪个按钮做了注释. Dim a, temp, ans As Integer Dim op As String Sub sh ...

  2. TP5增加扩展配置目录

    ThinkPHP5.0.1版本开始增加了扩展配置目录的概念,在应用配置目录或者模块配置目录下面增加extra子目录,下面的配置文件都会自动加载,无需任何配置. 这极大的方便了我们进行扩展配置,比如在a ...

  3. Hadoop的高可用搭建

    在已经安装完hadoop单机和zookeeper前提下 1.免密钥 ssh-keygen -t rsa 分发秘钥 ssh-copy-id -i master ssh-copy-id -i node1 ...

  4. Django边学边记—模型查询

    查询集 两大特性 惰性执行:创建查询集不会访问数据库,直到调用数据时,才会访问数据库,调用数据的情况包括迭代.序列化.与if合用 缓存:查询集的结果被存下来之后,再次查询时会使用之前缓存的数据 返回列 ...

  5. Mysql Navicate 基础操作与SQL语句 版本5.7.29

    SQL数据的增删改查:此部分所有SQL语句在navicat中与mysql命令行执行效果一样,只是mysql服务端在命令行执行,而navicat只是在客户端的图形化打开操作. 一.进入数据库 .连接数据 ...

  6. javascript 求最大前5个数; 对象 深拷贝 deep copy

    * 用数组 function getTopN(a, n) { function _cloneArray(aa) { var n = aa.length, a = new Array(n); for ( ...

  7. python 正则表达式findall

    re.findall("匹配规则", "要匹配的字符串") 以列表形式返回匹配到的字符串 https://www.cnblogs.com/gufengchen/ ...

  8. P4980-[模板]Pólya定理

    正题 题目链接:https://www.luogu.com.cn/problem/P4980 题目大意 \(n\)个物品图上\(m\)种颜色,求在可以旋转的情况下本质不同的涂色方案. 解题思路 既然是 ...

  9. python3之cx_Freeze使用(PyQt5)

    1.   cx_Freeze简介 Python脚本在装有Python的系统中可以直接双击运行,但绝大多数普通用户并没有配置此类环境,而编译为可执行二进制文件后,用户无需预先安装Python及依赖库即可 ...

  10. 电商管理后台 API 接口文档

    1. 电商管理后台 API 接口文档 1.1. API V1 接口说明 接口基准地址:http://127.0.0.1:8888/api/private/v1/ 服务端已开启 CORS 跨域支持 AP ...