第九章使用共享变量实现并发

  • 竞态

考虑一个能在串行程序中正确工作的函数,如果这个函数在并发调用时仍然能正确工作,那么这个函数是并发安全(concurrency-safe)的,在这里并发调用是指,在没有额外同步机制的情况下,从两个或者多个goroutine同时调用这个函数

对于绝大部分变量,如要回避并发访问,要么限制变量只存在于一个goroutine内,要么维护一个更高层的互斥不变量

导出的包级别函数通常可以认为是并发安全的。因为包级别的变量无法限制在一个goroutine内,所以那些修改这些变量的函数就必须采用互斥机制

函数并发调用时不工作的原因有很多,包括死锁、活锁以及资源耗尽

竞态是指在多个goroutine按某些交错顺序执行时程序无法给出正确的结果

package bank

var balance int

func Deposit(amount int) { balance = balance + amount }

func Balance() int { return balance }

数据竞态:发生于两个goroutine并发读写同一个变量并且至少其中一个是写入时

避免数据竞态:

方法一:不要修改变量

var icons = make(map[string]image.Image)

func loadIcon(name string) image.Image

//并发不安全
func Icon(name string) image.Image {
    icon, ok := icons[name]
    if !ok {
        icon = loadIcon(name)
        icons[name] = icon
    }
    return icon
} 

//如果在创建其他goroutine之前就用完整的数据来初始化map,并且不再修改。那么无论多少goroutine也可以安全地并发调用Icon,因为每个goroutine都只读取这个map

var icons = map[string]image.Image{
    "spades.png":        loadIcon("spades.png")
    ......
}

//并发安全
func Icon(name string) image.Image {
    return icons[name]
}

//在上面的例子中,icons变量的赋值发生在包初始化时,也就是在程序的main函数开始运行之前。一旦初始化完成之后,icons就不再修改

方法二:避免从多个goroutine访问同一个变量

由于其他goroutine无法直接访问相关变量,因此它们就必须使用通道来向受限goroutine发送查询请求或者更新变量-->Go箴言:不要通过共享内存来通信,而应该通过通信来共享内存

使用通道请求来代理一个受限变量的所有访问的goroutine称为该变量的监控goroutine(monitor goroutine)

//重写银行案例,用一个叫teller的监控goroutine限制balance变量

package bank

var deposits = make(chan int)  //发送存款额
var balances = make(chan int)  //接收余额

func Deposit(amount int) { 
    deposits <- amount
}

func Balance() int {
    return <-balances
}

func teller() {
    var balance int  //balance被限制在teller goroutine中
    for {
        select {
        case amount := <-deposits:
            balance += amount
        case balances <- balance:
        }
    }
}

func init() {
    go teller()  //启动监控goroutine
}

//即使一个变量无法在整个生命周期受限于单个goroutine,加以限制仍然可以是解决并发访问的好方法。比如一个

方法三:互斥机制

  • 互斥锁:sync.Mutex

可以用一个容量为1的通道来保证同一时间最多有一个goroutine能访问共享变量。一个计数上限为1的信号量称为二进制信号量

var (
    sema = make(chan struct{}, 1)  //用来保护balance的二进制信号量
    balance int
)

func Deposit(amount int) {
    sema <- struct{}{}  //获取令牌
    balance = balance + amount
    <-sema  //释放令牌
}

func Balance() int {
    sema <- struct{}{}  //获取令牌
    b := balance
    <-sema  //释放令牌
    return b
}
//互斥锁模式应用非常广泛,所以sync包有一个单独的Mutex类型来支持这种模式
//它的Lock方法用于获取令牌,Unlock方法用于释放令牌
import "sync"

var {
    mu          sync.Mutex  //保护balance
    balance    int
}
//按照惯例被互斥量保护的变量声明应当紧接在互斥量的声明之后

//在Lock和Unlock之间的代码,可以自由地读取和修改共享变量,这一部分称为临界区域。在锁的持有人调用Unlock之前,其他goroutine不能获取锁

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

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

