Go基于共享变量的并发

在前一章中,我们介绍了几个使用goroutines和channel以直接和自然的方式表示并发的程序。然而,在这样做的过程中,我们忽略了程序员在编写并发代码时必须牢记的一些重要而微妙的问题。

在本章中,我们将更深入地了解并发性的机制。特别地,我们将指出与多个goroutines之间共享变量相关的一些问题,识别这些问题的分析技术,以及解决这些问题的模式。最后,我们将解释goroutines和操作系统线程之间的一些技术差异。

9.1 Race Conditions 竞态条件

在串行/顺序执行的程序中,即只有一个goroutine的程序,程序执行的步骤按照程序逻辑确定的执行顺序发生。例如,在一系列的串行/顺序语句中,第一个语句hanppen before【发生先于】第二个语句,以此类推。在一个有两个或多个goroutine的程序中,每个goroutine中的步骤按照既定的顺序发生,但通常我们不知道一个goroutine中的x事件是在另一个goroutine的y事件之前发生的,还是在它之后发生的,或者是同时发生的。当我们不能自信地说一个事件hanppen before【发生先于】另一个事件的发生时,那么事件x和y就是同时发生的。

考虑一个在串行/顺序执行的程序中运行的函数。如果该函数即使在并发调用时仍能正常工作(即从两个或多个goroutines调用而不需要额外的同步),那么它就是并发安全【concurrency-safe】的。我们可以将此概念推广到一组协作函数,例如特定类型的方法和操作。如果一个类型的所有可访问的方法和操作都是并发安全的,那么该类型也是并发安全的。

我们可以不需要使程序中的所有具体类型都并发安全,就可以实现程序的并发安全。实际上,并发安全类型是例外,而不是规则,因此只有在其类型的文档中表明该类型这是安全的情况下,我们才应该并发地访问变量。我们可以通过将重要的变量限制在一个单独的goroutine中,或者通过保持互斥的高级不变量来避免对重要的变量的并发访问。我们将在本章中解释这些术语。

相反,导出的包级别的函数通常被期望为并发安全的。由于包级别的变量不能限制在单个goroutine中执行,因此修改它们的函数必须强制执行互斥。

当并发访问时,有多种原因会导致函数不能正常的工作,这些原因包括死锁【deadlive】、活锁【livelock】以及资源饥饿【resource starvation】。我们没有时间来讨论这所有的会导致程序非正常运行的原因,因此我们将注意力关注于最重要的一点,即竞态条件。
竞态条件是指程序由于多个goroutine的检查操作导致没有给出正确结果的情况。竞态条件是有害的,因为它们可能隐藏在程序中,很少出现,可能只在高负载或使用某些特定的编译器、平台或体系结构时才出现。这使得它们难以重现和诊断。
我们一般使用元数据或者财务损失来表述它的重要性,所以我们将考虑一个简单的银行账户程序。

// 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 }

(我们可以把Deposit(存款的意思)函数的主体写成balance += amount,这是等价的,但较长的形式将简化解释)。
对于这个简单的程序,我们一眼就可以看出来,任何串行/顺序的调用Deposit,Balance函数都会给出正确的结果,也就是说Balance会报告出之前存款的总额。然而,如果我们并发的调用Deposit函数,那么Balance就不能保证会给出正确的结果了。让我们看下面这两个goroutines,它们代表的是在一个联合银行帐户上的两笔交易:

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

Alice存了$200,然后检查了余额,这时Bob也存了$100。因为步骤A1,A2与B是并发发生的,我们无法预测它们发生的顺序。直觉上,有三种可能的熟悉怒,分别是“Alice先”,“”Bob先“,“”Alice/Bob/Alice”。下面的表展示了balance变量在每一个步骤之后的值。双引号代表的是打印的余额。

Alice First Bob First Alice/Bob/Alice
0 0 0
A1 200 B 100 A1 200
A2 “= 200” A1 300 B 300
B 300 A2 “= 300” A2 “= 300”

