《Go语言程序设计》读书笔记(六) 基于共享变量的并发

竞争条件

  • 在一个线性(就是说只有一个goroutine的)的程序中,程序的执行顺序只由程序的逻辑来决定。在有两个或更多goroutine的程序中,每个goroutine内的语句也是按照既定的顺序去执行的,可是通常状况下咱们无法知道分别位于两个goroutine的事件x和y的执行顺序,x是在y以前?以后?仍是同时发生?是无法判断的。当咱们没有办法确认一个事件是在另外一个事件的前面仍是后面发生的话,就说明x和y这两个事件是并发的。
  • 一个函数在线性程序中能够正确地工做。若是在并发的状况下,这个函数依然能够正确地工做的话,那么咱们就说这个函数是并发安全的,并发安全的函数不须要额外的同步工做。咱们能够把这个概念归纳为一个特定类型的一些方法和操做函数,若是这个类型是并发安全的话,那么全部它的访问方法和操做就都是并发安全的。
  • 竞争条件指的是程序在多个goroutine交叉执行操做时,没有给出正确的结果。竞争条件是很恶劣的一种场景,由于这种问题会一直潜伏在你的程序里,而后在很是少见的时候蹦出来,或许只是会在很大的负载时才会发生。
  • 不管任什么时候候,只要有两个goroutine并发访问同一变量,且至少其中的一个是写操做的时候就会发生数据竞争。
  • 避免数据竞争的方法是容许不少goroutine去访问变量,可是在同一个时刻最多只有一个goroutine在访问。这种方式被称为“互斥”。

sync.Mutex互斥锁

  • 咱们能够用一个容量只有1的channel来保证最多只有一个goroutine在同一时刻访问一个共享变量。一个只能为1和0的信号量叫作二元信号量(binary semaphore)。
  • 下面用容量为 1 的 bufferred 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方法可以获取到token(这里叫锁),Unlock方法会释放这个token:
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
}
  • 在Lock和Unlock之间的代码段在goroutine能够随便读取或者修改共享变量,这个代码段叫作临界区。goroutine在结束后释放锁是必要的,不管以哪条路径经过函数都须要释放,即便是在错误路径中,也要记得释放。
  • 因为上面存款和查询余额函数中的临界区代码这么短--只有一行,没有分支调用--在代码最后去调用Unlock就显得更为直截了当。在更复杂的临界区的应用中,尤为是必需要尽早处理错误并返回的状况下,就很难去(靠人)判断对Lock和Unlock的调用是在全部路径中都可以严格配对的了。Go语言里的defer简直就是这种状况下的救星:咱们用defer来调用Unlock,临界区会隐式地延伸到函数做用域的最后,这样咱们就从“总要记得在函数返回以后或者发生错误返回时要记得调用一次Unlock”这种状态中得到了解放。Go会自动帮咱们完成这些事情。
func Balance() int {
    mu.Lock()
    defer mu.Unlock()
    return balance
}

上面的例子里Unlock会在return语句读取完balance的值以后执行,因此Balance函数是并发安全的。程序员

  • 一个deferred Unlock即便在临界区发生panic时依然会执行,这对于用recover 来恢复的程序来讲是很重要的。defer调用只会比显式地调用Unlock成本高那么一点点,不过却在很大程度上保证了代码的整洁性。大多数状况下对于并发程序来讲,代码的整洁性比过分的优化更重要。尽可能使用defer来将临界区扩展到函数的结束。

sync.RWMutex读写锁

  • 因为Balance函数只须要读取变量的状态,因此咱们同时让多个Balance调用并发运行事实上是安全的,只要在运行的时候没有存款或者取款操做就行。在这种场景下咱们须要一种特殊类型的锁,其容许多个只读操做并行执行,但写操做会彻底互斥。这种锁叫作“多读单写”锁(multiple readers, single writer lock),Go语言提供的这样的锁是sync.RWMutex:
var mu sync.RWMutex
var balance int
func Balance() int {
    mu.RLock() // readers lock
    defer mu.RUnlock()
    return balance
}
  • Balance函数如今调用了RLock和RUnlock方法来获取和释放一个读共享锁。Deposit函数没有变化,会调用mu.Lock和mu.Unlock方法来获取和释放一个写互斥锁。
  • RWMutex只有当得到锁的大部分goroutine都是读操做,并且锁是在竞争条件下,也就是说,goroutine们必须等待才能获取到锁的时候,RWMutex才是最能带来好处的。RWMutex须要更复杂的内部记录,因此会让它的性能比通常的mutex锁慢一些。

