目录

一 并发解决方案

Go程序可以使用通道进行多个goroutine间的数据交换,但是这仅仅是数据同步中的一种方法。Go语言与其他语言如C、Java一样,也提供了同步机制,在某些轻量级的场合,原子访问(sync/atomic包),互斥锁(sync.Mutex)以及等待组(sync.WaitGroup)能最大程度满足需求。

贴士:利用通道优雅的实现了并发通信,但是其内部的实现依然使用了各种锁,因此优雅代码的代价是性能的损失。

二 锁

1.1 互斥锁 sync.Mutex

互斥锁是传统并发程序进行共享资源访问控制的主要方法。Go中由结构体sync.Mutex表示互斥锁,保证同时只有一个 goroutine 可以访问共享资源。

示例一,普通数据加锁:

package main

import (
    "fmt"
    "sync"

    //"sync"
    "time"
)

func main() {

    var mutex sync.Mutex

    num := 0

    // 开启10个协程,每个协程都让共享数据 num + 1
    for i := 0; i < 1000; i++ {
        go func() {
            mutex.Lock()                // 加锁,阻塞其他协程获取锁
            num += 1
            mutex.Unlock()                // 解锁
        }()
    }

    // 大致模拟协程结束 等待5秒
    time.Sleep(time.Second * 5)

    // 输出1000,如果没有加锁,则输出的数据很大可能不是1000
    fmt.Println("num = ", num)

}

一旦发生加锁,如果另外一个 goroutine 尝试继续加锁时将会发生阻塞,直到这个 goroutine 被解锁,所以在使用互斥锁时应该注意一些常见情况:

  • 对同一个互斥量的锁定和解锁应该成对出现,对一个已经锁定的互斥量进行重复锁定,会造成goroutine阻塞,直到解锁

  • 对未加锁的互斥锁解锁,会引发运行时崩溃,1.8版本之前可以使用defer可以有效避免该情况,但是重复解锁容易引起goroutine永久阻塞,1.8版本之后无法利用defer+recover恢复

示例二:对象加锁

// 账户对象,对象内置了金额与锁,对象拥有读取金额、添加金额方法
package main

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

type Account struct {
    money int
    lock *sync.Mutex
}

func (a *Account)Query() {
    fmt.Println("当前金额为:", a.money)
}

func (a *Account)Add(num int) {
    a.lock.Lock()
    a.money += num
    a.lock.Unlock()
}

func main() {

    a := &Account{
        0,
        &sync.Mutex{},
    }

    for i := 0; i < 100; i++ {
        go func(num int){
            a.Add(num)
        }(10)
    }

    time.Sleep(time.Second * 2)
    a.Query()                        // 不加锁会打印不到1000的数值,加锁后打印 1000
}

2.2 读写锁 sync.RWMutex

在开发场景中,经常遇到多处并发读取,一次并发写入的情况,Go为了方便这些操作,在互斥锁基础上,提供了读写锁操作。

读写锁即针对读写操作的互斥锁,简单来说,就是将数据设定为 写模式(只写)或者读模式(只读)。使用读写锁可以分别针对读操作和写操作进行锁定和解锁操作。

读写锁的访问控制规则与互斥锁有所不同:

  • 写操作与读操作之间也是互斥的

  • 读写锁控制下的多个写操作之间是互斥的,即一路写

  • 多个读操作之间不存在互斥关系,即多路读

在Go中,读写锁由结构体sync.RWMutex表示,包含两对方法:

// 设定为写模式:与互斥锁使用方式一致,一路只写
func (*RWMutex) Lock()                // 锁定写
func (*RWMutex) Unlock()            // 解锁写

// 设定为读模式:对读执行加锁解锁,即多路只读
func (*RWMutex) RLock()
func (*RWMutex) RUnlock()

注意:

  • Mutex和RWMutex都不关联goroutine,但RWMutex显然更适用于读多写少的场景。仅针对读的性能来说,RWMutex要高于Mutex,因为rwmutex的多个读可以并存。

  • 所有被读锁定的goroutine会在写解锁时唤醒

  • 读解锁只会在没有任何读锁定时,唤醒一个要进行写锁定而被阻塞的goroutine

  • 对未被锁定的读写锁进行写解锁或读解锁,都会引发运行时崩溃

  • 对同一个读写锁来说,读锁定可以有多个,所以需要进行等量的读解锁,才能让某一个写锁获得机会,否则该goroutine一直处于阻塞,但是sync.RWMutext没有提供获取读锁数量方法,这里需要使用defer避免,如下案例所示。

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

func main() {

    var rwm sync.RWMutex
    
    for i := 0; i < 3; i++ {
        go func(i int) {
            fmt.Println("Try Lock reading i:", i)
            rwm.RLock()
            fmt.Println("Ready Lock reading i:", i)
            time.Sleep(time.Second * 2)
            fmt.Println("Try Unlock reading i: ", i)
            rwm.RUnlock()
            fmt.Println("Ready Unlock reading i:", i)
        }(i)
    }

    time.Sleep(time.Millisecond * 100)
    fmt.Println("Try Lock writing ")
    rwm.Lock()
    fmt.Println("Ready Locked writing ")
}

上述案例中,只有循环结束,才会执行写锁,所以输出如下:

...
Ready Locked writing        // 总在最后一行

2.3 读写锁补充 RLocker方法

sync.RWMutex类型还有一个指针方法RLocker

func (rw *RWMutex) RLocker() Locker

返回值Locker是实现了接口sync.Lokcer的值,该接口同样被 *sync.Mutex*sync.RWMutex实现,包含方法:LockUnlock

当调用读写锁的RLocker方法后,获得的结果是读写锁本身,该结果可以调用Lock和Unlock方法,和RLock,RUnlock使用一致。

2.4 最后的说明

读写锁的内部其实使用了互斥锁来实现,他们都使用了同步机制:信号量。

三 死锁

常见会出现死锁的场景:

  • 两个协程互相要求对方先操作,如:AB相互要求对方先发红包,然后自己再发

  • 读写双方相互要求对方先执行,自己后执行

模拟死锁:

package main

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


func main() {

    var rwm sync.RWMutex
    ch := make(chan int)
    
    go func() {
        rwm.RLock()                // 加读锁
         x := <- ch                // 如果不写入,则无法读取
         fmt.Println("读取到的x:", x)
        rwm.RUnlock()
    }()

    go func() {
        rwm.Lock()            // 加入写锁
        ch <- 10            // 管道无缓存,没人读走,则无法写入
        fmt.Println("写入:", 10)
        rwm.Unlock()
    }()

    time.Sleep(time.Second * 5)

}

将上述死锁案例中的锁部分代码去除,则两个协程正常执行。