在所有情况最后,balance都是$300。唯一的变化是Alice的资产负债表是否包含Bob的交易,但是客户对这些情况都很满意。

但这种直觉是错误的。还有第四种可能的结果,Bob的存款发生在Alice存款的中间,在余额被读取( balance + amount)之后,但是在余额被更新(balance =…)之前,这将导致Bob的事务消失。这是因为Alice的存款操作A1,实际上是两个操作,一个读和一个写;我们称它们为A1r和A1w。这里有一个有问题的交叉:

Data race
0
A1r  0 		... = balance + amount
B 100
A1w  200 		balance = ...
A2 "= 200"

在A1r后,表达式balance + amount计算得到200,这个值会在A1w时候被写入到balance,尽管这中间有介入的存款。最终的余额是$200,银行因为Bob而富有了$100。
这个程序包含一个特殊类型的竞态条件,我们称之为数据竞争(data race)。当两个goroutine并发的访问相同的变量
当两个goroutines同时访问同一个变量且至少有一个访问是写操作时,就会发生数据争用。

如果数据竞态涉及的数据的类型是一个比单个机器字还要大的类型(如接口、字符串或切片),事情就会变得更加混乱。下面这段代码并发地将x更新为两个不同长度的切片:

var x []int
go func() { x = make([]int, 10) }()
go func() { x = make([]int, 1000000) }()
x[999999] = 1 // NOTE: undefined behavior; memory corruption possible!

最后一个语句中的x值没有被明确定义,它可能是nil,或者是length为10的切片,或者length为1,000,000的切片。但是回想一下,一个切片有三个部分:指针、长度和容量。如果指针来自第一个执行的make调用,而长度来自第二个make调用,那么x将是一个嵌合体,即一个名义长度为1,000,000的切片,但其底层数组只有10个元素。在这种情况下,存储元素到999,999将会破坏一个任意遥远的内存地址,其后果是无法预测的,也很难调试和本地化。这种语义雷区称为未定义行为【undefined behavior】,C程序员都知道;因为总的来说,在Go中很少有像在C语言中一样的词。
即使直觉上认为并发程序是几个顺序执行的程序的交错,这也是错误的。正如我们将在9.4章节中看到的那样,数据竞态可能会产生更奇怪的结果。许多程序员—甚至一些非常聪明的人—他们偶尔会为程序中存在已知的数据竞态提供辩解:“一次性排除的成本太高了”,“这块逻辑只用于日志记录”,“我不介意丢失一些消息”等等。在给定的编译器和平台上没有出现问题,这可能会给他们提供了错误的信心。一个好的经验法则是,没有良性的数据竞态。那么我们如何避免程序中的数据竞态呢?

我们将重复这个定义,因为它非常重要:当两个goroutines同时访问同一个变量且至少有一个访问是写操作时,就会发生数据竞态。根据这个定义,有三种方法可以避免数据竞态。

第一种方法是不要向变量做写操作。考虑下面的map,由于每个键都是第一次请求的,因此它被延迟填充。如果按顺序调用 Icon,程序运行正常,但如果同时调用 Icon,就会出现数据竞态。

var icons = make(map[string]image.Image)
func loadIcon(name string) image.Image
// NOTE: not concurrency-safe!
func Icon(name string) image.Image {
	icon, ok := icons[name]
	if !ok {
		icon = loadIcon(name)
		icons[name] = icon
	}
	return icon
}

如果我们在创建额外的goroutines之前,用所有必要的条目初始化这个map,并且不再修改它,那么任意数量的goroutines都可以安全地同时调用Icon,因为它们都只读取map。

var 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"),
}
// Concurrency-safe.
func Icon(name string) image.Image { return icons[name] }

在上面的例子中,icon变量在包初始化的时候就被赋值了,而包初始化是happen before【发生先于】程序的main函数。一旦被初始化了,icon就不会被更改了。从不修改或不可变的数据结构在本质上是并发安全的,不需要同步。但显然,如果更新是必要的,我们就不能使用这种方法,就像银行账户一样。