内存同步

你可能比较纠结为何Balance方法只由一个简单的操做组成也须要用到互斥条件?这里使用mutex有两方面考虑。第一Balance不会在其它操做好比Withdraw“中间”执行。第二(更重要)的是"同步"不只仅是一堆goroutine执行顺序的问题;一样也会涉及到内存的问题。编程

在现代计算机中可能会有一堆处理器,每个都会有其本地缓存(local cache)。为了效率,对内存的写入通常会在每个处理器中缓冲,并在必要时一块儿flush到主存。这种状况下这些数据可能会以与当初goroutine写入顺序不一样的顺序被提交到主存。像channel通讯或者互斥量操做这样的原语会使处理器将其汇集的写入flush并commit,这样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是并发执行,而且访问共享变量时也没有互斥,会有数据竞争,因此程序的运行结果无法预测的话也请不要惊讶。咱们可能但愿它可以打印出下面这四种结果中的一种,至关于几种不一样的交错执行时的状况:安全

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中,每个语句的执行顺序是能够被保证的;也就是说goroutine是顺序连贯的。可是在不使用channel且不使用mutex这样的显式同步操做时,咱们就无法保证事件在不一样的goroutine中看到的执行顺序是一致的了。尽管goroutine A中必定须要观察到x=1执行成功以后才会去读取y,但它无法确保本身观察获得goroutine B中对y的写入,因此A还可能会打印出y的一个旧版的值。并发

尽管去理解并发的一种尝试是去将其运行理解为不一样goroutine语句的交错执行,但看看上面的例子,这已经不是现代的编译器和cpu的工做方式了。由于赋值和打印指向不一样的变量,编译器可能会判定两条语句的顺序不会影响执行结果,而且会交换两个语句的执行顺序。若是两个goroutine在不一样的CPU上执行,每个核心有本身的缓存,这样一个goroutine的写入对于其它goroutine的Print,在主存同步以前就是不可见的了。函数

全部并发的问题均可以用一致的、简单的既定的模式来规避。因此可能的话,将变量限定在goroutine内部;若是是多个goroutine都须要访问的变量,使用互斥条件来访问。工具

竞争条件检测

只要在go build,go run或者go test命令后面加上-race的flag,就会使编译器建立一个你的应用的“修改”版或者一个附带了可以记录全部运行期对共享变量访问工具的test,而且会记录下每个读或者写共享变量的goroutine的身份信息。另外,修改版的程序会记录下全部的同步事件,好比go语句,channel操做,以及对(sync.Mutex).Lock,(sync.WaitGroup).Wait等等的调用。

竞争检查器会报告全部的已经发生的数据竞争。然而,它只能检测到运行时的竞争条件;并不能证实以后不会发生数据竞争。因此为了使结果尽可能正确,请保证你的测试并发地覆盖到了你到包。

因为须要额外的记录,所以构建时加了竞争检测的程序跑起来会慢一些,且须要更大的内存,即便是这样,这些代价对于不少生产环境的工做来讲仍是能够接受的。对于一些偶发的竞争条件来讲,让竞争检查器来干活能够节省无很多天夜的debugging。

goroutine和线程的区别

动态栈

每个OS线程都有一个固定大小的内存块(通常会是2MB)来作栈,这个栈会用来存储当前正在被调用或挂起(指在调用其它函数时)的函数的内部变量。这个固定大小的栈同时很大又很小。由于2MB的栈对于一个小小的goroutine来讲是很大的内存浪费,好比对于咱们用到的,一个只是用来WaitGroup以后关闭channel的goroutine来讲。而对于go程序来讲,同时建立成百上千个gorutine是很是广泛的,若是每个goroutine都须要这么大的栈的话,那这么多的goroutine就不太可能了。除去大小的问题以外,固定大小的栈对于更复杂或者更深层次的递归函数调用来讲显然是不够的。修改固定的大小能够提高空间的利用率容许建立更多的线程,而且能够容许更深的递归调用,不过这二者是无法同时兼备的。

