Go sync.Mutex

Go语言提供了sync包和channel机制来解决并发机制中不同goroutine之间的同步和通信

Locker

Go语言使用go语句开启新的goroutine,由于goroutine非常轻量除了对其分配栈空间外,所占的空间也是微乎其微的。但当多个goroutine同时处理时会遇到比如同时抢占一个资源,某个goroutine会等待等一个goroutine处理完毕某才能继续执行的问题。对于这种情况,官方并不希望依靠共享内存的方式来实现进程的协同操作,而是希望通过channel信道的方式来处理。但在某些特殊情况下,依然需要使用到锁,为此sync包提供了锁。

当在并发情况下,多个goroutine同时修改某一个变量时,就会出现资源抢占,因此会导致数据不一致的问题。

package main

import (
    "fmt"
    "time"
)

func main() {
    var num = 0
    for i := 0; i < 100000; i++ {
        go func(i int) {
            num++
            fmt.Printf("goroutine %d: num = %d\n", i, num)
        }(i)
    }
    time.Sleep(time.Second)//主goroutine等待1秒以确保所有工作goroutine执行完毕
}
...
goroutine 10018: num = 98635
goroutine 12646: num = 98635
goroutine 12844: num = 98635
goroutine 12950: num = 98635
...

上例中goroutine一依次从寄存器中读取num的值后做加法运算,然后将其结果回写到寄存器中。运行中会发现存在一个goroutine取出num的值时做加法运算时,另一个goroutine也取出了num的值。因为上一个goroutine运行结果还没有回写到寄存器,最终导致多个goroutine产生的相同的结果。

并发编程中同步原语-锁,为了保证多个线程或goroutine在访问同一块内存时不出现混乱,Go语言的sync包提供了常见的并发编程同步原语的控制锁。

sync包围绕着Locker锁接口展开,Locker接口中提供了两个方法Lock()Unlock()

type Locker interface {
  Lock()
  Unlock()
}

Go语言标准库sync中提供了两种锁分别是互斥锁sync.Mutex和读写互斥锁sync.RWMutex

互斥锁sync.Mutex

sync.Mutex是一个互斥锁,可以由不同的goroutine加锁和解锁。

sync.Mutex是Golang标准库提供的一个互斥锁,当一个goroutine获得互斥锁权限后,其他请求锁的goroutine会阻塞在Lock()方法的调用上,直到调用Unlock()方法被释放。

例如:10个并发的goroutine打印同一个数字100,为避免重复打印,实现printOnce(num int)函数,使用集合set记录已打印过的数字。若数字已经打印过,则不再打印。

$ vim mutex_test.go
package test

import (
    "fmt"
    "testing"
    "time"
)

var set = make(map[int]bool, 0)

func printOnce(index int, num int) {
    if _, ok := set[num]; !ok {
        fmt.Println(index, num)
    }
    set[num] = true
}

func TestPrint(t *testing.T) {
    for i := 0; i < 10; i++ {
        go printOnce(i, 100)
    }
    time.Sleep(time.Second)
}
$ go test -v mutex_test.go
=== RUN   TestPrint
9 100
3 100
--- PASS: TestPrint (1.00s)
PASS
ok      command-line-arguments  1.304s

程序多次运行后会发现打印次数多次,因为对同一个数据结构set的访问发生了冲突。

并发访问中比如多个goroutine并发更新同一个资源,比如计时器、账户余额、秒杀系统、向同一个缓存中并发写入数据等等。如果没有互斥控制,很容易会出现异常,比如计时器计数不准确、用户账户可能出现透支、秒杀系统出现超卖、缓存出现数据缓存等等,后果会很严重。

互斥锁是并发控制的一种基本手段,是为了避免竞争而建立的一种并发控制机制。学习前首先需要弄清楚一个概念-临界区。在并发编程中,如果程序中的一部分会被并发访问或修改,为了避免并发访问导致的意想不到的结果,这部分程序需要被保护起来,这部分被保护起来的程序就叫做临界区。

临界区是一个被共享的资源,或者说是一个整体的共享资源,比如对数据库的访问,对某个共享数据结构的操作。对一个I/O设备的使用,对一个连接池中的连接的调用等等。

如果很多线程同步访问临界区就会造成访问或操作错误,这并不是我们希望看到的结果。所以,使用互斥锁,限定临界区只能同时由一个线程持有。当临界区由一个线程持有的时候,其他线程如果想进入临界区就会返回失败,或者是等待。直到持有的线程退出临界区,这些等待线程中的某一个才有机会接着持有这个临界区。

互斥锁

互斥锁可以很好的解决资源竞争的问题,因此也有人称之为排它锁。Golang标准库中使用Mutex来实现互斥锁。根据2019年分析Go并发Bug的论文Understanding Real-World Concurrency Bugs in Go中,Mutex是使用最为广泛的同步原语(Synchronization primitives, 并发原语或同步原语)。关于同步原语并没有一个严格的定义,可将其看作是解决并发问题的一个基础的数据结构。

type Mutex struct {
  state int32 //状态标识
  sema uint32 //信号量
}

Go标准库提供了sync.Mutex互斥锁类型以及两个方法分别是Lock加锁和Unlock释放锁。可以通过在代码前调用Lock方法,在代码后调用Unlock方法来保证一段代码的互斥执行,也可以使用defer语句来保证互斥锁一定会被解锁。当一个goroutine调用Lock方法获得锁后,其它请求的goroutine都会阻塞在Lock方法直到锁被释放。