避免数据竞态的第二种方法是避免从多个goroutines访问变量。这是上一章中许多程序所采用的方法。例如,并发网络爬虫(§8.6)中的main goroutine是唯一一个访问seen所对应的map的goroutine,还有在聊天服务器(§8.10)中,运行broadcaster函数的goroutine是唯一一个访问clients这个map的goroutine。这些变量被限制于仅被一个单独的goroutine访问。

由于其他goroutine不能直接访问该变量,它们必须使用一个Channel向受限的goroutine发送一个请求来查询或更新变量。这就是Go经典的口头禅“不要通过共享记忆来通信;取而代之,通过通信来共享内存“的意思。通过使用Channel请求,代理了对受限变量的访问,这样的goroutine被称为该变量的monitor goroutine。例如,运行broadcaster函数的goroutine,监视对clients所对应的map的访问。
下面是重写的银行示例,balance变量被限制于仅被称为teller的监视器例程访问。

// Package bank provides a concurrency-safe bank with one account.
package bank
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
}

即使变量不能在其整个生命周期都内被限制仅在单个goroutine中访问,限制访问仍然可能是并发访问问题的解决方案。例如,通过Channel将变量的地址从一个阶段传递到下一个阶段,这是在处于Pipeline中的goroutines之间共享变量的很常见的方式。如果Pipeline的每个阶段在将变量发送到下一个阶段后都不访问该变量,那么对该变量的所有访问都是串行/顺序的。实际上,变量首先被限制在管道的一个阶段,然后又被限制在另一个阶段,以此类推。这种纪律有时被称为连环监禁【 serial confinement.】
让我们看下面的例子,Cakes就是连环监禁,他首先被限制于执行baker函数的goroutione,然后是执行icer函数的goroutine:

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
}
}

第三种避免数据竞态的方式,是可以允许多个goroutine访问变量,但是一次只允许一个访问。这种方法也被称为互斥【 mutual exclusion】,下一节讲。

9.2 Mutual Exclusion: sync.Mutex

在8.6节,我们使用一个带缓冲的Channel作为一个计数信号量[counting semaphore],来保证不会有超过20个goroutine同时发起HTTP请求。同样的道理,我们可以使用一个容量为1的Channel,来保证不会同时有超过1个的goroutine去访问共享变量。计数仅为1的信号量也被称为二元信号量【binary semaphore】。

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(也称为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访问银行系统中的变量时(我们例子中仅指的是balance),它必须调用mutex的Lock方法,来获取一个独有排他的锁。如果有其他goroutine已经获取了锁,那么这个操作将会阻塞,直到其他的goroutine调用Unlock释放了锁为止。互斥锁保护共享变量。按照惯例,由互斥锁保护的变量在互斥锁本身声明之后应该立即声明(如上例中的互斥锁mu和共享变量balance)。如果你偏离违背了这一点,一定要记录下来。

在Lock和Unlock之间的代码区域中,goroutine可以自由地读取和修改共享变量,称为临界区【critical section】。在其他goroutine能够自由的获得锁之前,当前锁持有者必须调用Unlock。当goroutine结束后,释放锁是它必须要做的事,无论函数执行是够成功。

上面的银行程序演示了一种常见的并发模式。一组导出的函数封装了一个或多个变量,因此访问变量的唯一方法是通过这些函数(或方法)。每一个函数会在开始执行时,获取一个互斥所,并在函数结束时释放这个锁,因此可以保证共享的变量可以并发的访问。这种函数、互斥锁和变量的排列组合方式称为监视器。(我们在监视器例程【monitor goroutine】中也提到了monitor这个词。这两种方法都使用了代理,以确保变量被按顺序访问。)
因为Deposit函数和Balance函数中临界区太小了-----只有一行,也没有分支-----我们可以在最后直接了当的调用Unlock。在很多复杂的临界区中,特别是那些必须通过提前返回来处理错误的情况下,很难分辨所有情况下,加锁与释放锁是否都成对执行的。Go的延迟声明来拯救:通过延迟对Unlock的调用,临界区隐式地扩展到了当前函数的末尾,这样我们就不用记得在一个或多个远离Lock调用的地方插入Unlock调用了.

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

在上面的例子,Unlock会在return语句读取了balance的值之后执行,因此Balance函数是并发安全的。另外,我们不再需要局部变量b了。

此外,即使临界区内发生了恐慌,延迟Unlock也可以保证被运行,这对于使用recover(§5.10)的程序至关重要。defer的执行成本比显式调用Unlock要稍微昂贵一些,但这不足以成为代码不够清晰的佐证。与其他并发程序一样,要优先支持清晰性,避免过早的优化。在可能的情况下,使用defer,并让临界区扩展到函数的末尾。
让我们来考虑下面的withdraw函数。当成功时,他会将余额减少指定的数额,并返回true,但如果账户没有足够的资金进行交易,则恢复余额并返回false。

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

这个函数最终给出了正确的结果,但是它有一个令人讨厌的副作用。当试图超额提款时,余额会暂时地降至零以下。而这可能会导致并发发起的一笔取款被拒绝(因为此时余额小于0)。所以,如果鲍勃想买一辆跑车,爱丽丝就付不起她的咖啡钱。问题是,提款操作不是原子操作:它由三个独立的操作序列组成,每个操作序列都会获取并释放这个互斥锁,但没有什么能锁住整个执行序列。
理想情况下,Withdraw应该在整个操作中只获取互斥锁一次。但是这种尝试没有用:

// 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
}

