golang学习随便记13-并发中的竞争、互斥、锁

基于共享变量的并发

用 goroutine 和 channel 来实现并发,的确比较自然,但是,用 channel 传递数据,只是实现了数据在发送方和接收方的“共享”,它基本上类似于事件通信机制,共享的数据是事件参数。但在并发中,我们还常常不得不面对更复杂的情况,使得我们仍然需要使用基于共享变量的并发控制技术。

竞争条件

书中用来描述数据竞争的是只有一个账户的存钱模型。可用操作如下

// Package bank implements a bank with only one account.
package bank
var balance int
func Deposit(amount int) { balance = balance + amount }
func Balance() int { return balance }

并发的两笔交易:Alice存入200美刀(步骤A1),然后看余额(步骤A2);Bob存入100美刀(步骤B)

// Alice:
go func() {
    bank.Deposit(200)                // A1
    fmt.Println("=", bank.Balance()) // A2
}()

// Bob:
go bank.Deposit(100)                 // B

直观理解,A1、A2、B的顺序只有3种可能:A1-A2-B,B-A1-A2,A1-B-A2,这3种可能中,虽然Alice查看到的余额可能是200或者300,但最后的余额都是300,交易结果正确(和实际资产符合)。

但并发的复杂在于,语句 balance = balance + amount 本身是不能保证它工作的原子性的!这个语句包含了两个操作,计算 balance + amount将前述结果赋值给 balance,因为前一个操作是不修改任何内存单元的,所以,认为它是一个“读取操作”,后一个操作对应称为“写入操作”,这样,我们可以认为 A1 = A1r + A1w,B = Br + Bw,如果实际执行步骤为 A1r - Br - Bw - A1w - A2

就会出现 Bob 的存钱操作“无效”,他修改了余额之后,余额马上被更早开始计算的 Alice 的待改余额覆盖掉了,最后,银行账户实际资产比显示的余额要多100美刀。

并发编程的难度在于,上面描述的这种问题,实际测试时很难出现,因为发生竞争的数据类型很小(只有一个机器字长),当数据类型变大时,事情就不一样了。即使竞争条件的产生概率不高,我们也要避免数据竞争,因为你不清楚它什么时候产生,而且一旦出现,程序会极难排错和调试,因为很难重现。

数据竞争定义:数据竞争会在两个以上的goroutine并发访问相同的变量且至少其中一个为写操作时发生。

避免数据竞争的方式:

1、除了初始化,不要去写变量。因为初始化通常不会是并发的,后面对变量的使用,只是读取,并发读取并不会有问题。缺点是不是所有情形都能如此(后续不需要update)

2、避免从多个goroutine访问变量。这样,变量的读写控制都在一个goroutine内。其他goroutine如何共享使用变量呢?请求服务!其他goroutine不能直接访问变量,只能用一个 channel来发送请求给有控制权的goroutine来查询更新变量——这符合golang哲学“不要使用共享数据来通信;使用通信来共享数据”。这个有控制权的goroutine,通常叫该变量的 monitor goroutine

var deposits = make(chan int) // send amount to deposit
var balances = make(chan int) // receive balance

func Deposit(amount int) { deposits <- amount }
func Balance() int       { return <-balances }

func teller() {
    var balance int // balance is confined to teller goroutine
    for {
        select {
        case amount := <-deposits:
            balance += amount
        case balances <- balance:
        }
    }
}

func init() {
    go teller() // start the monitor goroutine
}

使用 monitor goroutine 后,存钱操作改成向 deposits 这个 channel 发送存款金额,查询余额则是从 balances 这个 channel 接收数据。teller 是 monitor goroutine,变量 balance 限定在 teller 函数范围内,teller 中 for 循环死循环运行一个 select multiplex,第二个 case 向 balances channel 发送当时余额后会阻塞,除非 调用了 Balance() 查询余额接收掉了值,第一个case在 deposits channel 没有数据时阻塞,除非 调用了 Deposit(amount) 存入了钱(向 deposits channel 发送了金额), 该 case 代码会接收这个值并加到 balance上——select multiplex 的 case 分支同一个时间点只会有一个 case 在运行。

有时候,可能无法做到一个变量在全部生命周期内绑定到一个独立的goroutine,但如果可以做到每个阶段都能避免在将变量传送(通常用channel传递地址信息)到下一阶段后再去访问它,那么对这个变量的所有访问就是线性的,变量就可以绑定到流水线的一个阶段后,绑定到下一个阶段,这种用法称为串行绑定

type Cake struct{ state string }

