2021/04/08谈谈Go中的sync.Mutex

1.基本用法

基本用法

package main

import (
    "fmt"
    "sync"
)

func main() {
    var mylock sync.Mutex
    var wg sync.WaitGroup
    count :=0
    for i:=0 ;i <10 ;i++ {
        wg.Add(1)
        go func (){
            defer  wg.Done()
            for i:=0;i<10000;i++ {
                mylock.Lock()
                count++
                mylock.Unlock()
            }
        }()
    }
    wg.Wait()
    fmt.Println(count)

}

通过使用锁的机制来达到对公共资源读写的原子操作控制

锁的粒度如何?

demo

package main

import (
    "fmt"
    "sync"
    "time"
)

func main() {
    var mylock sync.Mutex
    var wg sync.WaitGroup
    count :=0
    another_count := 0
    wg.Add(2)
    go func(){
        defer wg.Done()
        defer mylock.Unlock() //在释放锁前进行打印
        mylock.Lock()
        count++
        fmt.Println(count)
        time.Sleep(time.Second*2)
    }()
    go func(){
        defer wg.Done()
        defer mylock.Unlock() 
        mylock.Lock()
        another_count++
        fmt.Println( another_count )
        time.Sleep(time.Second*2)
    }()
    wg.Wait()

}

第一个协程对公共资源count进行加锁修改,第二个协程对公共资源another_count进行加锁修改,发现两协程先后返回,一共耗时4s,是否说明了锁的粒度类似mysql的表级锁,锁全部的公共资源呢

这里其实是错误的,因为在释放锁之前,我们使用了fmt的输出,涉及到了公共资源标准输出的占用
这也提醒了大量协程的打印输出存在数据的竞争

修改demo

package main

import (
    "fmt"
    "sync"
    "time"
)

func main() {
    var mylock sync.Mutex
    var wg sync.WaitGroup
    count :=0
    another_count := 0
    wg.Add(2)
    go func(){
        defer wg.Done()
        mylock.Lock()
        count++
        mylock.Unlock()
        time.Sleep(time.Second*2)
        fmt.Println(count) //在释放锁之后进行打印
    }()
    go func(){
        defer wg.Done()
        mylock.Lock()
        another_count++
        mylock.Unlock()
        time.Sleep(time.Second*2)
        fmt.Println( another_count )
    }()
    wg.Wait()
}

两协程同时返回,该实验也证明了Go中的锁更类似mysql的行锁,粒度是公共资源的单个变量

补充一点:这里不能使用defer进行fmt的打印,因为函数中有defer时,会首先将defer中需要的变量进行拷贝,等函数执行完毕再执行defer,因此使用defer进行打印结果只会输出原值0

Mutex也可以作为嵌入字段嵌入结构体

以下demo是保护结构体成员原子修改的demo

type MyData struct {
    sync.Mutex
    count int
}

func (d *MyData) Add() {
    d.Lock()
    d.count++
    d.Unlock()
}

func (d *MyData) Read() int {
    defer d.Unlock() //defer 在return后 或 panic前执行
    d.Lock()
    return d.count
}

func main() {
    var wg sync.WaitGroup
    mydata := MyData{count:0}
    for i:=0;i<10;i++ {
        wg.Add(1)
        go func(){
            defer wg.Done()
            for i:=0;i<10000;i++{
                mydata.Add()
            }
        }()
    }
    wg.Wait()
    fmt.Println( mydata.Read() )
}

当结构体有多字段,一般把Mutex放在要控制的字段上面(仅仅是美观,方便阅读,并无特殊作用)

多个协程同时等待释放锁,哪些会先获取到执行机会

等待的goroutine们是以FIFO排队的

  1. 当Mutex处于正常模式时,若此时没有新goroutine与队头goroutine竞争,则队头goroutine获得。若有新goroutine竞争大概率新goroutine获得。
  2. 当队头goroutine竞争锁失败1ms后,它会将Mutex调整为饥饿模式。进入饥饿模式后,锁的所有权会直接从解锁goroutine移交给队头goroutine,此时新来的goroutine直接放入队尾。
  3. 当一个goroutine获取锁后,如果发现自己满足下列条件中的任何一个#1它是队列中最后一个#2它等待锁的时间少于1ms,则将锁切换回正常模式

以上简略翻译自https://golang.org/src/sync/mutex.go 中注释Mutex fairness.

为什么Mutex只需要声明不需要初始化

我们尝试打印一下

func main() {
    var mylock sync.Mutex
    fmt.Printf("%v\n",mylock)

}
//结果为  {0,0}

是一个0值的结构体,我们再看一下源码中的结构

type Mutex struct {
        state int32
        sema  uint32
}

Mutex 的零值是还没有 goroutine 等待的未加锁的状态,所以你不需要额外的初始化,直接声明变量(如 var mu sync.Mutex)即可。
同时这也引出我们的下一个话题Mutex的演进过程

2.Mutex的演进过程

大致演进过程可总结为如下图
摘自极客时间go并发编程实战

2.1初版使用flag标记是否持有锁


   // CAS操作,当时还没有抽象出atomic包
    func cas(val *int32, old, new int32) bool
    func semacquire(*int32)
    func semrelease(*int32)
    // 互斥锁的结构,包含两个字段
    type Mutex struct {
        key  int32 // 锁是否被持有的标识
        sema int32 // 信号量专用,用以阻塞/唤醒goroutine
    }
    
    // 保证成功在val上增加delta的值
    func xadd(val *int32, delta int32) (new int32) {
        for {
            v := *val
            if cas(val, v, v+delta) {
                return v + delta
            }
        }
        panic("unreached")
    }
    
    // 请求锁
    func (m *Mutex) Lock() {
        if xadd(&m.key, 1) == 1 { //标识加1,如果等于1,成功获取到锁
            return
        }
        semacquire(&m.sema) // 否则阻塞等待
    }
    
    func (m *Mutex) Unlock() {
        if xadd(&m.key, -1) == 0 { // 将标识减去1,如果等于0,则没有其它等待者
            return
        }
        semrelease(&m.sema) // 唤醒其它阻塞的goroutine
    }    