Deposit试图通过调用mu.Lock()再次获得互斥锁,但是由于互斥锁不是可重入的,因此不可能锁定已经锁定的互斥锁,这会导致死锁,而无法继续进行任何操作,而Withdraw会永远阻塞。
Go中的互斥锁不具有可重入是有原因的。互斥的目的是确保共享变量的某些不变量
互斥锁的目的是确保共享变量的特定不变量(invariants)在程序执行的临界点得到维护。其中一个不变量就是“没有goroutine正在访问共享的变量”,但是对于互斥锁所保护的数据结构,可能会有额外的不变量(invariants)。当goroutine获得互斥锁时,它可能假设这些不变量是满足的。当它持有锁时,它可能会更新共享变量,这样可能会临时违反不变量。但是,当它释放锁时,它必须保证秩序已经恢复,并且不变量再次满足。一个可重入互斥锁将确保没有其他goroutine访问共享变量,但它不能保护这些变量的其他不变量。

一种常见的解决方案是将一个函数(如Deposit)划分为两个:一个未导出的函数,deposit,该函数假定已经持有了锁,只执行实际的工作,另一个是一个导出的函数,Deposit,即在调用deposit之前获取锁。这样我们就可以用存款的方式来表示取款:

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 }

当然,这里显示的存款(Deposit)函数非常简单,一个实际的取款(Withdraw)函数不会麻烦地调用它,但它说明了这个原则。
封装(§6.6),通过减少程序中非预期的交互,帮助我们维护数据结构不变量。出于同样的原因,封装也可以帮助我们维护并发不变量。当您使用互斥时,请确保它和它保护的变量都没有导出,无论它们是包级别的变量还是结构体的属性字段。

9.3 读写互斥锁:sync.RWMutex

在看到自己的100美元存款消失得无影无踪后,鲍勃感到一阵焦虑,他写了一个程序,每秒数百次检查自己的银行存款余额。他在家里、单位里、手机上运行它的程序。银行观察到快速增长的业务请求正在拖慢存款与借款操作,因为所有的Balance请求都是串行运行的,持有互斥锁,并暂时妨碍了其他的goroutine执行。
因为Balance函数只需读取变量的状态即可,所以多个Balance请求实际上可以安全的并发运行,只要Deposit和Withdraw请求没有同时运行即可。在这种场景下,我们需要一个特殊类型的锁,来允许只读操作彼此并行进行,但是写操作具有完全独占的访问权。这种锁也被称为多读单写锁【multiple readers, single writer】。Go中的sunv.RWMutex提供了这种功能。

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