func baker(cooked chan<- *Cake) {
    for {
        cake := new(Cake)
        cake.state = "cooked"
        cooked <- cake // baker never touches this cake again
    }
}

func icer(iced chan<- *Cake, cooked <-chan *Cake) {
    for cake := range cooked {
        cake.state = "iced"
        iced <- cake // icer never touches this cake again
    }
}

上面的代码中,baker 函数的参数是 一个发送类型的 channel,发送的值类型是 Cake类型值的地址,icer 函数的第二个参数则是一个接收类型的 channel,接收的值类型也是 Cake类型值的地址。baker 函数中一旦发送了 那个 cake (地址),就不会再有任何对该 cake 的访问,它的控制权会移交给接收方 icer函数,icer函数内部也类似。

3、允许多个goroutine访问变量,但是用一定机制确保同一个时刻最多只有一个goroutine在访问,即“互斥”。

sync.Mutex 互斥锁

我们可以用一个内部队列长度为1的缓冲 channel 作为计数信号量,从而确保最多只有一个goroutine在同一个时刻访问共享的变量——一个只能为1和0的信号量称为二元信号量(binary semaphore)

var (
    sema    = make(chan struct{}, 1) // a binary semaphore guarding balance
    balance int
)

func Deposit(amount int) {
    sema <- struct{}{} // acquire token 竖旗,Deposit在操作
    balance = balance + amount
    <-sema // release token  撤旗
}

func Balance() int {
    sema <- struct{}{} // acquire token  竖旗,Balance在操作
    b := balance
    <-sema // release token  撤旗
    return b
}

上面的代码中, sema <- struct{}{} 向 sema channel 发送一个信号后,另一个 sema <- struct{}{} 就会阻塞,因为 队列长只有1,必须等到 <-sema 接收掉这个信号,另一个 sema <- struct{}{} 才能继续。这种互斥很实用,sync 包里的 Mutex类型提供了语义更清晰的支持。

var (
    mu      sync.Mutex // guards balance
    balance int
)

func Deposit(amount int) {
    mu.Lock()
    balance = balance + amount
    mu.Unlock()
}

func Balance() int {
    mu.Lock()
    b := balance
    mu.Unlock()
    return b
}

换成 sync.Mutex 类型变量 mu Lock Unlock 操作,看上去清爽多了。加互斥锁后,操作就进入“独占访问”,另一个加锁操作会被阻塞,直到前一个解锁,确保了 Lock 和 Unlock 之间操作的原子性(Lock和Unlock之间的代码段,称为 critical section,有翻译成临界区,关键段之类的)。

使用互斥体的并发访问,其实也是用一个代理确保变量被顺序访问:对变量 balance 的访问,不是直接访问,而是通过一系列函数进行的,这些函数在一开始获取互斥锁,最后再释放,这些函数、互斥锁和变量的编排叫做 monitor

在编程规范上,被互斥保护的变量在互斥体声明之后立刻声明,并加以注释说明。而且,应该只对确实需要加锁的部分加锁,并且一定要记得解锁,即使是在错误处理的路径上。当函数比较复杂时,记得解锁并不是那么容易,golang 的 defer 大法能解救我们。

func Balance() int {
    mu.Lock()
    defer mu.Unlock()
    return balance
}

看,中间变量 b 没有了!defer魔法可以确保return balance 在读取 balance值之后,函数返回之前 mu.Unlock() 得到调用。即使 Balance 函数内发生了 panic,deferred Unlock 也会得到执行。使用 deferred Unlock 的代价是有可能锁定了稍长时间,好处是代码整洁——不要写太长的函数,把功能切割成小一点的函数可以缓解锁定成本。对于并发程序,代码的整洁性比过度优化更重要

golang的互斥锁是不可重入的!看下面的取款函数

func Withdraw(amount int) bool {
    Deposit(-amount)
    if Balance() < 0 {
        Deposit(amount)
        return false // insufficient funds
    }
    return true
}

这个取款(或付款)函数不是原子操作(尽管它内部的 Deposit 、Balance 都是原子操作),当过多的取款操作同时执行时,会发生不符合直觉逻辑的情况:Alice为咖啡付款时,Deposit(-amount)执行完,她本来是可以期待 Balance() 返回值大于等于0的,但在 if 之前Deposit(-amount)之后,Bob尝试了买一辆跑车,他的Deposit(-amount)瞬间把余额减成负数,造成Alice连咖啡钱都付不了(需要第二次尝试才能成功)。我们希望整个取款操作是原子的,但下面这样做是错误的