一个互斥锁只能同时被一个goroutine锁定,其它goroutine将阻塞直到互斥锁被解锁,也就是重新争抢对互斥锁的锁定。需要注意的是,对一个未锁定的互斥锁解锁时将会产生运行时错误。

sync.Mutex不区分读写锁,只有Lock()Lock()之间才会导致阻塞的情况。若在一个地方Lock(),在另一个地方不Lock()而是直接修改或访问共享数据,对于sync.Mutext类型是允许的,因为mutex不会和goroutine进行关联。若要区分读锁和写锁,可使用sync.RWMutex类型。

Lock()Unlock()之间的代码段成为资源临界区(critical section),在这一区间内的代码是严格被Lock()保护的,是线程安全的,任何一个时间点都只能有一个goroutine执行这段区间的代码。

例如:使用互斥锁的Lock()Unlock()方法将冲突包裹

package test

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

var m sync.Mutex
var set = make(map[int]bool, 0)

func printOnce(index int, num int) {
    m.Lock()
    defer m.Unlock()

    if _, ok := set[num]; !ok {
        fmt.Println(index, num)
    }
    set[num] = true
}

func TestPrint(t *testing.T) {
    for i := 0; i < 10; i++ {
        go printOnce(i, 100)
    }
    time.Sleep(time.Second)
}

相同的数字只会比打印一次,当一个goroutine调用了Lock()方法时,其他goroutine被阻塞了,直到Unlock()调用将锁释放。因此被包裹部分的代码就能避免冲突,实现互斥。

互斥即不能同时运行,使用互斥锁的两个代码片段相互排斥,只有其中一个代码片段执行完毕后,另一个才能执行。

package main

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

func main() {
    var num = 0
    var locker sync.Mutex

    for i := 0; i < 100000; i++ {
        go func(i int) {
            locker.Lock()
            defer locker.Unlock()

            num++
            fmt.Printf("goroutine %d: num = %d\n", i, num)
        }(i)
    }

    time.Sleep(time.Second)
}

从上例中可以发现,添加互斥锁后,不仅没有出现抢占资源导致的重复输出,而且输出结果顺序递增。

Go语言中的Mutex类型的互斥锁Lock()锁定与其它语言不同的是,Lock()锁定的是互斥锁而非一段代码。其他语言比如Java中使用同步锁锁定的是一段代码,以确保多线程并发誓只有一个线程可以控制运行此代码块直到释放同步锁。Go语言是在goroutine中锁定互斥锁,其它goroutine执行到有锁的位置时,由于获取不到互斥锁的锁定,因此会发生阻塞而等待,从而达到控制同步的目的。

race detector

Golang提供了一个检测并发访问共享资源是否存在问题的工具 - race detector,它可以帮助自动发现城中有没有data race的问题。

Golang的race detector是基于Google的C/C++ sanitizers技术实现的,编译器通过探测所有的内存访问,加入代码能监视对这些内存地址的访问(读或是写)。代码运行时race detector能监控对共享变量的非同步访问,出现race时就会打印出警告信息。

https://blog.golang.org/race-detector

读写锁sync.RWMutex

在银行存取钱时,对账户余额的修改是需要加锁的,因此此时可能有人汇款到你的账户,如果对金额的修改不加锁,很可能导致最后的金额发生错误。读取账户余额也需要等待修改操作结束,才能读取到正确的余额。大部分情况下,读取余额的操作会更加频繁,如果能保证读取余额的操作能并发执行,程序的效率会很大地提升。

保证读操作的安全,只需要保证并发读时没有写操作在进行就行。在这种场景下就需要一种特殊类型的锁,即允许多个只读操作并行执行,但写操作会完全互斥。这种锁称之为多读单写锁(multiple readers, single writer lock),简称读写锁。读写锁分为读锁和写锁,读锁允许同时执行,但写锁是互斥的。

sync.RWMutex读写锁是基于sync.Mutex实现的,读写锁的特点是针对读写操作的互斥锁,读写锁与互斥锁最大不同之处在于分别对读、写进行了锁定。一般用在大量读操作少量写操作中。

  • 同时只能具有一个goroutine能够获得写锁定
  • 同时可以具有任意多个goroutine获得读锁定
  • 同时只能存在写锁定或读锁定,即读和写互斥。

换句话说

  • 当只有一个goroutine获得写锁定时,其它无论是读锁定还是写锁定都将会阻塞直到写解锁。
  • 当只有一个goroutine获得读锁定时,其它读锁定仍然可以继续执行。
  • 当有一个或多个读锁定时,写锁定将等待所有读锁定解锁之后才能进行写锁定。

这里所谓的读锁定(RLock)目的是为了告诉写锁定(Lock),此时有很多人正在读取数据,写锁定需要排队等待。

一般来说,读写锁会分为几种情况:

  • 读锁之间不互斥,在没有写锁的情况下,读锁是无堵塞的,多个goroutine可以同时获得读锁。
  • 写锁之间是互斥的,当存在写锁时,其它写锁会阻塞。
  • 写锁与读锁互斥,若存在读锁则写锁阻塞,若存在写锁则读锁阻塞。

Go标准库sync.RWMutex读写互斥锁提供了四个方法

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

推荐阅读更多精彩内容