相反,一个goroutine会以一个很小的栈开始其生命周期,通常只须要2KB。一个goroutine的栈,和操做系统线程同样,会保存其活跃或挂起的函数调用的本地变量,可是和OS线程不太同样的是一个goroutine的栈大小并非固定的;栈的大小会根据须要动态地伸缩。而goroutine的栈的最大值有1GB,比传统的固定大小的线程栈要大得多,尽管通常状况下,大多goroutine都不须要这么大的栈。

goroutine 调度

OS线程会被操做系统内核调度。每几毫秒,一个硬件计时器会中断处理器,这会调用一个叫作scheduler的内核函数。这个函数会挂起当前执行的线程并保存内存中它的寄存器内容,检查线程列表并决定下一次哪一个线程能够被运行,并从内存中恢复该线程的寄存器信息,而后恢复执行该线程的现场并开始执行线程。由于操做系统线程是被内核所调度,因此从一个线程向另外一个“移动”须要完整的上下文切换,也就是说,保存一个用户线程的状态到内存,恢复另外一个线程的到寄存器,而后更新调度器的数据结构。这几步操做很慢,由于其局部性不好须要几回内存访问,而且会增长运行的cpu周期。

Go的运行时包含了其本身的调度器,这个调度器使用了一些技术手段,好比m:n调度,由于其会在n个操做系统线程上多工(调度)m个goroutine。Go调度器的工做和内核的调度是类似的,可是这个调度器只关注单独的Go程序中的goroutine。

和操做系统的线程调度不一样的是,Go调度器并非用一个硬件定时器而是被Go语言"建筑"自己进行调度的。例如当一个goroutine调用了time.Sleep或者被channel调用或者mutex操做阻塞时,调度器会使其进入休眠并开始执行另外一个goroutine直到时机到了再去唤醒第一个goroutine。由于由于这种调度方式不须要进入内核的上下文,因此从新调度一个goroutine比调度一个线程代价要低得多。

GOMAXPROCS

Go的调度器使用了一个叫作GOMAXPROCS的变量来决定会有多少个操做系统的线程同时执行Go的代码。其默认的值是运行机器上的CPU的核心数,因此在一个有8个核心的机器上时,调度器一次会在8个OS线程上去调度GO代码。(GOMAXPROCS是前面说的m:n调度中的n)。在休眠中的或者在通讯中被阻塞的goroutine是不须要一个对应的线程来作调度的。在I/O中或系统调用中或调用非Go语言函数时,是须要一个对应的操做系统线程的,可是GOMAXPROCS并不须要将这几种状况计数在内。

你能够用GOMAXPROCS的环境变量显式地控制这个参数,或者也能够在运行时用runtime.GOMAXPROCS函数来修改它。咱们在下面的小程序中会看到GOMAXPROCS的效果,这个程序会无限打印0和1。

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被执行。初始状况下只有main goroutine被执行,因此会打印不少1。过了一段时间后,GO调度器会将其置为休眠,并唤醒另外一个goroutine,这时候就开始打印不少0了,在打印的时候,goroutine是被调度到操做系统线程上的。在第二次执行时,咱们使用了两个操做系统线程,因此两个goroutine能够一块儿被执行,以一样的频率交替打印0和1。咱们必须强调的是goroutine的调度是受不少因子影响的,而runtime也是在不断地发展演进的,因此这里的你实际获得的结果可能会由于版本的不一样而与咱们运行的结果有所不一样。

Goroutine没有ID号

在大多数支持多线程的操做系统和程序语言中,当前的线程都有一个独特的身份(id),而且这个身份信息能够以一个普通值的形式被被很容易地获取到,典型的能够是一个integer或者指针值。这种状况下咱们作一个抽象化的thread-local storage(线程本地存储,多线程编程中不但愿其它线程访问的内容)就很容易,只须要以线程的id做为key的一个map就能够解决问题,每个线程以其id就能从中获取到值,且和其它线程互不冲突。

goroutine没有能够被程序员获取到的身份(id)的概念。这一点是设计上故意而为之,因为thread-local storage老是会被滥用。Go鼓励更为简单的模式,这种模式下参数对函数的影响都是显式的。这样不只使程序变得更易读,并且会让咱们自由地向一些给定的函数分配子任务时不用担忧其身份信息影响行为。

相关文章
相关标签/搜索