// NOTE: incorrect!
func Withdraw(amount int) bool {
    mu.Lock()
    defer mu.Unlock()
    Deposit(-amount)
    if Balance() < 0 {
        Deposit(amount)
        return false // insufficient funds
    }
    return true
}

因为golang互斥锁不可重入,mu.Lock() 后,Deposit(-amount) 内部会尝试再次加锁,发现已经加锁就会阻塞,造成死锁(永远阻塞)。

一个通用的解决方案是将一个函数分离为多个函数,比如,把Deposit拆分成两个:一个不导出的 deposit,它不考虑锁的事,做具体事情,并且包内私有;另一个导出的Deposit,它会调用deposit,同时应用锁。这样,Withdraw 函数就好写了。

func Withdraw(amount int) bool {
    mu.Lock()
    defer mu.Unlock()
    deposit(-amount)
    if balance < 0 {
        deposit(amount)
        return false // insufficient funds
    }
    return true
}

func Deposit(amount int) {
    mu.Lock()
    defer mu.Unlock()
    deposit(amount)
}

func Balance() int {
    mu.Lock()
    defer mu.Unlock()
    return balance
}

// This function requires that the lock be held.
func deposit(amount int) { balance += amount }

mutex 和 它保护的变量,都不会导出,不带锁做具体事情的 deposit 函数也不导出,它们都“躲”(封装)在包内部(包内变量或一个struct字段),限制了外部程序对这些变量的意外交互。而互斥锁机制,在包内导出的函数内部工作,让包的使用者可以了解包的并发安全性,同时不需要自己去操心并发安全性,这是包设计者的职责。

sync.RWMutex读写锁

对所有的查询余额也加一下锁,一旦Bob查询太过于密集,会对Alice实际的存款和取款操作也产生延时。实际的业务中,往往查询远多余更新等写入操作,所以,我们需要一种允许多个读取者并行执行,但写操作互斥的锁,这种锁称为多读单写锁(multiple readers,single writer lock),golang提供了 sync.RWMutex (我们用两个 buffered channels 也能模拟它,一个长度为1,另一个足够长即可)。我们将 mu 声明为 sync.RWMutex 锁,并且改写一下 Balance函数

var mu sync.RWMutex
var balance int
func Balance() int {
    mu.RLock() // readers lock
    defer mu.RUnlock()
    return balance
}

注意,对于读取加锁、解锁是 RLock、RUnlock,对于写入加锁、解锁还是 Lock、Unlock

改写后,Bob密集查询请求可以并行执行并很快完成,总体上看Alice的存款请求能更快得到响应了。

RLock和RUnlock只有在它们包围的critical section内部没有任何写入操作时可用。我们不能凭直觉假设某些“只读”函数或方法不会去更新一些变量。比如一个方法功能是访问一个变量,但它可能会同时去更新内部的计数器+1(例如记录访问次数),或者去更新缓存。如果有疑惑,就不应该使用RLock和RUnlock,换成 Lock 和 Unlock。

不该想当然用 RWMutex 代替所有场合,只有当获得锁的大部分goroutine都是读操作,而锁在竞争条件下(有写入的goroutine在执行原子操作,这些读goroutines必须等待才能获取到锁),RWMutex才是最能带来好处的(一旦writer解锁,所有readers可以快速得到响应)。RWMutex需要更复杂的内部记录,用它比一般的互斥锁慢一些,所以,没有多读单写用它会更慢。

内存同步

前面的 Balance 内部用 RLock 和 RUnlock,我只想到了第一种意图:确保 Balance 读取余额时,不会是 Withdraw 那样的取款操作的“中间”。第二种更重要的意图是内存同步问题。

现代计算机可能会有一堆处理器(多核、多CPU),每一个都会有它的本地缓存(local cache)。为了效率,对内存的写入一般会在每个CPU自己的cache中缓冲,在必要的时候才flush到主内存。这种情况下,数据可能会以与当初goroutine写入顺序不同的顺序被提交到主内存。像 channel通信或者 互斥体操作这样的原语会使处理器将其集聚的写入flush并commit(到主内存),这样goroutine在某个时间点上的执行结果才能被其它处理器上运行的goroutine得到。

var x, y int
go func() {
    x = 1 // A1   可能只是对 cache 中的 x' 进行了修改
    fmt.Print("y:", y, " ") // A2
}()
go func() {
    y = 1                   // B1 可能只是修改 cache 中 y'
    fmt.Print("x:", x, " ") // B2
}()

上面的代码中,出来的结果除了可能以下4种之一

y:0 x:1
x:0 y:1
x:1 y:1
y:1 x:1