Go语言的defer语句可以通过延迟执行Unlock就可以把临界区域隐式扩展到当前函数的结尾,避免了必须在一个或者多个远离Lock的位置插入一条Unlock语句

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

//withdraw函数:当成功时,余额减少了指定的数量,并且返回true
//但如果余额不足,无法完成交易,withdraw恢复余额并返回false
func Withdraw(amount int) bool {
    Deposit(-amount)
    if Balance() < 0 {
        Deposit(amount)
        return false  //余额不足
    }
    return true
}

//上面withdraw函数的问题在于:不是原子操作,包含三个串行的操作,每个操作都申请并释放了互斥锁,但对于整个序列没有上锁
//互斥锁是不能再入的

封装即通过在程序中减少对数据结构的非预期交互,来帮助我们保证数据结构中的不变量。因为类似的原因,封装也可以用来保持并发中的不变性

  • 读写互斥锁:sync.RWMutex

允许只读操作可以并发执行,但写操作需要获得完全独享的访问权限。这种锁称为多读单写锁,Go语言中的sync.RWMutex可以提供这种功能

var mu sync.RWMutex
var balance int

func Balance() int {
    mu.RLock()  //读锁
    defer mu.RUnlock()
    return balance
}

//Balance函数现在可以调用RLock和RUnlock方法来分别获取和释放一个读锁
//Deposit函数无须更改,它通过调用mu.Lock和mu.Unlock来分别获取和释放一个写锁

 

  • 内存同步

在缺乏显式同步的情况下,编译器和CPU在能保证每个goroutine都满足串行一致性的基础上可以自由地重排访问内存的顺序

Balance方法只包含单个操作,并不存在另外一个goroutine插在中间执行的风险,但它仍需要互斥锁(不管是基于通道的锁还是基于互斥量的锁),原因有两个:

第一个是防止Balance插到其他操作中间,比如withdraw;第二个是,因为同步不仅涉及多个goroutine的执行顺序问题,同步还会影响到内存

 

计算机一般都会有多个处理器,每个处理器都有内存的本地缓存。为了提高效率,对内存的写入是缓存在每个处理器中的,只在必要时才刷回内存。甚至刷回内存的顺序都可能与goroutine的写入顺序不一致。像通道通信或者互斥锁操作这样的同步原语都会导致处理器把累积的写操作刷回内存并提交,所以这个时刻之前goroutine的执行结果就保证了对运行在其他处理器的goroutine可见

 

var x, y int

go func() {
    x = 1          //A1
    fmt.Print("y:", y, " ")  //A2
}()

go func() {
    y = 1          //B1
    fmt.Print("x:", x, " ")  //B2
}()

在单个goroutine内,每个语句的效果保证按照执行的顺序发生,goroutine是串行一致的。但是在缺乏使用通道或者互斥量来显式同步的情况下,并不能保证所有的goroutine看到的事件顺序都是一致的

现代编译器和CPU的工作方式:因为赋值和print对应不同的变量,所以编译器就可能会认为两个语句的执行顺序不会影响结果,然后就交换了这两个语句的执行顺序。CPU也有类似的问题,如果两个goroutine在不同的CPU上执行,每个CPU都有自己的缓存,那么一个goroutine的写入操作在同步到内存之前对另外一个goroutine的Print语句是不可见的

这些并发问题都可以通过采用简单、成熟的模式来避免,即在可能的情况下,把变量限制到单个goroutine中,对于其他变量,使用互斥锁

  • 延迟初始化:sync.Once

延迟一个昂贵的初始化步骤到有实际需求的时刻是一个很好的实践。预先初始化一个变量会增加程序的启动延时,并且如果实际执行时有可能根本用不上这个变量,那么初始化也不是必需的

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"),
    }
}

//并发不安全
func Icon(name string) image.Image {
    if icons == nil{ 
    loadIcons()//一次性初始化
    }
    return icons[name]
}
//保证所有goroutine都能观察到loadIcons效果最简单的正确方法就是用一个互斥锁来做同步
var mu sync.Mutex  //保护icons
var icons map[string]image.Image

