go语言圣经第九章(读书笔记)

时间:2022-02-02 04:21:07

第九章 基于共享变量的并发

  • 同样的,省略例子,还有一些目前还没理解的部分,这些等挑战完Go语言实战再来

竞争冒险

  • 必须说明:
    1.竞争条件这个翻译是很糟糕的,在gopl-zh中的理解容易产生歧义,这里采用竞争冒险
    2.*中的理解 :竞争冒险(race hazard)又名竞态条件、竞争条件(race condition),它旨在描述一个系统或者进程的输出依赖于不受控制的事件出现顺序或者出现时机。此词源自于两个信号试着彼此竞争,来影响谁先输出。举例来说,如果计算机中的两个进程同时试图修改一个共享内存的内容,在没有并发控制的情况下,最后的结果依赖于两个进程的执行顺序与时机。而且如果发生了并发访问冲突,则最后的结果是不正确的。竞争冒险常见于不良设计的电子系统,尤其是逻辑电路。但它们在软件中也比较常见,尤其是有采用多线程技术的软件。

  • 并发:当我们没法确认一个事件是在另一个事件的前面后后面发生的,说明这两个事件是并发的
  • 并发安全:
    1.一个函数在线性程序下可以正确工作,在并发情况下也可以正确工作,那么就说这个函数是并发安全的
    2.一个特定类型的一些方法和操作函数,如果这个类型是并发安全的,那么它的访问方法和操作都是并发安全的
    3.并发安全并不是规则,只是一种情况,可以采取手段让程序并发安全

  • 一个函数并发调用时,可能会发生竞争冒险,例如发生了死锁,活锁,饿死
  • 竞争冒险——数据竞争:
    1.描述:在两个以上的goroutine并发访问相同的变量且至少其中一个为写操作
    2.解决方法:不执行写操作;避免从多个goroutine访问变量(不要使用共享数据来通信;使用通信来共享数据);允许多个goroutine去访问变量,但是同一时刻最多只有一个goroutine在访问(互斥)
    3.变量的监控goroutine:goroutine中,一个变量通过channel来请求数据
    4.串行绑定:如果流水线的每一个阶段都能够避免在将变量传送到下一阶段时再去访问它,那么对这个变量的所有访问就是线性的。其效果是变量会被绑定到流水线的一个阶段,传送完之后被绑定到下一个,以此类推

sync.Mutex互斥锁

  • 二元信号量:信号量只能为0或者1
  • 使用容量只有1的channel可以模拟二元信号量
var (
    sema= make(chan struct{}, 1) // a binary semaphore guarding balance
    balance int
)
func Deposit(amount int) {
    sema <- struct{}{} // acquire token
    balance = balance + amount
    <-sema // release token
}
func Balance() int {
    sema <- struct{}{} // acquire token
    b := balance
    <-sema // release token
    return b
}
  • 这种方式可以直接用sync包里的Mutex类型直接支持,术语上称为互斥锁,有lock和unlock操作
import "sync"
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
}
  • 对于上面的例子,每次一个goroutine访问bank变量时,它都会调用mutex的Lock方法来获取一个互斥锁;如果其它的goroutine已经获得这个锁的话,这个操作会被阻塞,知道其它goroutine调用Unlock使锁变回可用状态
  • mutex会保护共享变量
  • 按照惯例,mutex所保护的变量是在mutex变量之后立即声明的,如上例中balance是要保护的变量,它的声明位置恰好紧挨着mu这个sync.Mutex类型变量声明的下面
  • 临界区:在Lock和Unlock之间的代码段的内容,goroutine可以随便读取或者修改,这个代码段叫做临界区
  • 并发模式——监控monitor:
    1.一系列的导出函数封装了一个或多个变量,那么访问这些变量唯一的方式就是通过这些函数来做
    2.每个函数一开始就获取互斥锁(Lock)并在最后释放锁(Unlock),从而保证共享变量不会被并发访问
    3.对于简单的逻辑,释放锁是很显然的,但是复杂操作下,建议使用defer语句来调用Unlock释放锁

  • Go的互斥量不能重入:互斥量的目的,是为了保证共享变量,在程序执行时的关键点上能够保证不变性
  • 不变性:
    1.没有goroutine访问共享变量
    2.当一个goroutine获得一个互斥锁时,它会断定这种不变性能够被保持
    3.在其持有锁期间,可能回去更新共享变量,这时会失去不变性
    4.然而,当释放锁后,它必须保证不变性已经恢复
    5.使用mutex时,确保mutex和其保护的变量没有被导出(使用小写),无论这些变量是包级的变量是一个struct的字段

sync.RWMutex读写锁

  • 这是一种更加细化的锁规定
  • 多读单写锁,允许多个只读操作,但写操作完全互斥,由sync.RWMutex提供
var mu sync.RWMutex
var balance int
func Balance() int {
    mu.RLock() // readers lock
    defer mu.RUnlock()
    return balance
}
  • Balance函数调用RLock和RULock方法来获取和释放一个读取或者共享锁
  • RWMutex需要更复杂的内部记录,所以会让它比一般的无竞争锁mutex慢一些

sync.Once初始化

  • 如果初始化成本较大,初始化可以延迟到需要的时候去做
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]
}
  • 上述例子中的确做到在需要的时候初始化了,但是如果并发调用Icon函数很可能会造成多次初始化引发问题
  • 可以使用sync.Once来解决这种一次性初始化问题
var loadIconsOnce sync.Once
var icons map[string]image.Image
// Concurrency-safe.
func Icon(name string) image.Image {
    loadIconsOnce.Do(loadIcons)
    return icons[name]
}
  • "每一次对Do(loadIcons)的调用都会锁定mutex,并会检查boolean变量。在第一次调用时,变量的值是false,Do会调用loadIcons并会将boolean设置为true。随后的调用什么都不会做,但是mutex同步会保证loadIcons对内存(这里其实就是指icons变量啦)产生的效果能够对所有goroutine可见。用这种方式来使用sync.Once的话,我们能够避免在变量被构建完成之前和其它goroutine共享该变量。"

竞争冒险检测

  • Go为程序员提供了竞争检查器(the race detector)
  • 只要在go build或者go run或者go test后面加上-race的flag,就能创建一个附带了能够记录所有运行期对共享变量访问的工具的test

Goruntines和线程

动态栈

  • 每一个OS线程:
    1.有固定大小的内存块作为栈,,用来存储当前正在被调用或挂起的函数内部变量

  • 一个goroutine:
    1.从一个很小的栈开始生命周期,一般只需2KB,会保存活跃或挂起的函数的本地变量,但是其大小是动态伸缩的,最大值为1GB

Goroutine调度

  • OS线程会被操作系统内核调度(通过内核函数scheduler)
  • Go的运行,有自己的调度器

  • GOMAXPROCS:
    1.Go的调度器使用了一个叫做GOMAXPROCS的变量来决定会有多少个操作系统的线程同时执行Go的代码
    2.其默认的值是运行机器上的CPU的核心数,所以在一个有8个核心的机器上时,调度器一次会在8个OS线程上去调度GO代码

  • Goroutine 没有ID号