还可能是

x:0 y:0
y:0 x:0

一个goroutine内,语句的执行顺序是可以保证的,但不使用 channel 或者 mutex 这样的显式同步操作时,我们无法保证事件在不同的goroutine中看到的执行顺序一致。尽管 routine A 中要 x=1 后才会打印 y 的值,但是 A 无法观察到 B 对 y 的写入操作 y=1,从而可能打印出旧的 y值,同样,B中可能打印旧的x值。

用 cache 来理解就是,A中修改了local cache 中的 x' 为 1,然后打印了主内存中y的值0,B中修改了local cache 中的 y' 为 1,然后打印了主内存中x的值为0,最后,x'的值 flush并commit到主内存x,y'的值flush并commit到主内存y,使得最后主内存中x和y都为1

要避免这种内存不一致问题,确保内存同步,可以遵循一定的规则来避免:将变量限定在goroutine内部如果是多个goroutine都需要访问的变量,使用互斥条件来访问(包括 channel机制或者 mutex)

sync.Once惰性初始化

前面说,避免数据竞争可以除了初始化,不要去写入变量,但有时候,一次性初始化成本比较大(增加启动时间,如果后续从来没有用到,产生浪费),将初始化延迟到需要时再做更好(需要考虑互斥同步)。

下面,icons变量表示所有图标,它是一个map,每个元素键为图标名称,值为图片,loadIcon返回指定名称的图片,loadIcons则初始化icons,Icon根据图标名称返回图片,使用了惰性初始化(lazy initialization,发现icons还是空的时候再去初始化它)。

var icons map[string]image.Image

func loadIcons() {
    icons = map[string]image.Image{
        "spades.png":   loadIcon("spades.png"),
        "hearts.png":   loadIcon("hearts.png"),
        "diamonds.png": loadIcon("diamonds.png"),
        "clubs.png":    loadIcon("clubs.png"),
    }
}

// NOTE: not concurrency-safe!
func Icon(name string) image.Image {
    if icons == nil {
        loadIcons() // one-time initialization
    }
    return icons[name]
}

当多个 goroutine 一起工作时,编译器和CPU只能保证每个 goroutine 按自己的执行顺序来,其他可以随意更改访问内存的指令顺序,从而,loadIcons 执行效果可能和下面的代码等价

func loadIcons() {
    icons = make(map[string]image.Image)
    icons["spades.png"] = loadIcon("spades.png")
    icons["hearts.png"] = loadIcon("hearts.png")
    icons["diamonds.png"] = loadIcon("diamonds.png")
    icons["clubs.png"] = loadIcon("clubs.png")
}

这样,一个 goroutine 已经开始执行 loadIcons 进行初始化了,但它执行到 上述 icons = make(map[string]image.Image)语句 (空的map,零值nil),此时另一个 goroutine 正好在做 if icons == nil 判断,结果是重复执行 loadIcons。这个例子似乎还不算什么严重后果,但这种情况是不应该存在的。

最简单且正确的保证所有 goroutine 能够观察到 loadIcons 效果的方式,是 用 mutex 进行同步检查(用互斥访问保障懒初始化的并发安全性)。(注意mu最后的作用说明注释)

var mu sync.Mutex // guards icons
var icons map[string]image.Image

// Concurrency-safe.
func Icon(name string) image.Image {
    mu.Lock()
    defer mu.Unlock()
    if icons == nil {
        loadIcons()
    }
    return icons[name]
}

但使用 sync.Mutex 锁会导致无法并发访问 icons (一个 Icon函数在获取图标时,另一个不能获取),因为这个场合很可能已经一次性初始化完毕了,属于写少读多的情形,所以,应该用 sync.RWMutex 来提升性能。

var mu sync.RWMutex // guards icons
var icons map[string]image.Image
// Concurrency-safe.
func Icon(name string) image.Image {
    mu.RLock()
    if icons != nil {
        icon := icons[name]
        mu.RUnlock()
        return icon
    }
    mu.RUnlock()

    // acquire an exclusive lock
    mu.Lock()
    if icons == nil { // NOTE: must recheck for nil
        loadIcons()
    }
    icon := icons[name]
    mu.Unlock()
    return icon
}

上面的代码中,先处理更有可能出现的情形,以便快速返回结果。后面的半段代码中,不能想当然认为前面已经判断了 icons != nil,这里就一定是 icons == nil 了,因为要考虑到此时此刻,也许另一个 goroutine 已经在mu.RUnlock()之后,mu.Lock()之前初始化了icons。