Balanace函数现在通过吊桶RLock来获取读锁,通过RUnlock来释放读锁(读锁也称为共享锁)。Deposit函数无需更改,它通过调用mu.Lock和mu.Unlock来分别获取和释放写锁(写锁也称为互斥锁)。
经过修改后,Bob的绝大多数请求都会并行的运行,且可以很快的完成。锁可以在更多的时间内使用,并且Deposit(存款)请求可以及时的得到响应。

只有在临界区内没有对共享变量做写操作时,才能使用RLock。通常,我们不应该假设逻辑上只读的函数或方法不会更新一些变量。例如,一个看似简单的访问器的方法可能还会递增一个内部使用计数器,或者更新一个缓存,使重复调用更快(如记账单例模式)。如果有疑问,使用独占锁。

仅在绝大部分goroutine都是获取读锁,并且锁竞争比较激烈时(即goroutine一版都需要等待才会获取到锁),RWMutex才有优势。因为RWMutex需要更复杂的内部簿记工作,所以在竞争不激烈时他比普通的互斥锁要慢。

9.4 内存同步

您可能想知道为什么Balance方法需要基于Channel或基于互斥锁的互斥。毕竟,与Deposit不同的是,它只包含一个操作,所以不存在另一个goroutine在“中间”执行的危险。我们需要互斥锁有两个原因。首先,防止Balance函数插入到其他其他操作(如Withdraw)的“中间”,这也是很重要的。第二个(也是更微妙的)原因是同步不仅涉及多个goroutine的执行顺序;同步也会影响内存。

在现代计算机上,可能有多个处理器,每一个处理器都有其自己关于主存的本地缓存。为了提高效率,对内存的写操作会缓存在每个处理器中,并仅在必要时将其刷新到主存。甚至刷回主存的顺序都可能与goroutine的写入顺序不一致 。像Channel通信和互斥锁操作这样的同步原语会导致处理器刷新并提交所有累积的写操作,从而保证在那一点上运行的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

第四行可以是A1 B1 A2 B2或者是B1 A1 A2 B2这样的执行顺序。无论如何,程序产生的如下两个输出就出乎意料了:

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

但是在某些编译器,CPU或者其他因素的条件下,这是可能发生的。这四种语句以什么杨的顺讯交错,才可以解释这个结果呢?

在单个goroutine中,可以保证每个语句的作用/效果【effect】按执行顺序发生;也就是说,goroutines是串行一致【 sequentially consistent】的。但是,如果没有使用Channel或互斥锁的方式进行显式同步,就不能保证所有goroutines以相同的顺序看到事件。虽然A goroutine在读取y值之前必然会观察到 x = 1这个写入操作的效果,但它并不一定观察到B goroutine对y的写入作用效果,所以A可能打印处一个陈旧的y值。

尽管很容易把并发简单的理解为多个goroutine中语句的某种交错执行方式,但是正如上面的例子所示,这并不是现代编译器和CPU的工作方式。因为复制语句和Print调用都使用了相同的变量,所以编译器就可能会认为两个语句的执行顺序不会影响结果,然后就叫唤了两个语句的执行顺序。如果这两个goroutine运行在不同的CPUs上,而每一个CPUs都有自己私有的缓冲区,那么一个goroutine的写入才做在同步到内存之前,对其他goroutine上的Print语句是不可见的。
通过一致地使用简单的、成熟的模式,可以避免所有这些并发问题。在可能的情况下,将变量限制到单个goroutine中;对于所有其他变量,使用互斥。

9.5 延迟初始化 sync.Once

将昂贵的初始化步骤推迟到需要的时候,这是一个很好的实践。预初始化变量会增加程序的启动延迟,如果使用该变量的程序部分总不会被执行,那么就没有必要这样做。让我们回到我们在前面章节中看到的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]
}

