golang学习随便记13
Posted sjg20010414
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了golang学习随便记13相关的知识,希望对你有一定的参考价值。
基于共享变量的并发
用 goroutine 和 channel 来实现并发,的确比较自然,但是,用 channel 传递数据,只是实现了数据在发送方和接收方的“共享”,它基本上类似于事件通信机制,共享的数据是事件参数。但在并发中,我们还常常不得不面对更复杂的情况,使得我们仍然需要使用基于共享变量的并发控制技术。
竞争条件
书中用来描述数据竞争的是只有一个账户的存钱模型。可用操作如下
// 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
并发的两笔交易:Alice存入200美刀(步骤A1),然后看余额(步骤A2);Bob存入100美刀(步骤B)
// Alice:
go func()
bank.Deposit(200) // A1
fmt.Println("=", bank.Balance()) // A2
()
// Bob:
go bank.Deposit(100) // B
直观理解,A1、A2、B的顺序只有3种可能:A1-A2-B,B-A1-A2,A1-B-A2,这3种可能中,虽然Alice查看到的余额可能是200或者300,但最后的余额都是300,交易结果正确(和实际资产符合)。
但并发的复杂在于,语句 balance = balance + amount 本身是不能保证它工作的原子性的!这个语句包含了两个操作,计算 balance + amount 和将前述结果赋值给 balance,因为前一个操作是不修改任何内存单元的,所以,认为它是一个“读取操作”,后一个操作对应称为“写入操作”,这样,我们可以认为 A1 = A1r + A1w,B = Br + Bw,如果实际执行步骤为 A1r - Br - Bw - A1w - A2
就会出现 Bob 的存钱操作“无效”,他修改了余额之后,余额马上被更早开始计算的 Alice 的待改余额覆盖掉了,最后,银行账户实际资产比显示的余额要多100美刀。
并发编程的难度在于,上面描述的这种问题,实际测试时很难出现,因为发生竞争的数据类型很小(只有一个机器字长),当数据类型变大时,事情就不一样了。即使竞争条件的产生概率不高,我们也要避免数据竞争,因为你不清楚它什么时候产生,而且一旦出现,程序会极难排错和调试,因为很难重现。
数据竞争定义:数据竞争会在两个以上的goroutine并发访问相同的变量且至少其中一个为写操作时发生。
避免数据竞争的方式:
1、除了初始化,不要去写变量。因为初始化通常不会是并发的,后面对变量的使用,只是读取,并发读取并不会有问题。缺点是不是所有情形都能如此(后续不需要update)
2、避免从多个goroutine访问变量。这样,变量的读写控制都在一个goroutine内。其他goroutine如何共享使用变量呢?请求服务!其他goroutine不能直接访问变量,只能用一个 channel来发送请求给有控制权的goroutine来查询更新变量——这符合golang哲学“不要使用共享数据来通信;使用通信来共享数据”。这个有控制权的goroutine,通常叫该变量的 monitor goroutine
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
使用 monitor goroutine 后,存钱操作改成向 deposits 这个 channel 发送存款金额,查询余额则是从 balances 这个 channel 接收数据。teller 是 monitor goroutine,变量 balance 限定在 teller 函数范围内,teller 中 for 循环死循环运行一个 select multiplex,第二个 case 向 balances channel 发送当时余额后会阻塞,除非 调用了 Balance() 查询余额接收掉了值,第一个case在 deposits channel 没有数据时阻塞,除非 调用了 Deposit(amount) 存入了钱(向 deposits channel 发送了金额), 该 case 代码会接收这个值并加到 balance上——select multiplex 的 case 分支同一个时间点只会有一个 case 在运行。
有时候,可能无法做到一个变量在全部生命周期内绑定到一个独立的goroutine,但如果可以做到每个阶段都能避免在将变量传送(通常用channel传递地址信息)到下一阶段后再去访问它,那么对这个变量的所有访问就是线性的,变量就可以绑定到流水线的一个阶段后,绑定到下一个阶段,这种用法称为串行绑定。
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
上面的代码中,baker 函数的参数是 一个发送类型的 channel,发送的值类型是 Cake类型值的地址,icer 函数的第二个参数则是一个接收类型的 channel,接收的值类型也是 Cake类型值的地址。baker 函数中一旦发送了 那个 cake (地址),就不会再有任何对该 cake 的访问,它的控制权会移交给接收方 icer函数,icer函数内部也类似。
3、允许多个goroutine访问变量,但是用一定机制确保同一个时刻最多只有一个goroutine在访问,即“互斥”。
sync.Mutex 互斥锁
我们可以用一个内部队列长度为1的缓冲 channel 作为计数信号量,从而确保最多只有一个goroutine在同一个时刻访问共享的变量——一个只能为1和0的信号量称为二元信号量(binary semaphore)。
var (
sema = make(chan struct, 1) // a binary semaphore guarding balance
balance int
)
func Deposit(amount int)
sema <- struct // acquire token 竖旗,Deposit在操作
balance = balance + amount
<-sema // release token 撤旗
func Balance() int
sema <- struct // acquire token 竖旗,Balance在操作
b := balance
<-sema // release token 撤旗
return b
上面的代码中, sema <- struct 向 sema channel 发送一个信号后,另一个 sema <- struct 就会阻塞,因为 队列长只有1,必须等到 <-sema 接收掉这个信号,另一个 sema <- struct 才能继续。这种互斥很实用,sync 包里的 Mutex类型提供了语义更清晰的支持。
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
换成 sync.Mutex 类型变量 mu 的 Lock 和 Unlock 操作,看上去清爽多了。加互斥锁后,操作就进入“独占访问”,另一个加锁操作会被阻塞,直到前一个解锁,确保了 Lock 和 Unlock 之间操作的原子性(Lock和Unlock之间的代码段,称为 critical section,有翻译成临界区,关键段之类的)。
使用互斥体的并发访问,其实也是用一个代理确保变量被顺序访问:对变量 balance 的访问,不是直接访问,而是通过一系列函数进行的,这些函数在一开始获取互斥锁,最后再释放,这些函数、互斥锁和变量的编排叫做 monitor
在编程规范上,被互斥保护的变量在互斥体声明之后立刻声明,并加以注释说明。而且,应该只对确实需要加锁的部分加锁,并且一定要记得解锁,即使是在错误处理的路径上。当函数比较复杂时,记得解锁并不是那么容易,golang 的 defer 大法能解救我们。
func Balance() int
mu.Lock()
defer mu.Unlock()
return balance
看,中间变量 b 没有了!defer魔法可以确保return balance 在读取 balance值之后,函数返回之前 mu.Unlock() 得到调用。即使 Balance 函数内发生了 panic,deferred Unlock 也会得到执行。使用 deferred Unlock 的代价是有可能锁定了稍长时间,好处是代码整洁——不要写太长的函数,把功能切割成小一点的函数可以缓解锁定成本。对于并发程序,代码的整洁性比过度优化更重要。
golang的互斥锁是不可重入的!看下面的取款函数
func Withdraw(amount int) bool
Deposit(-amount)
if Balance() < 0
Deposit(amount)
return false // insufficient funds
return true
这个取款(或付款)函数不是原子操作(尽管它内部的 Deposit 、Balance 都是原子操作),当过多的取款操作同时执行时,会发生不符合直觉逻辑的情况:Alice为咖啡付款时,Deposit(-amount)执行完,她本来是可以期待 Balance() 返回值大于等于0的,但在 if 之前Deposit(-amount)之后,Bob尝试了买一辆跑车,他的Deposit(-amount)瞬间把余额减成负数,造成Alice连咖啡钱都付不了(需要第二次尝试才能成功)。我们希望整个取款操作是原子的,但下面这样做是错误的
// 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
因为golang互斥锁不可重入,mu.Lock() 后,Deposit(-amount) 内部会尝试再次加锁,发现已经加锁就会阻塞,造成死锁(永远阻塞)。
一个通用的解决方案是将一个函数分离为多个函数,比如,把Deposit拆分成两个:一个不导出的 deposit,它不考虑锁的事,做具体事情,并且包内私有;另一个导出的Deposit,它会调用deposit,同时应用锁。这样,Withdraw 函数就好写了。
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
mutex 和 它保护的变量,都不会导出,不带锁做具体事情的 deposit 函数也不导出,它们都“躲”(封装)在包内部(包内变量或一个struct字段),限制了外部程序对这些变量的意外交互。而互斥锁机制,在包内导出的函数内部工作,让包的使用者可以了解包的并发安全性,同时不需要自己去操心并发安全性,这是包设计者的职责。
sync.RWMutex读写锁
对所有的查询余额也加一下锁,一旦Bob查询太过于密集,会对Alice实际的存款和取款操作也产生延时。实际的业务中,往往查询远多余更新等写入操作,所以,我们需要一种允许多个读取者并行执行,但写操作互斥的锁,这种锁称为多读单写锁(multiple readers,single writer lock),golang提供了 sync.RWMutex (我们用两个 buffered channels 也能模拟它,一个长度为1,另一个足够长即可)。我们将 mu 声明为 sync.RWMutex 锁,并且改写一下 Balance函数
var mu sync.RWMutex
var balance int
func Balance() int
mu.RLock() // readers lock
defer mu.RUnlock()
return balance
注意,对于读取加锁、解锁是 RLock、RUnlock,对于写入加锁、解锁还是 Lock、Unlock。
改写后,Bob密集查询请求可以并行执行并很快完成,总体上看Alice的存款请求能更快得到响应了。
RLock和RUnlock只有在它们包围的critical section内部没有任何写入操作时可用。我们不能凭直觉假设某些“只读”函数或方法不会去更新一些变量。比如一个方法功能是访问一个变量,但它可能会同时去更新内部的计数器+1(例如记录访问次数),或者去更新缓存。如果有疑惑,就不应该使用RLock和RUnlock,换成 Lock 和 Unlock。
不该想当然用 RWMutex 代替所有场合,只有当获得锁的大部分goroutine都是读操作,而锁在竞争条件下(有写入的goroutine在执行原子操作,这些读goroutines必须等待才能获取到锁),RWMutex才是最能带来好处的(一旦writer解锁,所有readers可以快速得到响应)。RWMutex需要更复杂的内部记录,用它比一般的互斥锁慢一些,所以,没有多读单写用它会更慢。
内存同步
前面的 Balance 内部用 RLock 和 RUnlock,我只想到了第一种意图:确保 Balance 读取余额时,不会是 Withdraw 那样的取款操作的“中间”。第二种更重要的意图是内存同步问题。
现代计算机可能会有一堆处理器(多核、多CPU),每一个都会有它的本地缓存(local cache)。为了效率,对内存的写入一般会在每个CPU自己的cache中缓冲,在必要的时候才flush到主内存。这种情况下,数据可能会以与当初goroutine写入顺序不同的顺序被提交到主内存。像 channel通信或者 互斥体操作这样的原语会使处理器将其集聚的写入flush并commit(到主内存),这样goroutine在某个时间点上的执行结果才能被其它处理器上运行的goroutine得到。
var x, y int
go func()
x = 1 // A1 可能只是对 cache 中的 x' 进行了修改
fmt.Print("y:", y, " ") // A2
()
go func()
y = 1 // B1 可能只是修改 cache 中 y'
fmt.Print("x:", x, " ") // B2
()
上面的代码中,出来的结果除了可能以下4种之一
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内,语句的执行顺序是可以保证的,但不使用 channel 或者 mutex 这样的显式同步操作时,我们无法保证事件在不同的goroutine中看到的执行顺序一致。尽管 routine A 中要 x=1 后才会打印 y 的值,但是 A 无法观察到 B 对 y 的写入操作 y=1,从而可能打印出旧的 y值,同样,B中可能打印旧的x值。
用 cache 来理解就是,A中修改了local cache 中的 x' 为 1,然后打印了主内存中y的值0,B中修改了local cache 中的 y' 为 1,然后打印了主内存中x的值为0,最后,x'的值 flush并commit到主内存x,y'的值flush并commit到主内存y,使得最后主内存中x和y都为1
要避免这种内存不一致问题,确保内存同步,可以遵循一定的规则来避免:将变量限定在goroutine内部;如果是多个goroutine都需要访问的变量,使用互斥条件来访问(包括 channel机制或者 mutex)。
sync.Once惰性初始化
前面说,避免数据竞争可以除了初始化,不要去写入变量,但有时候,一次性初始化成本比较大(增加启动时间,如果后续从来没有用到,产生浪费),将初始化延迟到需要时再做更好(需要考虑互斥同步)。
下面,icons变量表示所有图标,它是一个map,每个元素键为图标名称,值为图片,loadIcon返回指定名称的图片,loadIcons则初始化icons,Icon根据图标名称返回图片,使用了惰性初始化(lazy initialization,发现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]
当多个 goroutine 一起工作时,编译器和CPU只能保证每个 goroutine 按自己的执行顺序来,其他可以随意更改访问内存的指令顺序,从而,loadIcons 执行效果可能和下面的代码等价
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 已经开始执行 loadIcons 进行初始化了,但它执行到 上述 icons = make(map[string]image.Image)语句 (空的map,零值nil),此时另一个 goroutine 正好在做 if icons == nil 判断,结果是重复执行 loadIcons。这个例子似乎还不算什么严重后果,但这种情况是不应该存在的。
最简单且正确的保证所有 goroutine 能够观察到 loadIcons 效果的方式,是 用 mutex 进行同步检查(用互斥访问保障懒初始化的并发安全性)。(注意mu最后的作用说明注释)
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]
但使用 sync.Mutex 锁会导致无法并发访问 icons (一个 Icon函数在获取图标时,另一个不能获取),因为这个场合很可能已经一次性初始化完毕了,属于写少读多的情形,所以,应该用 sync.RWMutex 来提升性能。
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
上面的代码中,先处理更有可能出现的情形,以便快速返回结果。后面的半段代码中,不能想当然认为前面已经判断了 icons != nil,这里就一定是 icons == nil 了,因为要考虑到此时此刻,也许另一个 goroutine 已经在mu.RUnlock()之后,mu.Lock()之前初始化了icons。
因为这种一次性初始化,但懒初始化的场合比较常见,所以,sync包直接提供了 sync.Once:它的作用相当于一个互斥体mutex和一个记录初始化是否完成的boolean变量,mutex保护boolean变量和客户端数据结构。sync.Once对象只有Do()这个唯一的方法,用初始化函数作为它的参数。
var loadIconsOnce sync.Once
var icons map[string]image.Image
// Concurrency-safe.
func Icon(name string) image.Image
loadIconsOnce.Do(loadIcons)
return icons[name]
上面的代码,可以理解为loadIconsOnce内部存在一个boolean变量,可以识别 loadIcons 有没有被执行过(false=未执行过,true=已经执行过),没有就执行 loadIcons 填充 icons变量,否则直接跳到下一语句。loadIconsOnce保护loadIcons函数的执行是互斥的(其实这里就是只有一个loadIcons会得到调用),同时保证loadIcons对内存的影响(这里是icons变量的改变)是所有goroutine可见的(一致的)。用 sync.Once,可以避免变量在构建完成之前和其它goroutine共享该变量。
竞争条件检测
golang的 runtime 和工具链,提供了动态分析工具:竞争监测器(the race detector)
go build,go run 或者 go test 命令后面加上 -race 选项,就会创建特别的版本,附带了能够记录所有运行期对共享变量访问工具的test,并且会记录下每一个读或者写共享变量的goroutine的身份信息,还会记录下所有的同步事件(go语句,channel操作,对 (*sync.Mutex).Lock, (*sync.WaitGroup).Wait的调用)。
这个工具会打印一份报告,包含已经发生的数据竞争,但它只能监测到运行时的竞争条件,并不能证明之后不会发生数据竞争,所以,只有测试并发实现了覆盖才能使结果可靠。
使用这个特别版本会跑起来慢一点,且需要更大的内存,但很多生产环境,让程序应对偶发的竞争,损失这点性能是可以接受的。
这个竞争报告的例子是后一节:并发的非阻塞缓存 附带的,稍微有点复杂(主要是代码有点多),这里暂时略过
goroutine和线程
我们说goroutine是逻辑线程,和操作系统线程主要是量的区别,不过这个量变可以引起质变。
每个操作系统线程都有一个固定大小的内存块(一般是2MB)来做栈,这个栈会用来存储当前正在被调用或挂起的函数的内部变量。这玩意就是Windows里面的TLS(线程本地存储,或者称为线程局部存储)。这个2MB大小可以认为既很大又很小,它对于小小的goroutine来说就是浪费,就会限制我们创建成百上千个goroutine,同时,真的碰上复杂的或者递归层次很深的函数,2MB又显然不够(需要人为考虑放到堆上去)。
golang为了突破这个限制,相当于自己加了一层,它会为一个goroutine初始配置一个很小的栈,一般只需要2KB(2MB的千分之一),操作系统线程的挂起或恢复,是操作系统调度的(包括对相应变量的处理),而 goroutine 是 自己的调度器调度的。另外,goroutine的栈并不是固定的,栈大小会根据需要动态伸缩,最大值可以1GB(2MB的500倍)。
操作系统的线程切换,因为涉及线程上下文切换(涉及操作系统内核的工作),代价是比较高的,go运行时包含了自己的调度器,使用了 m:n 调度(m个goroutine由n个操作系统线程多工调度),而且goroutine之间的切换不需要进入内核的上下文,切换代价小得多。
上面的n值,对应go调度器的GOMAXPROCS变量,它默认等于运行机器上的CPU核心数。休眠的或者通信中被阻塞的goroutine是不需要对应的线程来调度的。在 I/O中或系统调用中或调用非Go语言函数时,需要一个对应的操作系统线程的,但 GOMAXPROCS 不把这几种情况计算在内。执行go程序时,可以用 GOMAXPROCS 环境变量显式控制该参数。
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没有ID号。线程有ID号,操作系统可以根据ID号跟踪线程,也可以根据ID找到它的TLS,而 TLS 总是会被滥用,一些函数会到 TLS 去寻找信息,从而函数行为不完全有它的参数“静态地”决定,和运行时的线程也有关系。golang通过不给goroutine分配ID号(其实应该是不向外部暴露这个信息),刻意杜绝了这种行为。
以上是关于golang学习随便记13的主要内容,如果未能解决你的问题,请参考以下文章