因为这种一次性初始化,但懒初始化的场合比较常见,所以,sync包直接提供了 sync.Once:它的作用相当于一个互斥体mutex一个记录初始化是否完成的boolean变量,mutex保护boolean变量和客户端数据结构。sync.Once对象只有Do()这个唯一的方法,用初始化函数作为它的参数。

var loadIconsOnce sync.Once
var icons map[string]image.Image
// Concurrency-safe.
func Icon(name string) image.Image {
    loadIconsOnce.Do(loadIcons)
    return icons[name]
}

上面的代码,可以理解为loadIconsOnce内部存在一个boolean变量,可以识别 loadIcons 有没有被执行过(false=未执行过,true=已经执行过),没有就执行 loadIcons 填充 icons变量,否则直接跳到下一语句。loadIconsOnce保护loadIcons函数的执行是互斥的(其实这里就是只有一个loadIcons会得到调用),同时保证loadIcons对内存的影响(这里是icons变量的改变)是所有goroutine可见的(一致的)。用 sync.Once,可以避免变量在构建完成之前和其它goroutine共享该变量。

竞争条件检测

golang的 runtime 和工具链,提供了动态分析工具:竞争监测器(the race detector)

go buildgo run 或者 go test 命令后面加上 -race 选项,就会创建特别的版本,附带了能够记录所有运行期对共享变量访问工具的test,并且会记录下每一个读或者写共享变量的goroutine的身份信息,还会记录下所有的同步事件(go语句,channel操作,对 (*sync.Mutex).Lock, (*sync.WaitGroup).Wait的调用)。

这个工具会打印一份报告,包含已经发生的数据竞争,但它只能监测到运行时的竞争条件,并不能证明之后不会发生数据竞争,所以,只有测试并发实现了覆盖才能使结果可靠。

使用这个特别版本会跑起来慢一点,且需要更大的内存,但很多生产环境,让程序应对偶发的竞争,损失这点性能是可以接受的。

这个竞争报告的例子是后一节:并发的非阻塞缓存 附带的,稍微有点复杂(主要是代码有点多),这里暂时略过

goroutine和线程

我们说goroutine是逻辑线程,和操作系统线程主要是量的区别,不过这个量变可以引起质变。

每个操作系统线程都有一个固定大小的内存块(一般是2MB)来做栈,这个栈会用来存储当前正在被调用或挂起的函数的内部变量。这玩意就是Windows里面的TLS(线程本地存储,或者称为线程局部存储)。这个2MB大小可以认为既很大又很小,它对于小小的goroutine来说就是浪费,就会限制我们创建成百上千个goroutine,同时,真的碰上复杂的或者递归层次很深的函数,2MB又显然不够(需要人为考虑放到堆上去)。

golang为了突破这个限制,相当于自己加了一层,它会为一个goroutine初始配置一个很小的栈,一般只需要2KB(2MB的千分之一),操作系统线程的挂起或恢复,是操作系统调度的(包括对相应变量的处理),而 goroutine 是 自己的调度器调度的。另外,goroutine的栈并不是固定的,栈大小会根据需要动态伸缩,最大值可以1GB(2MB的500倍)。

操作系统的线程切换,因为涉及线程上下文切换(涉及操作系统内核的工作),代价是比较高的,go运行时包含了自己的调度器,使用了 m:n 调度(m个goroutine由n个操作系统线程多工调度),而且goroutine之间的切换不需要进入内核的上下文,切换代价小得多。

上面的n值,对应go调度器的GOMAXPROCS变量,它默认等于运行机器上的CPU核心数。休眠的或者通信中被阻塞的goroutine是不需要对应的线程来调度的。在 I/O中或系统调用中或调用非Go语言函数时,需要一个对应的操作系统线程的,但 GOMAXPROCS 不把这几种情况计算在内。执行go程序时,可以用 GOMAXPROCS 环境变量显式控制该参数。

for {
    go fmt.Print(0)
    fmt.Print(1)
}

$ GOMAXPROCS=1 go run hacker-cliché.go
111111111111111111110000000000000000000011111...

$ GOMAXPROCS=2 go run hacker-cliché.go
010101010101010101011001100101011010010100110...

和操作系统线程相比,goroutine没有ID号。线程有ID号,操作系统可以根据ID号跟踪线程,也可以根据ID找到它的TLS,而 TLS 总是会被滥用,一些函数会到 TLS 去寻找信息,从而函数行为不完全有它的参数“静态地”决定,和运行时的线程也有关系。golang通过不给goroutine分配ID号(其实应该是不向外部暴露这个信息),刻意杜绝了这种行为。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值