对那那些只被单线程访问的变量,我们可以安全的使用上面的模式,但是如果Icon函数被并发调用,那么怎么使用则是不安全的。像银行系统中原始的Deposit函数一样,Icon函数也是由许多步骤组成的:它首先测试icons变量是否为nil,如果是nil,则健在所有的图标,然后更新icons变量为一个非空值。直觉可能会认为,上述竞态条件导致的最糟糕的结果可能是多次调用loadIcons函数。当第一个goroutine正忙于加载图标时,另一个进入Icon函数的goroutine会发现变量仍然等于nil,并且还会调用loadIcons进行图标加载。
但这种直觉也是错误的。(我们希望现在您正在培养一种关于并发性的新直觉,即关于并发性的直觉是不可信的!)回忆一下章节9.4中关于内存的讨论。在缺乏显式同步的情况下,编译器和CPU在能保证每个goroutine都满足串行一致性的基础上,可以自由地重排对内存的访问顺序。下面显示了对loadIcons语句的一种可能的重新排序。在填充它之前,它将空map存储到icons变量中:

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发现icons为非nil,并不意味着变量的初始化已经完成。

确保所有的goroutine都可以观察到loadIcons的效果的正确方式,是使用互斥来进行同步:

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]
}

但是,强制对icons进行互斥访问的代价是,两个goroutines不能并发地访问变量,即使变量已经安全地初始化了,并且永远不会再被修改。这意味着我们可以使用一个多读单写锁来优化:

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
}

这里有两个临界区。goroutine首先获得一个读锁,检索map,然后释放锁。如果从该map中检索到一个条目(常见情况),就返回。如果没有找到条目,goroutine就会获得一个独占写锁。如果不首先释放共享锁,就无法将共享锁升级到独占锁,因此我们必须重新检查icons变量,以防其他的goroutine已经初始化了它。

上面的模式带给我们更好的并发性,但是也引入了复杂度,因此更容易出错。幸运的是,sync包为一次性初始化问题提供专门的解决方案: sync.Once。从概念上讲,Once由互斥量和布尔变量组成,布尔变量用于记录是否进行了初始化;互斥变量则同时保护布尔结构和客户端数据结构。Once的唯一的方法Do接收初始化函数作为它的参数。让我们简化Icon函数:

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)时候都会先锁定互斥量,并检查布尔变量。在第一次调用时。这个值为false,Do会先调用loadIncon然后将布尔变量置为true。后续的调用相当于空调用

后续调的用什么也不做,但是互斥同步器使得loadIcons函数对内存(在这里就是icons)的作用效果变得对所有goroutine可见。通过使用sync.Once来同步,我们可以避免与其他goroutines共享变量,直到它们被正确构造。

9.6 竞态检测器(Race Detector)

简单讲- race 加到 go build , go run ,或者 go test命令上即可开启检测器

9.8 Goroutine和线程

在上一章中,我们说过可以先忽略goroutines和操作系统(OS)线程之间的区别。虽然它们之间的差异本质上是量变的,但一个足够大的量变上会变成了质变,goroutines和threads也是如此。现在是时候区分它们了。

9.8.1 Growable Stacks

每一个OS线程都有一个固定大小的内存块(通常带下是2M)用于它的栈,在该工作区域中可以保存处理中的函数调用或者临时暂停的函数(调用了另一个函数)中的本地变量。这固定大小的栈有时就太大了,有时又太小了。对于一个小的goroutine,2M大小的的栈内存就是很浪费了,比如一个goroutine等待WaitGroup,然后关闭Channel。在Go中一次创建十万左右的goroutine也不罕见,对于这种情况,栈就显得太大了。另外,对于复杂的深度递归函数,固定大小的栈就显得捉襟见肘了。改变栈的大小固定,可以提高空间效率并允许创建更多的线程,或者它可以支持更深层的递归函数,但它不能同时做到这两点。

相比之下,goroutine以一个小的堆栈(通常为2KB)开始它的声明周期。goroutine的堆栈,就像操作系统线程的堆栈一样,持有当前活动函数调用和挂起函数调用的本地变量,但与操作系统线程不同的是,goroutine的堆栈不是固定的;它根据需要增长和收缩。goroutine堆栈的大小限制可能高达1GB,比典型的固定大小的线程堆栈大几个数量级,当然,很少有goroutine使用这么多。