//并发安全
func Icon(name string) image.Image {
    mu.Lock()
    defer mu.Unlock()
    if icons == nil {
        loadIcons()
    }
    return icons[name]
}

//采用互斥锁访问icons的额外代价时两个goroutine不能并发访问这个变量,即使在变量已经安全完成初始化且不再更改的情况下,也会造成这个后果。使用一个可以并发读的锁就可以改善这个问题
var mu sync.RWMutex  //保护icons
var icons map[string]image.Image

//并发安全
func Icon(name string) image.Image {
    mu.RLock()
    if icons != nil {
        icon := icons[name]
        mu.RUnlock()
        return icon
    }
    mu.RUnlock()

    //获取互斥锁
    mu.Lock()
    if icons == nil {  //必须重新检查nil值
        loadIcons()
    }
    icon := icons[name]
    mu.Unlock()
    return icon
}

//这里有两个临界区域。goroutine先获取一个读锁,查阅map,然后释放这个读锁
//如果条目能找到,就返回它。如果没有找到,goroutine再获取一个写锁
//由于不先释放一个共享锁就无法直接把它升级到互斥锁,为了避免在过渡期其他goroutine已经初始化了icons,所以我们必须重新检查nil值
//sync包提供了针对一次性初始化问题的特化解决方案:sync.Once
//从概念上讲,Once包含一个布尔变量和一个互斥量,布尔变量记录初始化是否已经完成
//互斥量则负责保护这个布尔变量和客户端的数据结构
//Once的唯一方法Do以初始化函数作为它的参数

var loadIconsOnce sync.Once
var icons map[string]image.Image

//并发安全
func Icon(name string) image.Image {
    loadIconsOnce.Do(loadIcons)
    return icons[name]
}

//每次调用Do(loadIcons)时会显锁定互斥量并检查里边的布尔变量。在第一次调用时,这个布尔变量为假,Do会调用loadIcons然后把变量设置为真。后续的调用相当于空操作,只是通过互斥量的同步来保证loadIcons对内存产生的效果(在这里就是icons变量)对所有的goroutine可见。以这种方式来使用sync.Once,可以避免变量在正确构造之前就被其他goroutine分享

 

  • 竞态检测器

Go语言运行时和工具链装备了一个精致并易于使用的动态分析工具:竞态检测器race detector

简单地把-race命令行参数加到go build、 go run、go test命令里边即可使用该功能

  • 示例:并发非阻塞缓存
  • goroutine与线程

可增长的栈-->每个OS线程都有一个固定大小的栈内存(通常为2MB),栈内存区域用于保存在其他函数调用期间那些正在执行或临时暂停的函数中的局部变量

一个goroutine在生命周期开始时只有一个很小的栈,典型情况下为2KB。与OS线程类似,goroutine的栈也用于存放那些正在执行或临时暂停的函数中的局部变量。但与OS线程不同的是,goroutine的栈不是固定大小的,它可以按需增大和缩小。goroutine的栈大小限制可以达到1GB,比线程典型的固定大小的栈高几个数量级

goroutine调度-->OS线程由OS内核来调度,控制权限从一个线程到另外一个线程需要一个完整的上下文切换:即保存一个线程的状态到内存,再恢复另外一个线程的状态,最后更新调度器的数据结构

Go运行时包含一个自己的调度器,这个调度器使用一个称为m:n调度的技术(因为它可以复用/调度m个goroutine到n个OS线程)。Go调度器与内核调度器的工作类似,但Go调度器只需关心单个Go程序的goroutine调度问题

GOMAXPROCS-->Go调度器使用GOMAXPROCS参数来确定需要使用多少个OS线程来同时执行Go代码。默认值是机器上的CPU数量。正在休眠或者正在被通道通信阻塞的goroutine不需要占用线程

goroutine没有标识-->goroutine没有可供程序员访问的标识

posted @ 2020-06-10 14:36  LinBupt  阅读(340)  评论(0)    收藏  举报