其中cas是atomic包的前身,保证了内存地址的原子操作更新值
Mutex结构体

    type Mutex struct {
        key  int32 // 锁是否被持有的标识
        sema int32 // 信号量专用,用以阻塞/唤醒goroutine
    }
  • key为flag,>=1 则表示锁已经被某个协程占有
  • sema是信号量,用以控制等待协程阻塞休眠和唤醒
    image.png
  • Unlock函数:当释放锁时,将key-1,若不为0,则用信号唤醒其他等待协程

小结

初版的Mutex利用key判断是否被加锁,并记录多少协程需要(持有和等待获取)这个锁。但是从结构体中不难看出,并未记录持有这个锁的协程的信息,Unlock也没有检查是否是当前持有锁的协程释放锁(Mutex的这个设计一直保存至今)。
那么不就代表其他协程也可以释放锁了??

func main(){
    var wg sync.WaitGroup
    var lock sync.Mutex
    var count int
    wg.Add(2)
    go func(){
        defer wg.Done()
        lock.Lock()
        count++
        fmt.Println(count)
    }()
    go func(){
        defer wg.Done()
        time.Sleep(time.Second)
        lock.Unlock()
        count++
        fmt.Println(count)
    }()
    wg.Wait()
    lock.Unlock() //以下会抛出异常unlock of unlocked mutex
    lock.Unlock()
}

这是一件很危险的事情,因此我们使用锁需要遵循谁申请,谁释放的原则,在同一个方法中获取和释放锁

2.2新的协程也有竞争的机会

相比于初版,结构体发生了改变

   type Mutex struct {
        state int32
        sema  uint32
    }
    const (
        mutexLocked = 1 << iota // mutex is locked
        mutexWoken
        mutexWaiterShift = iota
    )

第一个字段不再仅代表是否持有锁,而是一个字段多个意义

state单字段分为三个字段,按位解析,最小位表示这个锁是否被持有,第二位表示唤醒的协程,第三位表示等待的协程数
image.png

剩余代码还没吃透(对二进制运算不够深刻),具体看鸟叔博客https://colobu.com/2018/12/18/dive-into-sync-mutex/

3.Mutex的错误使用场景

  • 第一种,也就是上文提到的申请和释放不在同一个方法中
  • 第二种Copy已使用的Mutex

    sync的同步原语在使用后是不能复制的!Mutex的state字段记录了这个锁的状态,复制一个已经加锁的Mutex那么新的刚初始化的变量就是已经加锁的,这不合我们预期(在并发环境下,我们根本不知道Mutex的状态)

demo

type Counter struct {
    sync.Mutex
    Count int
}


func main() {
    var c Counter
    c.Lock()
    defer c.Unlock()
    c.Count++
    foo(c) // 复制锁
}

// 这里Counter的参数是通过复制的方式传入的
// Go函数的参数都是值赋值传递
func foo(c Counter) {
    c.Lock()
    defer c.Unlock()
    fmt.Println("in foo")
}

结果

fatal error: all goroutines are asleep - deadlock!

分析

  1. main函数加锁
  2. main调用foo,函数拷贝了其副本传递到函数体
  3. foo不知道已经上锁了,尝试用lock来获取锁(但是没有其他协程来释放这个赋值的锁),结果主协程被完全阻塞

利用vet工具检测

go vet demo.go 
# command-line-arguments
./demo.go:20:9: call of foo copies lock value: command-line-arguments.Counter
./demo.go:25:12: foo passes lock by value: command-line-arguments.Counter

提示我们foo函数发生了 锁的复制

  • 第三种,重入,即申请锁的协程又再次申请锁

重入,当一个线程获取锁时,如果没有其它线程拥有这个锁,那么,这个线程就成功获取到这个锁。之后,如果其它线程再请求这个锁,就会处于阻塞等待的状态。但是,如果拥有这把锁的线程再请求这把锁的话,不会阻塞,而是成功返回,所以叫可重入锁(有时候也叫做递归锁)。只要你拥有这把锁,你可以可着劲儿地调用,比如通过递归实现一些算法,调用者不会阻塞或者死锁。

不同于JAVA,Go中的Mutex是不可重入锁,因为它并没有记录持有锁的协程信息,只是修改state的状态


func foo(l sync.Locker) {
    fmt.Println("in foo")
    l.Lock()
    bar(l)
    l.Unlock()
}


func bar(l sync.Locker) {
    l.Lock()
    fmt.Println("in bar")
    l.Unlock()
}


func main() {
    l := &sync.Mutex{}
    foo(l) //注意这里传递的是指针
}
  • 第四种,死锁即互相依赖对方释放锁
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 216,496评论 6 501
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 92,407评论 3 392
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 162,632评论 0 353
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,180评论 1 292
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,198评论 6 388
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,165评论 1 299
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,052评论 3 418
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,910评论 0 274
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,324评论 1 310
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,542评论 2 332
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,711评论 1 348
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,424评论 5 343
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,017评论 3 326
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,668评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,823评论 1 269
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,722评论 2 368
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,611评论 2 353

推荐阅读更多精彩内容