9.8.2 Goroutine调度

OS线程由OS内核调度。每隔几毫秒,一个硬件时钟就会中断处理器,而这会引发一个称为scheduler的内核函数被调用。该函数会暂停当前执行的线程,并将它的寄存器信息保存到内存中,检查线程列表,并决定选择哪一个线程接下来执行,接下来会从内存中加载该线程的寄存器信息,然后唤醒该线程执行。因为OS线程是由内核调度的,所以控制权从一个线程传递到另一个线程的整个过程被称为上下文切换(context switch),简而言之,就是保存一个线程的状态信息进内存,恢复另个线程的状态,然后更新调度器的数据结构。考虑到这个歌操作涉及到内存局限性以及涉及的内存访问数量,这种操作非常缓慢,而且随着访问内存所需的CPU周期数量的增加,这种操作只会变得更糟。

Go Runtime包含一个自己的调度器,它使用称为m:n调度的技术,因为它可以复用m个goroutine到n个OS线程上。Go调度器与OS调度器类似,但是Go的调度器只需要关心单个Go程序中的goroutines即可。

与操作系统的线程调度器不同,Go的调度器并不是由硬件时钟来触发的,而是由一个特定的Go语言结构控制的。例如,当一个Goroutine调用time.Sleep或者阻塞在Channel或互斥操作上时,调度器会将这个goroutine置为休眠(sleep),并运行其他的goroutine,直到前一个可唤醒为止。因为不需要内核上下文切换,调度一个goroutine,也要比线程调度来的更廉价。

9.8.3. GOMAXPROCS

Go使用一个称为GOMAXPROCS的参数,来决定使用多少个OS线程来同时执行Go代码。默认值是机器的CPU核数。因此在一个有8 CPUs的机器上,调度器会将Go代码同时调度到8个OS线程上执行( GOMAXPROCS是m:n中的n)。休眠(Sleeping)或者在通信中阻塞的goroutine并不需要占用一个线程。阻塞在I/O或者其他系统调用中的goroutine,或者是调用非Go编写的函数的goroutine,这样的Goroutines需要一个单独的OS线程,但是这个线程并不在GOMAXPROCS中。
我们可以使用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调度器让它进入休眠状态,并唤醒打印0的goroutine,让它在OS线程上运行。在第二次运行中,有两个操作系统线程可用,因此两个goroutines可以同时运行,以相同的速度打印数字。我们必须强调,许多因素都影响到goroutine调度,运行时是不断演进的,所以您的结果可能与上面的结果不同。

9.8.4. Goroutines Have No Identity

在大部分的操作系统和支持线程的编程语言中,当前线程有一个可区别的标识,它通常可取一个数值或者指针。这使得我们可以很轻松的实现一个称谓线程本地存储【Thread-Local Storage 】的抽象,它本质上是一个全局的map,以线程的标识作为key,这样每个线程都可以独立的使用这个map读取和存储值,而不受其他线程的影响。
goroutine没有可供程序员访问的标识。这是由于涉及而决定的,因为线程本地存储有被滥用的倾向。例如,在使用线程本地存储实现的web服务器中,许多函数通过查找该存储来查找关于HTTP请求的信息是很常见的。但就像那些过度依赖于全局变量的程序一样,这会导致一种不健康的“超距行为”,即函数的行为并不由参数决定,而是由返回的线程的标识。因此,如果线程的标识需要可改----比如需要使用工作线程【worker thread】来帮忙----这些函数的行为就会变得诡秘。
Go鼓励一种更简单的编程风格,其中能影响函数行为的参数必须是显式的。这不仅使程序更易于阅读,而且让我们可以自由地将给定函数的子任务分配给多个不同的goroutines,而不必担心这些goroutine的标识。。

猜你喜欢

转载自blog.csdn.net/qq_31179577/article/details/83653081