mirror of
https://github.com/cheetahlou/CategoryResourceRepost.git
synced 2025-11-16 14:13:46 +08:00
del
This commit is contained in:
584
极客时间专栏/geek/Go 并发编程实战课/Channel/13 | Channel:另辟蹊径,解决并发问题.md
Normal file
584
极客时间专栏/geek/Go 并发编程实战课/Channel/13 | Channel:另辟蹊径,解决并发问题.md
Normal file
@@ -0,0 +1,584 @@
|
||||
<audio id="audio" title="13 | Channel:另辟蹊径,解决并发问题" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/44/d6/4426613dd4a1bb429yyyy3035107a2d6.mp3"></audio>
|
||||
|
||||
你好,我是鸟窝。
|
||||
|
||||
Channel是Go语言内建的first-class类型,也是Go语言与众不同的特性之一。Go语言的Channel设计精巧简单,以至于也有人用其它语言编写了类似Go风格的Channel库,比如[docker/libchan](https://github.com/docker/libchan)、[tylertreat/chan](https://github.com/tylertreat/chan),但是并不像Go语言一样把Channel内置到了语言规范中。从这一点,你也可以看出来,Channel的地位在编程语言中的地位之高,比较罕见。
|
||||
|
||||
所以,这节课,我们就来学习下Channel。
|
||||
|
||||
# Channel的发展
|
||||
|
||||
要想了解Channel这种Go编程语言中的特有的数据结构,我们要追溯到CSP模型,学习一下它的历史,以及它对Go创始人设计Channel类型的影响。
|
||||
|
||||
CSP是Communicating Sequential Process 的简称,中文直译为通信顺序进程,或者叫做交换信息的循序进程,是用来描述并发系统中进行交互的一种模式。
|
||||
|
||||
CSP最早出现于计算机科学家Tony Hoare 在1978年发表的[论文](https://www.cs.cmu.edu/~crary/819-f09/Hoare78.pdf)中(你可能不熟悉Tony Hoare这个名字,但是你一定很熟悉排序算法中的Quicksort算法,他就是Quicksort算法的作者,图灵奖的获得者)。最初,论文中提出的CSP版本在本质上不是一种进程演算,而是一种并发编程语言,但之后又经过了一系列的改进,最终发展并精炼出CSP的理论。**CSP允许使用进程组件来描述系统,它们独立运行,并且只通过消息传递的方式通信。**
|
||||
|
||||
就像Go的创始人之一Rob Pike所说的:“每一个计算机程序员都应该读一读Tony Hoare 1978年的关于CSP的论文。”他和Ken Thompson在设计Go语言的时候也深受此论文的影响,并将CSP理论真正应用于语言本身(Russ Cox专门写了一篇文章记录这个[历史](https://swtch.com/~rsc/thread/)),通过引入Channel这个新的类型,来实现CSP的思想。
|
||||
|
||||
**Channel类型是Go语言内置的类型,你无需引入某个包,就能使用它**。虽然Go也提供了传统的并发原语,但是它们都是通过库的方式提供的,你必须要引入sync包或者atomic包才能使用它们,而Channel就不一样了,它是内置类型,使用起来非常方便。
|
||||
|
||||
Channel和Go的另一个独特的特性goroutine一起为并发编程提供了优雅的、便利的、与传统并发控制不同的方案,并演化出很多并发模式。接下来,我们就来看一看Channel的应用场景。
|
||||
|
||||
# Channel的应用场景
|
||||
|
||||
首先,我想先带你看一条Go语言中流传很广的谚语:
|
||||
|
||||
>
|
||||
Don’t communicate by sharing memory, share memory by communicating.
|
||||
|
||||
|
||||
>
|
||||
Go Proverbs by Rob Pike
|
||||
|
||||
|
||||
这是Rob Pike在2015年的一次Gopher会议中提到的一句话,虽然有一点绕,但也指出了使用Go语言的哲学,我尝试着来翻译一下:“**执行业务处理的goroutine不要通过共享内存的方式通信,而是要通过Channel通信的方式分享数据。**”
|
||||
|
||||
“communicate by sharing memory”和“share memory by communicating”是两种不同的并发处理模式。“communicate by sharing memory”是传统的并发编程处理方式,就是指,共享的数据需要用锁进行保护,goroutine需要获取到锁,才能并发访问数据。
|
||||
|
||||
“share memory by communicating”则是类似于CSP模型的方式,通过通信的方式,一个goroutine可以把数据的“所有权”交给另外一个goroutine(虽然Go中没有“所有权”的概念,但是从逻辑上说,你可以把它理解为是所有权的转移)。
|
||||
|
||||
从Channel的历史和设计哲学上,我们就可以了解到,Channel类型和基本并发原语是有竞争关系的,它应用于并发场景,涉及到goroutine之间的通讯,可以提供并发的保护,等等。
|
||||
|
||||
综合起来,我把Channel的应用场景分为五种类型。这里你先有个印象,这样你可以有目的地去学习Channel的基本原理。下节课我会借助具体的例子,来带你掌握这几种类型。
|
||||
|
||||
1. **数据交流**:当作并发的buffer或者queue,解决生产者-消费者问题。多个goroutine可以并发当作生产者(Producer)和消费者(Consumer)。
|
||||
1. **数据传递**:一个goroutine将数据交给另一个goroutine,相当于把数据的拥有权(引用)托付出去。
|
||||
1. **信号通知**:一个goroutine可以将信号(closing、closed、data ready等)传递给另一个或者另一组goroutine 。
|
||||
1. **任务编排**:可以让一组goroutine按照一定的顺序并发或者串行的执行,这就是编排的功能。
|
||||
1. **锁**:利用Channel也可以实现互斥锁的机制。
|
||||
|
||||
下面,我们来具体学习下Channel的基本用法。
|
||||
|
||||
# Channel基本用法
|
||||
|
||||
你可以往Channel中发送数据,也可以从Channel中接收数据,所以,Channel类型(为了说起来方便,我们下面都把Channel叫做chan)分为**只能接收**、**只能发送**、**既可以接收又可以发送**三种类型。下面是它的语法定义:
|
||||
|
||||
```
|
||||
ChannelType = ( "chan" | "chan" "<-" | "<-" "chan" ) ElementType .
|
||||
|
||||
```
|
||||
|
||||
相应地,Channel的正确语法如下:
|
||||
|
||||
```
|
||||
chan string // 可以发送接收string
|
||||
chan<- struct{} // 只能发送struct{}
|
||||
<-chan int // 只能从chan接收int
|
||||
|
||||
```
|
||||
|
||||
我们把既能接收又能发送的chan叫做双向的chan,把只能发送和只能接收的chan叫做单向的chan。其中,“<-”表示单向的chan,如果你记不住,我告诉你一个简便的方法:**这个箭头总是射向左边的,元素类型总在最右边。如果箭头指向chan,就表示可以往chan中塞数据;如果箭头远离chan,就表示chan会往外吐数据**。
|
||||
|
||||
chan中的元素是任意的类型,所以也可能是chan类型,我来举个例子,比如下面的chan类型也是合法的:
|
||||
|
||||
```
|
||||
chan<- chan int
|
||||
chan<- <-chan int
|
||||
<-chan <-chan int
|
||||
chan (<-chan int)
|
||||
|
||||
```
|
||||
|
||||
可是,怎么判定箭头符号属于哪个chan呢?其实,“<-”有个规则,总是尽量和左边的chan结合(The `<-` operator associates with the leftmost `chan` possible:),因此,上面的定义和下面的使用括号的划分是一样的:
|
||||
|
||||
```
|
||||
chan<- (chan int) // <- 和第一个chan结合
|
||||
chan<- (<-chan int) // 第一个<-和最左边的chan结合,第二个<-和左边第二个chan结合
|
||||
<-chan (<-chan int) // 第一个<-和最左边的chan结合,第二个<-和左边第二个chan结合
|
||||
chan (<-chan int) // 因为括号的原因,<-和括号内第一个chan结合
|
||||
|
||||
```
|
||||
|
||||
通过make,我们可以初始化一个chan,未初始化的chan的零值是nil。你可以设置它的容量,比如下面的chan的容量是9527,我们把这样的chan叫做buffered chan;如果没有设置,它的容量是0,我们把这样的chan叫做unbuffered chan。
|
||||
|
||||
```
|
||||
make(chan int, 9527)
|
||||
|
||||
```
|
||||
|
||||
如果chan中还有数据,那么,从这个chan接收数据的时候就不会阻塞,如果chan还未满(“满”指达到其容量),给它发送数据也不会阻塞,否则就会阻塞。unbuffered chan只有读写都准备好之后才不会阻塞,这也是很多使用unbuffered chan时的常见Bug。
|
||||
|
||||
还有一个知识点需要你记住:nil是chan的零值,是一种特殊的chan,对值是nil的chan的发送接收调用者总是会阻塞。
|
||||
|
||||
下面,我来具体给你介绍几种基本操作,分别是发送数据、接收数据,以及一些其它操作。学会了这几种操作,你就能真正地掌握Channel的用法了。
|
||||
|
||||
**1.发送数据**
|
||||
|
||||
往chan中发送一个数据使用“ch<-”,发送数据是一条语句:
|
||||
|
||||
```
|
||||
ch <- 2000
|
||||
|
||||
```
|
||||
|
||||
这里的ch是chan int类型或者是chan <-int。
|
||||
|
||||
**2.接收数据**
|
||||
|
||||
从chan中接收一条数据使用“<-ch”,接收数据也是一条语句:
|
||||
|
||||
```
|
||||
x := <-ch // 把接收的一条数据赋值给变量x
|
||||
foo(<-ch) // 把接收的一个的数据作为参数传给函数
|
||||
<-ch // 丢弃接收的一条数据
|
||||
|
||||
```
|
||||
|
||||
这里的ch类型是chan T或者<-chan T。
|
||||
|
||||
接收数据时,还可以返回两个值。第一个值是返回的chan中的元素,很多人不太熟悉的是第二个值。第二个值是bool类型,代表是否成功地从chan中读取到一个值,如果第二个参数是false,chan已经被close而且chan中没有缓存的数据,这个时候,第一个值是零值。所以,如果从chan读取到一个零值,可能是sender真正发送的零值,也可能是closed的并且没有缓存元素产生的零值。
|
||||
|
||||
**3.其它操作**
|
||||
|
||||
Go内建的函数close、cap、len都可以操作chan类型:close会把chan关闭掉,cap返回chan的容量,len返回chan中缓存的还未被取走的元素数量。
|
||||
|
||||
send和recv都可以作为select语句的case clause,如下面的例子:
|
||||
|
||||
```
|
||||
func main() {
|
||||
var ch = make(chan int, 10)
|
||||
for i := 0; i < 10; i++ {
|
||||
select {
|
||||
case ch <- i:
|
||||
case v := <-ch:
|
||||
fmt.Println(v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
chan还可以应用于for-range语句中,比如:
|
||||
|
||||
```
|
||||
for v := range ch {
|
||||
fmt.Println(v)
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
或者是忽略读取的值,只是清空chan:
|
||||
|
||||
```
|
||||
for range ch {
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
好了,到这里,Channel的基本用法,我们就学完了。下面我从代码实现的角度分析chan类型的实现。毕竟,只有掌握了原理,你才能真正地用好它。
|
||||
|
||||
# Channel的实现原理
|
||||
|
||||
接下来,我会给你介绍chan的数据结构、初始化的方法以及三个重要的操作方法,分别是send、recv和close。通过学习Channel的底层实现,你会对Channel的功能和异常情况有更深的理解。
|
||||
|
||||
## chan数据结构
|
||||
|
||||
chan类型的数据结构如下图所示,它的数据类型是[runtime.hchan](https://github.com/golang/go/blob/master/src/runtime/chan.go#L32)。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/81/dd/81304c1f1845d21c66195798b6ba48dd.jpg" alt="">
|
||||
|
||||
下面我来具体解释各个字段的意义。
|
||||
|
||||
- qcount:代表chan中已经接收但还没被取走的元素的个数。内建函数len可以返回这个字段的值。
|
||||
- dataqsiz:队列的大小。chan使用一个循环队列来存放元素,循环队列很适合这种生产者-消费者的场景(我很好奇为什么这个字段省略size中的e)。
|
||||
- buf:存放元素的循环队列的buffer。
|
||||
- elemtype和elemsize:chan中元素的类型和size。因为chan一旦声明,它的元素类型是固定的,即普通类型或者指针类型,所以元素大小也是固定的。
|
||||
- sendx:处理发送数据的指针在buf中的位置。一旦接收了新的数据,指针就会加上elemsize,移向下一个位置。buf的总大小是elemsize的整数倍,而且buf是一个循环列表。
|
||||
- recvx:处理接收请求时的指针在buf中的位置。一旦取出数据,此指针会移动到下一个位置。
|
||||
- recvq:chan是多生产者多消费者的模式,如果消费者因为没有数据可读而被阻塞了,就会被加入到recvq队列中。
|
||||
- sendq:如果生产者因为buf满了而阻塞,会被加入到sendq队列中。
|
||||
|
||||
## 初始化
|
||||
|
||||
Go在编译的时候,会根据容量的大小选择调用makechan64,还是makechan。
|
||||
|
||||
下面的代码是处理make chan的逻辑,它会决定是使用makechan还是makechan64来实现chan的初始化:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/e9/d7/e96f2fee0633c8157a88b8b725f702d7.png" alt="">
|
||||
|
||||
**我们只关注makechan就好了,因为makechan64只是做了size检查,底层还是调用makechan实现的**。makechan的目标就是生成hchan对象。
|
||||
|
||||
那么,接下来,就让我们来看一下makechan的主要逻辑。主要的逻辑我都加上了注释,它会根据chan的容量的大小和元素的类型不同,初始化不同的存储空间:
|
||||
|
||||
```
|
||||
func makechan(t *chantype, size int) *hchan {
|
||||
elem := t.elem
|
||||
|
||||
// 略去检查代码
|
||||
mem, overflow := math.MulUintptr(elem.size, uintptr(size))
|
||||
|
||||
//
|
||||
var c *hchan
|
||||
switch {
|
||||
case mem == 0:
|
||||
// chan的size或者元素的size是0,不必创建buf
|
||||
c = (*hchan)(mallocgc(hchanSize, nil, true))
|
||||
c.buf = c.raceaddr()
|
||||
case elem.ptrdata == 0:
|
||||
// 元素不是指针,分配一块连续的内存给hchan数据结构和buf
|
||||
c = (*hchan)(mallocgc(hchanSize+mem, nil, true))
|
||||
// hchan数据结构后面紧接着就是buf
|
||||
c.buf = add(unsafe.Pointer(c), hchanSize)
|
||||
default:
|
||||
// 元素包含指针,那么单独分配buf
|
||||
c = new(hchan)
|
||||
c.buf = mallocgc(mem, elem, true)
|
||||
}
|
||||
|
||||
// 元素大小、类型、容量都记录下来
|
||||
c.elemsize = uint16(elem.size)
|
||||
c.elemtype = elem
|
||||
c.dataqsiz = uint(size)
|
||||
lockInit(&c.lock, lockRankHchan)
|
||||
|
||||
return c
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
最终,针对不同的容量和元素类型,这段代码分配了不同的对象来初始化hchan对象的字段,返回hchan对象。
|
||||
|
||||
## send
|
||||
|
||||
Go在编译发送数据给chan的时候,会把send语句转换成chansend1函数,chansend1函数会调用chansend,我们分段学习它的逻辑:
|
||||
|
||||
```
|
||||
func chansend1(c *hchan, elem unsafe.Pointer) {
|
||||
chansend(c, elem, true, getcallerpc())
|
||||
}
|
||||
func chansend(c *hchan, ep unsafe.Pointer, block bool, callerpc uintptr) bool {
|
||||
// 第一部分
|
||||
if c == nil {
|
||||
if !block {
|
||||
return false
|
||||
}
|
||||
gopark(nil, nil, waitReasonChanSendNilChan, traceEvGoStop, 2)
|
||||
throw("unreachable")
|
||||
}
|
||||
......
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
最开始,第一部分是进行判断:如果chan是nil的话,就把调用者goroutine park(阻塞休眠), 调用者就永远被阻塞住了,所以,第11行是不可能执行到的代码。
|
||||
|
||||
```
|
||||
// 第二部分,如果chan没有被close,并且chan满了,直接返回
|
||||
if !block && c.closed == 0 && full(c) {
|
||||
return false
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
第二部分的逻辑是当你往一个已经满了的chan实例发送数据时,并且想不阻塞当前调用,那么这里的逻辑是直接返回。chansend1方法在调用chansend的时候设置了阻塞参数,所以不会执行到第二部分的分支里。
|
||||
|
||||
```
|
||||
// 第三部分,chan已经被close的情景
|
||||
lock(&c.lock) // 开始加锁
|
||||
if c.closed != 0 {
|
||||
unlock(&c.lock)
|
||||
panic(plainError("send on closed channel"))
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
第三部分显示的是,如果chan已经被close了,再往里面发送数据的话会panic。
|
||||
|
||||
```
|
||||
// 第四部分,从接收队列中出队一个等待的receiver
|
||||
if sg := c.recvq.dequeue(); sg != nil {
|
||||
//
|
||||
send(c, sg, ep, func() { unlock(&c.lock) }, 3)
|
||||
return true
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
第四部分,如果等待队列中有等待的receiver,那么这段代码就把它从队列中弹出,然后直接把数据交给它(通过memmove(dst, src, t.size)),而不需要放入到buf中,速度可以更快一些。
|
||||
|
||||
```
|
||||
// 第五部分,buf还没满
|
||||
if c.qcount < c.dataqsiz {
|
||||
qp := chanbuf(c, c.sendx)
|
||||
if raceenabled {
|
||||
raceacquire(qp)
|
||||
racerelease(qp)
|
||||
}
|
||||
typedmemmove(c.elemtype, qp, ep)
|
||||
c.sendx++
|
||||
if c.sendx == c.dataqsiz {
|
||||
c.sendx = 0
|
||||
}
|
||||
c.qcount++
|
||||
unlock(&c.lock)
|
||||
return true
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
第五部分说明当前没有receiver,需要把数据放入到buf中,放入之后,就成功返回了。
|
||||
|
||||
```
|
||||
// 第六部分,buf满。
|
||||
// chansend1不会进入if块里,因为chansend1的block=true
|
||||
if !block {
|
||||
unlock(&c.lock)
|
||||
return false
|
||||
}
|
||||
......
|
||||
|
||||
```
|
||||
|
||||
第六部分是处理buf满的情况。如果buf满了,发送者的goroutine就会加入到发送者的等待队列中,直到被唤醒。这个时候,数据或者被取走了,或者chan被close了。
|
||||
|
||||
## recv
|
||||
|
||||
在处理从chan中接收数据时,Go会把代码转换成chanrecv1函数,如果要返回两个返回值,会转换成chanrecv2,chanrecv1函数和chanrecv2会调用chanrecv。我们分段学习它的逻辑:
|
||||
|
||||
```
|
||||
func chanrecv1(c *hchan, elem unsafe.Pointer) {
|
||||
chanrecv(c, elem, true)
|
||||
}
|
||||
func chanrecv2(c *hchan, elem unsafe.Pointer) (received bool) {
|
||||
_, received = chanrecv(c, elem, true)
|
||||
return
|
||||
}
|
||||
|
||||
func chanrecv(c *hchan, ep unsafe.Pointer, block bool) (selected, received bool) {
|
||||
// 第一部分,chan为nil
|
||||
if c == nil {
|
||||
if !block {
|
||||
return
|
||||
}
|
||||
gopark(nil, nil, waitReasonChanReceiveNilChan, traceEvGoStop, 2)
|
||||
throw("unreachable")
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
chanrecv1和chanrecv2传入的block参数的值是true,都是阻塞方式,所以我们分析chanrecv的实现的时候,不考虑block=false的情况。
|
||||
|
||||
第一部分是chan为nil的情况。和send一样,从nil chan中接收(读取、获取)数据时,调用者会被永远阻塞。
|
||||
|
||||
```
|
||||
// 第二部分, block=false且c为空
|
||||
if !block && empty(c) {
|
||||
......
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
第二部分你可以直接忽略,因为不是我们这次要分析的场景。
|
||||
|
||||
```
|
||||
// 加锁,返回时释放锁
|
||||
lock(&c.lock)
|
||||
// 第三部分,c已经被close,且chan为空empty
|
||||
if c.closed != 0 && c.qcount == 0 {
|
||||
unlock(&c.lock)
|
||||
if ep != nil {
|
||||
typedmemclr(c.elemtype, ep)
|
||||
}
|
||||
return true, false
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
第三部分是chan已经被close的情况。如果chan已经被close了,并且队列中没有缓存的元素,那么返回true、false。
|
||||
|
||||
```
|
||||
// 第四部分,如果sendq队列中有等待发送的sender
|
||||
if sg := c.sendq.dequeue(); sg != nil {
|
||||
recv(c, sg, ep, func() { unlock(&c.lock) }, 3)
|
||||
return true, true
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
第四部分是处理buf满的情况。这个时候,如果是unbuffer的chan,就直接将sender的数据复制给receiver,否则就从队列头部读取一个值,并把这个sender的值加入到队列尾部。
|
||||
|
||||
```
|
||||
// 第五部分, 没有等待的sender, buf中有数据
|
||||
if c.qcount > 0 {
|
||||
qp := chanbuf(c, c.recvx)
|
||||
if ep != nil {
|
||||
typedmemmove(c.elemtype, ep, qp)
|
||||
}
|
||||
typedmemclr(c.elemtype, qp)
|
||||
c.recvx++
|
||||
if c.recvx == c.dataqsiz {
|
||||
c.recvx = 0
|
||||
}
|
||||
c.qcount--
|
||||
unlock(&c.lock)
|
||||
return true, true
|
||||
}
|
||||
|
||||
if !block {
|
||||
unlock(&c.lock)
|
||||
return false, false
|
||||
}
|
||||
|
||||
// 第六部分, buf中没有元素,阻塞
|
||||
......
|
||||
|
||||
```
|
||||
|
||||
第五部分是处理没有等待的sender的情况。这个是和chansend共用一把大锁,所以不会有并发的问题。如果buf有元素,就取出一个元素给receiver。
|
||||
|
||||
第六部分是处理buf中没有元素的情况。如果没有元素,那么当前的receiver就会被阻塞,直到它从sender中接收了数据,或者是chan被close,才返回。
|
||||
|
||||
## close
|
||||
|
||||
通过close函数,可以把chan关闭,编译器会替换成closechan方法的调用。
|
||||
|
||||
下面的代码是close chan的主要逻辑。如果chan为nil,close会panic;如果chan已经closed,再次close也会panic。否则的话,如果chan不为nil,chan也没有closed,就把等待队列中的sender(writer)和receiver(reader)从队列中全部移除并唤醒。
|
||||
|
||||
下面的代码就是close chan的逻辑:
|
||||
|
||||
```
|
||||
func closechan(c *hchan) {
|
||||
if c == nil { // chan为nil, panic
|
||||
panic(plainError("close of nil channel"))
|
||||
}
|
||||
|
||||
lock(&c.lock)
|
||||
if c.closed != 0 {// chan已经closed, panic
|
||||
unlock(&c.lock)
|
||||
panic(plainError("close of closed channel"))
|
||||
}
|
||||
|
||||
c.closed = 1
|
||||
|
||||
var glist gList
|
||||
|
||||
// 释放所有的reader
|
||||
for {
|
||||
sg := c.recvq.dequeue()
|
||||
......
|
||||
gp := sg.g
|
||||
......
|
||||
glist.push(gp)
|
||||
}
|
||||
|
||||
// 释放所有的writer (它们会panic)
|
||||
for {
|
||||
sg := c.sendq.dequeue()
|
||||
......
|
||||
gp := sg.g
|
||||
......
|
||||
glist.push(gp)
|
||||
}
|
||||
unlock(&c.lock)
|
||||
|
||||
for !glist.empty() {
|
||||
gp := glist.pop()
|
||||
gp.schedlink = 0
|
||||
goready(gp, 3)
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
掌握了Channel的基本用法和实现原理,下面我再来给你讲一讲容易犯的错误。你一定要认真看,毕竟,这些可都是帮助你避坑的。
|
||||
|
||||
# 使用Channel容易犯的错误
|
||||
|
||||
根据2019年第一篇全面分析Go并发Bug的[论文](https://songlh.github.io/paper/go-study.pdf),那些知名的Go项目中使用Channel所犯的Bug反而比传统的并发原语的Bug还要多。主要有两个原因:一个是,Channel的概念还比较新,程序员还不能很好地掌握相应的使用方法和最佳实践;第二个是,Channel有时候比传统的并发原语更复杂,使用起来很容易顾此失彼。
|
||||
|
||||
**使用Channel最常见的错误是panic和goroutine泄漏**。
|
||||
|
||||
首先,我们来总结下会panic的情况,总共有3种:
|
||||
|
||||
1. close为nil的chan;
|
||||
1. send已经close的chan;
|
||||
1. close已经close的chan。
|
||||
|
||||
goroutine泄漏的问题也很常见,下面的代码也是一个实际项目中的例子:
|
||||
|
||||
```
|
||||
func process(timeout time.Duration) bool {
|
||||
ch := make(chan bool)
|
||||
|
||||
go func() {
|
||||
// 模拟处理耗时的业务
|
||||
time.Sleep((timeout + time.Second))
|
||||
ch <- true // block
|
||||
fmt.Println("exit goroutine")
|
||||
}()
|
||||
select {
|
||||
case result := <-ch:
|
||||
return result
|
||||
case <-time.After(timeout):
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在这个例子中,process函数会启动一个goroutine,去处理需要长时间处理的业务,处理完之后,会发送true到chan中,目的是通知其它等待的goroutine,可以继续处理了。
|
||||
|
||||
我们来看一下第10行到第15行,主goroutine接收到任务处理完成的通知,或者超时后就返回了。这段代码有问题吗?
|
||||
|
||||
如果发生超时,process函数就返回了,这就会导致unbuffered的chan从来就没有被读取。我们知道,unbuffered chan必须等reader和writer都准备好了才能交流,否则就会阻塞。超时导致未读,结果就是子goroutine就阻塞在第7行永远结束不了,进而导致goroutine泄漏。
|
||||
|
||||
解决这个Bug的办法很简单,就是将unbuffered chan改成容量为1的chan,这样第7行就不会被阻塞了。
|
||||
|
||||
Go的开发者极力推荐使用Channel,不过,这两年,大家意识到,Channel并不是处理并发问题的“银弹”,有时候使用并发原语更简单,而且不容易出错。所以,我给你提供一套选择的方法:
|
||||
|
||||
1. 共享资源的并发访问使用传统并发原语;
|
||||
1. 复杂的任务编排和消息传递使用Channel;
|
||||
1. 消息通知机制使用Channel,除非只想signal一个goroutine,才使用Cond;
|
||||
1. 简单等待所有任务的完成用WaitGroup,也有Channel的推崇者用Channel,都可以;
|
||||
1. 需要和Select语句结合,使用Channel;
|
||||
1. 需要和超时配合时,使用Channel和Context。
|
||||
|
||||
# 它们踩过的坑
|
||||
|
||||
接下来,我带你围观下知名Go项目的Channel相关的Bug。
|
||||
|
||||
[etcd issue 6857](https://github.com/etcd-io/etcd/pull/6857)是一个程序hang住的问题:在异常情况下,没有往chan实例中填充所需的元素,导致等待者永远等待。具体来说,Status方法的逻辑是生成一个chan Status,然后把这个chan交给其它的goroutine去处理和写入数据,最后,Status返回获取的状态信息。
|
||||
|
||||
不幸的是,如果正好节点停止了,没有goroutine去填充这个chan,会导致方法hang在返回的那一行上(下面的截图中的第466行)。解决办法就是,在等待status chan返回元素的同时,也检查节点是不是已经停止了(done这个chan是不是close了)。
|
||||
|
||||
当前的etcd的代码就是修复后的代码,如下所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/5f/da/5f3c15c110077714be81be8eb1fd3fda.png" alt="">
|
||||
|
||||
其实,我感觉这个修改还是有问题的。问题就在于,如果程序执行了466行,成功地把c写入到Status待处理队列后,执行到第467行时,如果停止了这个节点,那么,这个Status方法还是会阻塞在第467行。你可以自己研究研究,看看是不是这样。
|
||||
|
||||
[etcd issue 5505](https://github.com/etcd-io/etcd/issues/5505) 虽然没有任何的Bug描述,但是从修复内容上看,它是一个往已经close的chan写数据导致panic的问题。
|
||||
|
||||
[etcd issue 11256](https://github.com/etcd-io/etcd/issues/11256) 是因为unbuffered chan goroutine泄漏的问题。TestNodeProposeAddLearnerNode方法中一开始定义了一个unbuffered的chan,也就是applyConfChan,然后启动一个子goroutine,这个子goroutine会在循环中执行业务逻辑,并且不断地往这个chan中添加一个元素。TestNodeProposeAddLearnerNode方法的末尾处会从这个chan中读取一个元素。
|
||||
|
||||
这段代码在for循环中就往此chan中写入了一个元素,结果导致TestNodeProposeAddLearnerNode从这个chan中读取到元素就返回了。悲剧的是,子goroutine的for循环还在执行,阻塞在下图中红色的第851行,并且一直hang在那里。
|
||||
|
||||
这个Bug的修复也很简单,只要改动一下applyConfChan的处理逻辑就可以了:只有子goroutine的for循环中的主要逻辑完成之后,才往applyConfChan发送一个元素,这样,TestNodeProposeAddLearnerNode收到通知继续执行,子goroutine也不会被阻塞住了。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/d5/9f/d53573c8fc515f78ea590bf73396969f.png" alt="">
|
||||
|
||||
[etcd issue 9956](https://github.com/etcd-io/etcd/issues/9956) 是往一个已close的chan发送数据,其实它是grpc的一个bug([grpc issue 2695](https://github.com/grpc/grpc-go/pull/2695)),修复办法就是不close这个chan就好了:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/65/21/650f0911b1c7278cc0438c85bbc4yy21.png" alt="">
|
||||
|
||||
# 总结
|
||||
|
||||
chan的值和状态有多种情况,而不同的操作(send、recv、close)又可能得到不同的结果,这是使用chan类型时经常让人困惑的地方。
|
||||
|
||||
为了帮助你快速地了解不同状态下各种操作的结果,我总结了一个表格,你一定要特别关注下那些panic的情况,另外还要掌握那些会block的场景,它们是导致死锁或者goroutine泄露的罪魁祸首。
|
||||
|
||||
还有一个值得注意的点是,只要一个chan还有未读的数据,即使把它close掉,你还是可以继续把这些未读的数据消费完,之后才是读取零值数据。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/51/98/5108954ea36559860e5e5aaa42b2f998.jpg" alt="">
|
||||
|
||||
# 思考题
|
||||
|
||||
<li>
|
||||
有一道经典的使用Channel进行任务编排的题,你可以尝试做一下:有四个goroutine,编号为1、2、3、4。每秒钟会有一个goroutine打印出它自己的编号,要求你编写一个程序,让输出的编号总是按照1、2、3、4、1、2、3、4、……的顺序打印出来。
|
||||
</li>
|
||||
<li>
|
||||
chan T 是否可以给<- chan T和chan<- T类型的变量赋值?反过来呢?
|
||||
</li>
|
||||
|
||||
欢迎在留言区写下你的思考和答案,我们一起交流讨论。如果你觉得有所收获,也欢迎你把今天的内容分享给你的朋友或同事。
|
||||
724
极客时间专栏/geek/Go 并发编程实战课/Channel/14 | Channel:透过代码看典型的应用模式.md
Normal file
724
极客时间专栏/geek/Go 并发编程实战课/Channel/14 | Channel:透过代码看典型的应用模式.md
Normal file
@@ -0,0 +1,724 @@
|
||||
<audio id="audio" title="14 | Channel:透过代码看典型的应用模式" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/89/51/895031072cf84e0fb081db7c336e8251.mp3"></audio>
|
||||
|
||||
你好,我是鸟窝。
|
||||
|
||||
前一讲,我介绍了Channel的基础知识,并且总结了几种应用场景。这一讲,我将通过实例的方式,带你逐个学习Channel解决这些问题的方法,帮你巩固和完全掌握它的用法。
|
||||
|
||||
在开始上课之前,我先补充一个知识点:通过反射的方式执行select语句,在处理很多的case clause,尤其是不定长的case clause的时候,非常有用。而且,在后面介绍任务编排的实现时,我也会采用这种方法,所以,我先带你具体学习下Channel的反射用法。
|
||||
|
||||
# 使用反射操作Channel
|
||||
|
||||
select语句可以处理chan的send和recv,send和recv都可以作为case clause。如果我们同时处理两个chan,就可以写成下面的样子:
|
||||
|
||||
```
|
||||
select {
|
||||
case v := <-ch1:
|
||||
fmt.Println(v)
|
||||
case v := <-ch2:
|
||||
fmt.Println(v)
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
如果需要处理三个chan,你就可以再添加一个case clause,用它来处理第三个chan。可是,如果要处理100个chan呢?一万个chan呢?
|
||||
|
||||
或者是,chan的数量在编译的时候是不定的,在运行的时候需要处理一个slice of chan,这个时候,也没有办法在编译前写成字面意义的select。那该怎么办?
|
||||
|
||||
这个时候,就要“祭”出我们的反射大法了。
|
||||
|
||||
通过reflect.Select函数,你可以将一组运行时的case clause传入,当作参数执行。Go的select是伪随机的,它可以在执行的case中随机选择一个case,并把选择的这个case的索引(chosen)返回,如果没有可用的case返回,会返回一个bool类型的返回值,这个返回值用来表示是否有case成功被选择。如果是recv case,还会返回接收的元素。Select的方法签名如下:
|
||||
|
||||
```
|
||||
func Select(cases []SelectCase) (chosen int, recv Value, recvOK bool)
|
||||
|
||||
```
|
||||
|
||||
下面,我来借助一个例子,来演示一下,动态处理两个chan的情形。因为这样的方式可以动态处理case数据,所以,你可以传入几百几千几万的chan,这就解决了不能动态处理n个chan的问题。
|
||||
|
||||
首先,createCases函数分别为每个chan生成了recv case和send case,并返回一个reflect.SelectCase数组。
|
||||
|
||||
然后,通过一个循环10次的for循环执行reflect.Select,这个方法会从cases中选择一个case执行。第一次肯定是send case,因为此时chan还没有元素,recv还不可用。等chan中有了数据以后,recv case就可以被选择了。这样,你就可以处理不定数量的chan了。
|
||||
|
||||
```
|
||||
func main() {
|
||||
var ch1 = make(chan int, 10)
|
||||
var ch2 = make(chan int, 10)
|
||||
|
||||
// 创建SelectCase
|
||||
var cases = createCases(ch1, ch2)
|
||||
|
||||
// 执行10次select
|
||||
for i := 0; i < 10; i++ {
|
||||
chosen, recv, ok := reflect.Select(cases)
|
||||
if recv.IsValid() { // recv case
|
||||
fmt.Println("recv:", cases[chosen].Dir, recv, ok)
|
||||
} else { // send case
|
||||
fmt.Println("send:", cases[chosen].Dir, ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func createCases(chs ...chan int) []reflect.SelectCase {
|
||||
var cases []reflect.SelectCase
|
||||
|
||||
|
||||
// 创建recv case
|
||||
for _, ch := range chs {
|
||||
cases = append(cases, reflect.SelectCase{
|
||||
Dir: reflect.SelectRecv,
|
||||
Chan: reflect.ValueOf(ch),
|
||||
})
|
||||
}
|
||||
|
||||
// 创建send case
|
||||
for i, ch := range chs {
|
||||
v := reflect.ValueOf(i)
|
||||
cases = append(cases, reflect.SelectCase{
|
||||
Dir: reflect.SelectSend,
|
||||
Chan: reflect.ValueOf(ch),
|
||||
Send: v,
|
||||
})
|
||||
}
|
||||
|
||||
return cases
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
# 典型的应用场景
|
||||
|
||||
了解刚刚的反射用法,我们就解决了今天的基础知识问题,接下来,我就带你具体学习下Channel的应用场景。
|
||||
|
||||
首先来看消息交流。
|
||||
|
||||
## 消息交流
|
||||
|
||||
从chan的内部实现看,它是以一个循环队列的方式存放数据,所以,它有时候也会被当成线程安全的队列和buffer使用。一个goroutine可以安全地往Channel中塞数据,另外一个goroutine可以安全地从Channel中读取数据,goroutine就可以安全地实现信息交流了。
|
||||
|
||||
我们来看几个例子。
|
||||
|
||||
第一个例子是worker池的例子。Marcio Castilho 在 [使用Go每分钟处理百万请求](http://marcio.io/2015/07/handling-1-million-requests-per-minute-with-golang/) 这篇文章中,就介绍了他们应对大并发请求的设计。他们将用户的请求放在一个 chan Job 中,这个chan Job就相当于一个待处理任务队列。除此之外,还有一个chan chan Job队列,用来存放可以处理任务的worker的缓存队列。
|
||||
|
||||
dispatcher会把待处理任务队列中的任务放到一个可用的缓存队列中,worker会一直处理它的缓存队列。通过使用Channel,实现了一个worker池的任务处理中心,并且解耦了前端HTTP请求处理和后端任务处理的逻辑。
|
||||
|
||||
我在讲Pool的时候,提到了一些第三方实现的worker池,它们全部都是通过Channel实现的,这是Channel的一个常见的应用场景。worker池的生产者和消费者的消息交流都是通过Channel实现的。
|
||||
|
||||
第二个例子是etcd中的node节点的实现,包含大量的chan字段,比如recvc是消息处理的chan,待处理的protobuf消息都扔到这个chan中,node有一个专门的run goroutine负责处理这些消息。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/06/a4/0643503a1yy135b476d41345d71766a4.png" alt="">
|
||||
|
||||
## 数据传递
|
||||
|
||||
“击鼓传花”的游戏很多人都玩过,花从一个人手中传给另外一个人,就有点类似流水线的操作。这个花就是数据,花在游戏者之间流转,这就类似编程中的数据传递。
|
||||
|
||||
还记得上节课我给你留了一道任务编排的题吗?其实它就可以用数据传递的方式实现。
|
||||
|
||||
>
|
||||
有4个goroutine,编号为1、2、3、4。每秒钟会有一个goroutine打印出它自己的编号,要求你编写程序,让输出的编号总是按照1、2、3、4、1、2、3、4……这个顺序打印出来。
|
||||
|
||||
|
||||
为了实现顺序的数据传递,我们可以定义一个令牌的变量,谁得到令牌,谁就可以打印一次自己的编号,同时将令牌**传递**给下一个goroutine,我们尝试使用chan来实现,可以看下下面的代码。
|
||||
|
||||
```
|
||||
type Token struct{}
|
||||
|
||||
func newWorker(id int, ch chan Token, nextCh chan Token) {
|
||||
for {
|
||||
token := <-ch // 取得令牌
|
||||
fmt.Println((id + 1)) // id从1开始
|
||||
time.Sleep(time.Second)
|
||||
nextCh <- token
|
||||
}
|
||||
}
|
||||
func main() {
|
||||
chs := []chan Token{make(chan Token), make(chan Token), make(chan Token), make(chan Token)}
|
||||
|
||||
// 创建4个worker
|
||||
for i := 0; i < 4; i++ {
|
||||
go newWorker(i, chs[i], chs[(i+1)%4])
|
||||
}
|
||||
|
||||
//首先把令牌交给第一个worker
|
||||
chs[0] <- struct{}{}
|
||||
|
||||
select {}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
我来给你具体解释下这个实现方式。
|
||||
|
||||
首先,我们定义一个令牌类型(Token),接着定义一个创建worker的方法,这个方法会从它自己的chan中读取令牌。哪个goroutine取得了令牌,就可以打印出自己编号,因为需要每秒打印一次数据,所以,我们让它休眠1秒后,再把令牌交给它的下家。
|
||||
|
||||
接着,在第16行启动每个worker的goroutine,并在第20行将令牌先交给第一个worker。
|
||||
|
||||
如果你运行这个程序,就会在命令行中看到每一秒就会输出一个编号,而且编号是以1、2、3、4这样的顺序输出的。
|
||||
|
||||
这类场景有一个特点,就是当前持有数据的goroutine都有一个信箱,信箱使用chan实现,goroutine只需要关注自己的信箱中的数据,处理完毕后,就把结果发送到下一家的信箱中。
|
||||
|
||||
## 信号通知
|
||||
|
||||
chan类型有这样一个特点:chan如果为空,那么,receiver接收数据的时候就会阻塞等待,直到chan被关闭或者有新的数据到来。利用这个机制,我们可以实现wait/notify的设计模式。
|
||||
|
||||
传统的并发原语Cond也能实现这个功能。但是,Cond使用起来比较复杂,容易出错,而使用chan实现wait/notify模式,就方便多了。
|
||||
|
||||
除了正常的业务处理时的wait/notify,我们经常碰到的一个场景,就是程序关闭的时候,我们需要在退出之前做一些清理(doCleanup方法)的动作。这个时候,我们经常要使用chan。
|
||||
|
||||
比如,使用chan实现程序的graceful shutdown,在退出之前执行一些连接关闭、文件close、缓存落盘等一些动作。
|
||||
|
||||
```
|
||||
func main() {
|
||||
go func() {
|
||||
...... // 执行业务处理
|
||||
}()
|
||||
|
||||
// 处理CTRL+C等中断信号
|
||||
termChan := make(chan os.Signal)
|
||||
signal.Notify(termChan, syscall.SIGINT, syscall.SIGTERM)
|
||||
<-termChan
|
||||
|
||||
// 执行退出之前的清理动作
|
||||
doCleanup()
|
||||
|
||||
fmt.Println("优雅退出")
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
有时候,doCleanup可能是一个很耗时的操作,比如十几分钟才能完成,如果程序退出需要等待这么长时间,用户是不能接受的,所以,在实践中,我们需要设置一个最长的等待时间。只要超过了这个时间,程序就不再等待,可以直接退出。所以,退出的时候分为两个阶段:
|
||||
|
||||
1. closing,代表程序退出,但是清理工作还没做;
|
||||
1. closed,代表清理工作已经做完。
|
||||
|
||||
所以,上面的例子可以改写如下:
|
||||
|
||||
```
|
||||
func main() {
|
||||
var closing = make(chan struct{})
|
||||
var closed = make(chan struct{})
|
||||
|
||||
go func() {
|
||||
// 模拟业务处理
|
||||
for {
|
||||
select {
|
||||
case <-closing:
|
||||
return
|
||||
default:
|
||||
// ....... 业务计算
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// 处理CTRL+C等中断信号
|
||||
termChan := make(chan os.Signal)
|
||||
signal.Notify(termChan, syscall.SIGINT, syscall.SIGTERM)
|
||||
<-termChan
|
||||
|
||||
close(closing)
|
||||
// 执行退出之前的清理动作
|
||||
go doCleanup(closed)
|
||||
|
||||
select {
|
||||
case <-closed:
|
||||
case <-time.After(time.Second):
|
||||
fmt.Println("清理超时,不等了")
|
||||
}
|
||||
fmt.Println("优雅退出")
|
||||
}
|
||||
|
||||
func doCleanup(closed chan struct{}) {
|
||||
time.Sleep((time.Minute))
|
||||
close(closed)
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
## 锁
|
||||
|
||||
使用chan也可以实现互斥锁。
|
||||
|
||||
在chan的内部实现中,就有一把互斥锁保护着它的所有字段。从外在表现上,chan的发送和接收之间也存在着happens-before的关系,保证元素放进去之后,receiver才能读取到(关于happends-before的关系,是指事件发生的先后顺序关系,我会在下一讲详细介绍,这里你只需要知道它是一种描述事件先后顺序的方法)。
|
||||
|
||||
要想使用chan实现互斥锁,至少有两种方式。一种方式是先初始化一个capacity等于1的Channel,然后再放入一个元素。这个元素就代表锁,谁取得了这个元素,就相当于获取了这把锁。另一种方式是,先初始化一个capacity等于1的Channel,它的“空槽”代表锁,谁能成功地把元素发送到这个Channel,谁就获取了这把锁。
|
||||
|
||||
这是使用Channel实现锁的两种不同实现方式,我重点介绍下第一种。理解了这种实现方式,第二种方式也就很容易掌握了,我就不多说了。
|
||||
|
||||
```
|
||||
// 使用chan实现互斥锁
|
||||
type Mutex struct {
|
||||
ch chan struct{}
|
||||
}
|
||||
|
||||
// 使用锁需要初始化
|
||||
func NewMutex() *Mutex {
|
||||
mu := &Mutex{make(chan struct{}, 1)}
|
||||
mu.ch <- struct{}{}
|
||||
return mu
|
||||
}
|
||||
|
||||
// 请求锁,直到获取到
|
||||
func (m *Mutex) Lock() {
|
||||
<-m.ch
|
||||
}
|
||||
|
||||
// 解锁
|
||||
func (m *Mutex) Unlock() {
|
||||
select {
|
||||
case m.ch <- struct{}{}:
|
||||
default:
|
||||
panic("unlock of unlocked mutex")
|
||||
}
|
||||
}
|
||||
|
||||
// 尝试获取锁
|
||||
func (m *Mutex) TryLock() bool {
|
||||
select {
|
||||
case <-m.ch:
|
||||
return true
|
||||
default:
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// 加入一个超时的设置
|
||||
func (m *Mutex) LockTimeout(timeout time.Duration) bool {
|
||||
timer := time.NewTimer(timeout)
|
||||
select {
|
||||
case <-m.ch:
|
||||
timer.Stop()
|
||||
return true
|
||||
case <-timer.C:
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// 锁是否已被持有
|
||||
func (m *Mutex) IsLocked() bool {
|
||||
return len(m.ch) == 0
|
||||
}
|
||||
|
||||
|
||||
func main() {
|
||||
m := NewMutex()
|
||||
ok := m.TryLock()
|
||||
fmt.Printf("locked v %v\n", ok)
|
||||
ok = m.TryLock()
|
||||
fmt.Printf("locked %v\n", ok)
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
你可以用buffer等于1的chan实现互斥锁,在初始化这个锁的时候往Channel中先塞入一个元素,谁把这个元素取走,谁就获取了这把锁,把元素放回去,就是释放了锁。元素在放回到chan之前,不会有goroutine能从chan中取出元素的,这就保证了互斥性。
|
||||
|
||||
在这段代码中,还有一点需要我们注意下:利用select+chan的方式,很容易实现TryLock、Timeout的功能。具体来说就是,在select语句中,我们可以使用default实现TryLock,使用一个Timer来实现Timeout的功能。
|
||||
|
||||
## 任务编排
|
||||
|
||||
前面所说的消息交流的场景是一个特殊的任务编排的场景,这个“击鼓传花”的模式也被称为流水线模式。
|
||||
|
||||
在[第6讲](https://time.geekbang.org/column/article/298516),我们学习了WaitGroup,我们可以利用它实现等待模式:启动一组goroutine执行任务,然后等待这些任务都完成。其实,我们也可以使用chan实现WaitGroup的功能。这个比较简单,我就不举例子了,接下来我介绍几种更复杂的编排模式。
|
||||
|
||||
这里的编排既指安排goroutine按照指定的顺序执行,也指多个chan按照指定的方式组合处理的方式。goroutine的编排类似“击鼓传花”的例子,我们通过编排数据在chan之间的流转,就可以控制goroutine的执行。接下来,我来重点介绍下多个chan的编排方式,总共5种,分别是Or-Done模式、扇入模式、扇出模式、Stream和map-reduce。
|
||||
|
||||
### Or-Done模式
|
||||
|
||||
首先来看Or-Done模式。Or-Done模式是信号通知模式中更宽泛的一种模式。这里提到了“信号通知模式”,我先来解释一下。
|
||||
|
||||
我们会使用“信号通知”实现某个任务执行完成后的通知机制,在实现时,我们为这个任务定义一个类型为chan struct{}类型的done变量,等任务结束后,我们就可以close这个变量,然后,其它receiver就会收到这个通知。
|
||||
|
||||
这是有一个任务的情况,如果有多个任务,只要有任意一个任务执行完,我们就想获得这个信号,这就是Or-Done模式。
|
||||
|
||||
比如,你发送同一个请求到多个微服务节点,只要任意一个微服务节点返回结果,就算成功,这个时候,就可以参考下面的实现:
|
||||
|
||||
```
|
||||
func or(channels ...<-chan interface{}) <-chan interface{} {
|
||||
// 特殊情况,只有零个或者1个chan
|
||||
switch len(channels) {
|
||||
case 0:
|
||||
return nil
|
||||
case 1:
|
||||
return channels[0]
|
||||
}
|
||||
|
||||
orDone := make(chan interface{})
|
||||
go func() {
|
||||
defer close(orDone)
|
||||
|
||||
switch len(channels) {
|
||||
case 2: // 2个也是一种特殊情况
|
||||
select {
|
||||
case <-channels[0]:
|
||||
case <-channels[1]:
|
||||
}
|
||||
default: //超过两个,二分法递归处理
|
||||
m := len(channels) / 2
|
||||
select {
|
||||
case <-or(channels[:m]...):
|
||||
case <-or(channels[m:]...):
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
return orDone
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
我们可以写一个测试程序测试它:
|
||||
|
||||
```
|
||||
func sig(after time.Duration) <-chan interface{} {
|
||||
c := make(chan interface{})
|
||||
go func() {
|
||||
defer close(c)
|
||||
time.Sleep(after)
|
||||
}()
|
||||
return c
|
||||
}
|
||||
|
||||
|
||||
func main() {
|
||||
start := time.Now()
|
||||
|
||||
<-or(
|
||||
sig(10*time.Second),
|
||||
sig(20*time.Second),
|
||||
sig(30*time.Second),
|
||||
sig(40*time.Second),
|
||||
sig(50*time.Second),
|
||||
sig(01*time.Minute),
|
||||
)
|
||||
|
||||
fmt.Printf("done after %v", time.Since(start))
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这里的实现使用了一个巧妙的方式,**当chan的数量大于2时,使用递归的方式等待信号**。
|
||||
|
||||
在chan数量比较多的情况下,递归并不是一个很好的解决方式,根据这一讲最开始介绍的反射的方法,我们也可以实现Or-Done模式:
|
||||
|
||||
```
|
||||
func or(channels ...<-chan interface{}) <-chan interface{} {
|
||||
//特殊情况,只有0个或者1个
|
||||
switch len(channels) {
|
||||
case 0:
|
||||
return nil
|
||||
case 1:
|
||||
return channels[0]
|
||||
}
|
||||
|
||||
orDone := make(chan interface{})
|
||||
go func() {
|
||||
defer close(orDone)
|
||||
// 利用反射构建SelectCase
|
||||
var cases []reflect.SelectCase
|
||||
for _, c := range channels {
|
||||
cases = append(cases, reflect.SelectCase{
|
||||
Dir: reflect.SelectRecv,
|
||||
Chan: reflect.ValueOf(c),
|
||||
})
|
||||
}
|
||||
|
||||
// 随机选择一个可用的case
|
||||
reflect.Select(cases)
|
||||
}()
|
||||
|
||||
|
||||
return orDone
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这是递归和反射两种方法实现Or-Done模式的代码。反射方式避免了深层递归的情况,可以处理有大量chan的情况。其实最笨的一种方法就是为每一个Channel启动一个goroutine,不过这会启动非常多的goroutine,太多的goroutine会影响性能,所以不太常用。你只要知道这种用法就行了,不用重点掌握。
|
||||
|
||||
### 扇入模式
|
||||
|
||||
扇入借鉴了数字电路的概念,它定义了单个逻辑门能够接受的数字信号输入最大量的术语。一个逻辑门可以有多个输入,一个输出。
|
||||
|
||||
在软件工程中,模块的扇入是指有多少个上级模块调用它。而对于我们这里的Channel扇入模式来说,就是指有多个源Channel输入、一个目的Channel输出的情况。扇入比就是源Channel数量比1。
|
||||
|
||||
每个源Channel的元素都会发送给目标Channel,相当于目标Channel的receiver只需要监听目标Channel,就可以接收所有发送给源Channel的数据。
|
||||
|
||||
扇入模式也可以使用反射、递归,或者是用最笨的每个goroutine处理一个Channel的方式来实现。
|
||||
|
||||
这里我列举下递归和反射的方式,帮你加深一下对这个技巧的理解。
|
||||
|
||||
反射的代码比较简短,易于理解,主要就是构造出SelectCase slice,然后传递给reflect.Select语句。
|
||||
|
||||
```
|
||||
func fanInReflect(chans ...<-chan interface{}) <-chan interface{} {
|
||||
out := make(chan interface{})
|
||||
go func() {
|
||||
defer close(out)
|
||||
// 构造SelectCase slice
|
||||
var cases []reflect.SelectCase
|
||||
for _, c := range chans {
|
||||
cases = append(cases, reflect.SelectCase{
|
||||
Dir: reflect.SelectRecv,
|
||||
Chan: reflect.ValueOf(c),
|
||||
})
|
||||
}
|
||||
|
||||
// 循环,从cases中选择一个可用的
|
||||
for len(cases) > 0 {
|
||||
i, v, ok := reflect.Select(cases)
|
||||
if !ok { // 此channel已经close
|
||||
cases = append(cases[:i], cases[i+1:]...)
|
||||
continue
|
||||
}
|
||||
out <- v.Interface()
|
||||
}
|
||||
}()
|
||||
return out
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
递归模式也是在Channel大于2时,采用二分法递归merge。
|
||||
|
||||
```
|
||||
func fanInRec(chans ...<-chan interface{}) <-chan interface{} {
|
||||
switch len(chans) {
|
||||
case 0:
|
||||
c := make(chan interface{})
|
||||
close(c)
|
||||
return c
|
||||
case 1:
|
||||
return chans[0]
|
||||
case 2:
|
||||
return mergeTwo(chans[0], chans[1])
|
||||
default:
|
||||
m := len(chans) / 2
|
||||
return mergeTwo(
|
||||
fanInRec(chans[:m]...),
|
||||
fanInRec(chans[m:]...))
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这里有一个mergeTwo的方法,是将两个Channel合并成一个Channel,是扇入形式的一种特例(只处理两个Channel)。 下面我来借助一段代码帮你理解下这个方法。
|
||||
|
||||
```
|
||||
func mergeTwo(a, b <-chan interface{}) <-chan interface{} {
|
||||
c := make(chan interface{})
|
||||
go func() {
|
||||
defer close(c)
|
||||
for a != nil || b != nil { //只要还有可读的chan
|
||||
select {
|
||||
case v, ok := <-a:
|
||||
if !ok { // a 已关闭,设置为nil
|
||||
a = nil
|
||||
continue
|
||||
}
|
||||
c <- v
|
||||
case v, ok := <-b:
|
||||
if !ok { // b 已关闭,设置为nil
|
||||
b = nil
|
||||
continue
|
||||
}
|
||||
c <- v
|
||||
}
|
||||
}
|
||||
}()
|
||||
return c
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
### 扇出模式
|
||||
|
||||
有扇入模式,就有扇出模式,扇出模式是和扇入模式相反的。
|
||||
|
||||
扇出模式只有一个输入源Channel,有多个目标Channel,扇出比就是1比目标Channel数的值,经常用在设计模式中的[观察者模式](https://baike.baidu.com/item/%E8%A7%82%E5%AF%9F%E8%80%85%E6%A8%A1%E5%BC%8F/5881786?fr=aladdin)中(观察者设计模式定义了对象间的一种一对多的组合关系。这样一来,一个对象的状态发生变化时,所有依赖于它的对象都会得到通知并自动刷新)。在观察者模式中,数据变动后,多个观察者都会收到这个变更信号。
|
||||
|
||||
下面是一个扇出模式的实现。从源Channel取出一个数据后,依次发送给目标Channel。在发送给目标Channel的时候,可以同步发送,也可以异步发送:
|
||||
|
||||
```
|
||||
func fanOut(ch <-chan interface{}, out []chan interface{}, async bool) {
|
||||
go func() {
|
||||
defer func() { //退出时关闭所有的输出chan
|
||||
for i := 0; i < len(out); i++ {
|
||||
close(out[i])
|
||||
}
|
||||
}()
|
||||
|
||||
for v := range ch { // 从输入chan中读取数据
|
||||
v := v
|
||||
for i := 0; i < len(out); i++ {
|
||||
i := i
|
||||
if async { //异步
|
||||
go func() {
|
||||
out[i] <- v // 放入到输出chan中,异步方式
|
||||
}()
|
||||
} else {
|
||||
out[i] <- v // 放入到输出chan中,同步方式
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
你也可以尝试使用反射的方式来实现,我就不列相关代码了,希望你课后可以自己思考下。
|
||||
|
||||
### Stream
|
||||
|
||||
这里我来介绍一种把Channel当作流式管道使用的方式,也就是把Channel看作流(Stream),提供跳过几个元素,或者是只取其中的几个元素等方法。
|
||||
|
||||
首先,我们提供创建流的方法。这个方法把一个数据slice转换成流:
|
||||
|
||||
```
|
||||
func asStream(done <-chan struct{}, values ...interface{}) <-chan interface{} {
|
||||
s := make(chan interface{}) //创建一个unbuffered的channel
|
||||
go func() { // 启动一个goroutine,往s中塞数据
|
||||
defer close(s) // 退出时关闭chan
|
||||
for _, v := range values { // 遍历数组
|
||||
select {
|
||||
case <-done:
|
||||
return
|
||||
case s <- v: // 将数组元素塞入到chan中
|
||||
}
|
||||
}
|
||||
}()
|
||||
return s
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
流创建好以后,该咋处理呢?下面我再给你介绍下实现流的方法。
|
||||
|
||||
1. takeN:只取流中的前n个数据;
|
||||
1. takeFn:筛选流中的数据,只保留满足条件的数据;
|
||||
1. takeWhile:只取前面满足条件的数据,一旦不满足条件,就不再取;
|
||||
1. skipN:跳过流中前几个数据;
|
||||
1. skipFn:跳过满足条件的数据;
|
||||
1. skipWhile:跳过前面满足条件的数据,一旦不满足条件,当前这个元素和以后的元素都会输出给Channel的receiver。
|
||||
|
||||
这些方法的实现很类似,我们以takeN为例来具体解释一下。
|
||||
|
||||
```
|
||||
func takeN(done <-chan struct{}, valueStream <-chan interface{}, num int) <-chan interface{} {
|
||||
takeStream := make(chan interface{}) // 创建输出流
|
||||
go func() {
|
||||
defer close(takeStream)
|
||||
for i := 0; i < num; i++ { // 只读取前num个元素
|
||||
select {
|
||||
case <-done:
|
||||
return
|
||||
case takeStream <- <-valueStream: //从输入流中读取元素
|
||||
}
|
||||
}
|
||||
}()
|
||||
return takeStream
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
### map-reduce
|
||||
|
||||
map-reduce是一种处理数据的方式,最早是由Google公司研究提出的一种面向大规模数据处理的并行计算模型和方法,开源的版本是hadoop,前几年比较火。
|
||||
|
||||
不过,我要讲的并不是分布式的map-reduce,而是单机单进程的map-reduce方法。
|
||||
|
||||
map-reduce分为两个步骤,第一步是映射(map),处理队列中的数据,第二步是规约(reduce),把列表中的每一个元素按照一定的处理方式处理成结果,放入到结果队列中。
|
||||
|
||||
就像做汉堡一样,map就是单独处理每一种食材,reduce就是从每一份食材中取一部分,做成一个汉堡。
|
||||
|
||||
我们先来看下map函数的处理逻辑:
|
||||
|
||||
```
|
||||
func mapChan(in <-chan interface{}, fn func(interface{}) interface{}) <-chan interface{} {
|
||||
out := make(chan interface{}) //创建一个输出chan
|
||||
if in == nil { // 异常检查
|
||||
close(out)
|
||||
return out
|
||||
}
|
||||
|
||||
go func() { // 启动一个goroutine,实现map的主要逻辑
|
||||
defer close(out)
|
||||
for v := range in { // 从输入chan读取数据,执行业务操作,也就是map操作
|
||||
out <- fn(v)
|
||||
}
|
||||
}()
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
reduce函数的处理逻辑如下:
|
||||
|
||||
```
|
||||
func reduce(in <-chan interface{}, fn func(r, v interface{}) interface{}) interface{} {
|
||||
if in == nil { // 异常检查
|
||||
return nil
|
||||
}
|
||||
|
||||
out := <-in // 先读取第一个元素
|
||||
for v := range in { // 实现reduce的主要逻辑
|
||||
out = fn(out, v)
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
我们可以写一个程序,这个程序使用map-reduce模式处理一组整数,map函数就是为每个整数乘以10,reduce函数就是把map处理的结果累加起来:
|
||||
|
||||
```
|
||||
// 生成一个数据流
|
||||
func asStream(done <-chan struct{}) <-chan interface{} {
|
||||
s := make(chan interface{})
|
||||
values := []int{1, 2, 3, 4, 5}
|
||||
go func() {
|
||||
defer close(s)
|
||||
for _, v := range values { // 从数组生成
|
||||
select {
|
||||
case <-done:
|
||||
return
|
||||
case s <- v:
|
||||
}
|
||||
}
|
||||
}()
|
||||
return s
|
||||
}
|
||||
|
||||
func main() {
|
||||
in := asStream(nil)
|
||||
|
||||
// map操作: 乘以10
|
||||
mapFn := func(v interface{}) interface{} {
|
||||
return v.(int) * 10
|
||||
}
|
||||
|
||||
// reduce操作: 对map的结果进行累加
|
||||
reduceFn := func(r, v interface{}) interface{} {
|
||||
return r.(int) + v.(int)
|
||||
}
|
||||
|
||||
sum := reduce(mapChan(in, mapFn), reduceFn) //返回累加结果
|
||||
fmt.Println(sum)
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
# 总结
|
||||
|
||||
这节课,我借助代码示例,带你学习了Channel的应用场景和应用模式。这几种模式不是我们学习的终点,而是学习的起点。掌握了这几种模式之后,我们可以延伸出更多的模式。
|
||||
|
||||
虽然Channel最初是基于CSP设计的用于goroutine之间的消息传递的一种数据类型,但是,除了消息传递这个功能之外,大家居然还演化出了各式各样的应用模式。我不确定Go的创始人在设计这个类型的时候,有没有想到这一点,但是,我确实被各位大牛利用Channel的各种点子折服了,比如有人实现了一个基于TCP网络的分布式的Channel。
|
||||
|
||||
在使用Go开发程序的时候,你也不妨多考虑考虑是否能够使用chan类型,看看你是不是也能创造出别具一格的应用模式。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/41/c9/4140728d1f331beaf92e712cd34681c9.jpg" alt="">
|
||||
|
||||
# 思考题
|
||||
|
||||
想一想,我们在利用chan实现互斥锁的时候,如果buffer设置的不是1,而是一个更大的值,会出现什么状况吗?能解决什么问题吗?
|
||||
|
||||
欢迎在留言区写下你的思考和答案,我们一起交流讨论。如果你觉得有所收获,也欢迎你把今天的内容分享给你的朋友或同事。
|
||||
507
极客时间专栏/geek/Go 并发编程实战课/Channel/15 | 内存模型:Go如何保证并发读写的顺序?.md
Normal file
507
极客时间专栏/geek/Go 并发编程实战课/Channel/15 | 内存模型:Go如何保证并发读写的顺序?.md
Normal file
@@ -0,0 +1,507 @@
|
||||
<audio id="audio" title="15 | 内存模型:Go如何保证并发读写的顺序?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/8b/aa/8b4b79d1a716766582d453c45eb87daa.mp3"></audio>
|
||||
|
||||
你好,我是鸟窝。
|
||||
|
||||
Go官方文档里专门介绍了Go的[内存模型](https://golang.org/ref/mem),你不要误解这里的内存模型的含义,它并不是指Go对象的内存分配、内存回收和内存整理的规范,它描述的是并发环境中多goroutine读相同变量的时候,变量的可见性条件。具体点说,就是指,在什么条件下,goroutine在读取一个变量的值的时候,能够看到其它goroutine对这个变量进行的写的结果。
|
||||
|
||||
由于CPU指令重排和多级Cache的存在,保证多核访问同一个变量这件事儿变得非常复杂。毕竟,不同CPU架构(x86/amd64、ARM、Power等)的处理方式也不一样,再加上编译器的优化也可能对指令进行重排,所以编程语言需要一个规范,来明确多线程同时访问同一个变量的可见性和顺序( Russ Cox在麻省理工学院 [6.824 分布式系统Distributed Systems课程](https://pdos.csail.mit.edu/6.824/) 的一课,专门介绍了相关的[知识](http://nil.csail.mit.edu/6.824/2016/notes/gomem.pdf))。在编程语言中,这个规范被叫做内存模型。
|
||||
|
||||
除了Go,Java、C++、C、C#、Rust等编程语言也有内存模型。为什么这些编程语言都要定义内存模型呢?在我看来,主要是两个目的。
|
||||
|
||||
- 向广大的程序员提供一种保证,以便他们在做设计和开发程序时,面对同一个数据同时被多个goroutine访问的情况,可以做一些串行化访问的控制,比如使用Channel或者sync包和sync/atomic包中的并发原语。
|
||||
- 允许编译器和硬件对程序做一些优化。这一点其实主要是为编译器开发者提供的保证,这样可以方便他们对Go的编译器做优化。
|
||||
|
||||
既然内存模型这么重要,今天,我们就来花一节课的时间学习一下。
|
||||
|
||||
首先,我们要先弄明白重排和可见性的问题,因为它们影响着程序实际执行的顺序关系。
|
||||
|
||||
# 重排和可见性的问题
|
||||
|
||||
**由于指令重排,代码并不一定会按照你写的顺序执行**。
|
||||
|
||||
举个例子,当两个goroutine同时对一个数据进行读写时,假设goroutine g1对这个变量进行写操作w,goroutine g2同时对这个变量进行读操作r,那么,如果g2在执行读操作r的时候,已经看到了g1写操作w的结果,那么,也不意味着g2能看到在w之前的其它的写操作。这是一个反直观的结果,不过的确可能会存在。
|
||||
|
||||
接下来,我再举几个具体的例子,带你来感受一下,重排以及多核CPU并发执行导致程序的运行和代码的书写顺序不一样的情况。
|
||||
|
||||
先看第一个例子,代码如下:
|
||||
|
||||
```
|
||||
var a, b int
|
||||
|
||||
func f() {
|
||||
a = 1 // w之前的写操作
|
||||
b = 2 // 写操作w
|
||||
}
|
||||
|
||||
func g() {
|
||||
print(b) // 读操作r
|
||||
print(a) // ???
|
||||
}
|
||||
|
||||
func main() {
|
||||
go f() //g1
|
||||
g() //g2
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
可以看到,第9行是要打印b的值。需要注意的是,即使这里打印出的值是2,但是依然可能在打印a的值时,打印出初始值0,而不是1。这是因为,程序运行的时候,不能保证g2看到的a和b的赋值有先后关系。
|
||||
|
||||
再来看一个类似的例子。
|
||||
|
||||
```
|
||||
var a string
|
||||
var done bool
|
||||
|
||||
func setup() {
|
||||
a = "hello, world"
|
||||
done = true
|
||||
}
|
||||
|
||||
func main() {
|
||||
go setup()
|
||||
for !done {
|
||||
}
|
||||
print(a)
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在这段代码中,主goroutine main即使观察到done变成true了,最后读取到的a的值仍然可能为空。
|
||||
|
||||
更糟糕的情况是,main根本就观察不到另一个goroutine对done的写操作,这就会导致main程序一直被hang住。甚至可能还会出现**半初始化**的情况,比如:
|
||||
|
||||
```
|
||||
type T struct {
|
||||
msg string
|
||||
}
|
||||
|
||||
var g *T
|
||||
|
||||
func setup() {
|
||||
t := new(T)
|
||||
t.msg = "hello, world"
|
||||
g = t
|
||||
}
|
||||
|
||||
func main() {
|
||||
go setup()
|
||||
for g == nil {
|
||||
}
|
||||
print(g.msg)
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
即使main goroutine观察到g不为nil,也可能打印出空的msg(第17行)。
|
||||
|
||||
看到这里,你可能要说了,我都运行这个程序几百万次了,怎么也没有观察到这种现象?我可以这么告诉你,能不能观察到和提供保证(guarantee)是两码事儿。由于CPU架构和Go编译器的不同,即使你运行程序时没有遇到这些现象,也不代表Go可以100%保证不会出现这些问题。
|
||||
|
||||
刚刚说了,程序在运行的时候,两个操作的顺序可能不会得到保证,那该怎么办呢?接下来,我要带你了解一下Go内存模型中很重要的一个概念:happens-before,这是用来描述两个时间的顺序关系的。如果某些操作能提供happens-before关系,那么,我们就可以100%保证它们之间的顺序。
|
||||
|
||||
# happens-before
|
||||
|
||||
在一个goroutine内部,程序的执行顺序和它们的代码指定的顺序是一样的,即使编译器或者CPU重排了读写顺序,从行为上来看,也和代码指定的顺序一样。
|
||||
|
||||
这是一个非常重要的保证,我们一定要记住。
|
||||
|
||||
我们来看一个例子。在下面的代码中,即使编译器或者CPU对a、b、c的初始化进行了重排,但是打印结果依然能保证是1、2、3,而不会出现1、0、0或1、0、1等情况。
|
||||
|
||||
```
|
||||
func foo() {
|
||||
var a = 1
|
||||
var b = 2
|
||||
var c = 3
|
||||
|
||||
println(a)
|
||||
println(b)
|
||||
println(c)
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
但是,对于另一个goroutine来说,重排却会产生非常大的影响。**因为Go只保证goroutine内部重排对读写的顺序没有影响**,比如刚刚我们在讲“可见性”问题时提到的三个例子,那该怎么办呢?这就要用到happens-before关系了。
|
||||
|
||||
如果两个action(read 或者 write)有明确的happens-before关系,你就可以确定它们之间的执行顺序(或者是行为表现上的顺序)。
|
||||
|
||||
Go内存模型通过happens-before定义两个事件(读、写action)的顺序:如果事件e1 happens before 事件e2,那么,我们就可以说事件e2在事件e1之后发生(happens after)。如果e1 不是happens before e2, 同时也不happens after e2,那么,我们就可以说事件e1和e2是同时发生的。
|
||||
|
||||
如果要保证对“变量**v**的读操作**r**”能够观察到一个对“变量**v**的写操作**w**”,并且**r**只能观察到**w**对变量**v**的写,没有其它对v的写操作,也就是说,我们要保证**r**绝对能观察到**w**操作的结果,那么就需要同时满足两个条件:
|
||||
|
||||
1. w happens before r;
|
||||
1. 其它对v的写操作(w2、w3、w4, ......) 要么happens before w,要么happens after r,绝对不会和w、r同时发生,或者是在它们之间发生。
|
||||
|
||||
你可能会说,这是很显然的事情啊,但我要和你说的是,这是一个非常严格、严谨的数学定义。
|
||||
|
||||
对于单个的goroutine来说,它有一个特殊的happens-before关系,Go内存模型中是这么讲的:
|
||||
|
||||
>
|
||||
Within a single goroutine, the happens-before order is the order expressed by the program.
|
||||
|
||||
|
||||
我来解释下这句话。它的意思是,在单个的goroutine内部, happens-before的关系和代码编写的顺序是一致的。
|
||||
|
||||
其实,在这一章的开头我已经用橙色把这句话标注出来了。我再具体解释下。
|
||||
|
||||
在goroutine内部对一个局部变量v的读,一定能观察到最近一次对这个局部变量v的写。如果要保证多个goroutine之间对一个共享变量的读写顺序,在Go语言中,可以使用并发原语为读写操作建立happens-before关系,这样就可以保证顺序了。
|
||||
|
||||
说到这儿,我想先给你补充三个Go语言中和内存模型有关的小知识,掌握了这些,你就能更好地理解下面的内容。
|
||||
|
||||
1. 在Go语言中,对变量进行零值的初始化就是一个写操作。
|
||||
1. 如果对超过机器word(64bit、32bit或者其它)大小的值进行读写,那么,就可以看作是对拆成word大小的几个读写无序进行。
|
||||
1. Go并不提供直接的CPU屏障(CPU fence)来提示编译器或者CPU保证顺序性,而是使用不同架构的内存屏障指令来实现统一的并发原语。
|
||||
|
||||
接下来,我就带你学习下Go语言中提供的happens-before关系保证。
|
||||
|
||||
# Go语言中保证的happens-before关系
|
||||
|
||||
除了单个goroutine内部提供的happens-before保证,Go语言中还提供了一些其它的happens-before关系的保证,下面我来一个一个介绍下。
|
||||
|
||||
## init函数
|
||||
|
||||
应用程序的初始化是在单一的goroutine执行的。如果包p导入了包q,那么,q的init函数的执行一定 happens before p的任何初始化代码。
|
||||
|
||||
这里有一个特殊情况需要你记住:**main函数一定在导入的包的init函数之后执行**。
|
||||
|
||||
包级别的变量在同一个文件中是按照声明顺序逐个初始化的,除非初始化它的时候依赖其它的变量。同一个包下的多个文件,会按照文件名的排列顺序进行初始化。这个顺序被定义在[Go语言规范](https://golang.org/ref/spec#Program_initialization_and_execution)中,而不是Go的内存模型规范中。你可以看看下面的例子中各个变量的值:
|
||||
|
||||
```
|
||||
var (
|
||||
a = c + b // == 9
|
||||
b = f() // == 4
|
||||
c = f() // == 5
|
||||
d = 3 // == 5 全部初始化完成后
|
||||
)
|
||||
|
||||
func f() int {
|
||||
d++
|
||||
return d
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
具体怎么对这些变量进行初始化呢?Go采用的是依赖分析技术。不过,依赖分析技术保证的顺序只是针对同一包下的变量,而且,只有引用关系是本包变量、函数和非接口的方法,才能保证它们的顺序性。
|
||||
|
||||
同一个包下可以有多个init函数,甚至一个文件中也可以包含多个相同签名的init函数。
|
||||
|
||||
刚刚讲的这些都是不同包的init函数执行顺序,下面我举一个具体的例子,把这些内容串起来,你一看就明白了。
|
||||
|
||||
这个例子是一个**main**程序,它依赖包p1,包p1依赖包p2,包p2依赖p3。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/d5/2a/d5059fab1977602934339e18f9eddb2a.jpg" alt="">
|
||||
|
||||
为了追踪初始化过程,并输出有意义的日志,我定义了一个辅助方法,打印出日志并返回一个用来初始化的整数值:
|
||||
|
||||
```
|
||||
func Trace(t string, v int) int {
|
||||
fmt.Println(t, ":", v)
|
||||
return v
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
包**p3**包含两个文件,分别定义了一个init函数。第一个文件中定义了两个变量,这两个变量的值还会在init函数中进行修改。
|
||||
|
||||
我们来分别看下包p3的这两个文件:
|
||||
|
||||
```
|
||||
// lib1.go in p3
|
||||
|
||||
var V1_p3 = trace.Trace("init v1_p3", 3)
|
||||
var V2_p3 = trace.Trace("init v2_p3", 3)
|
||||
|
||||
|
||||
func init() {
|
||||
fmt.Println("init func in p3")
|
||||
V1_p3 = 300
|
||||
V2_p3 = 300
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
```
|
||||
// lib2.go in p3
|
||||
|
||||
func init() {
|
||||
fmt.Println("another init func in p3")
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
下面再来看看包p2。包p2定义了变量和init函数。第一个变量初始化为2,并在init函数中更改为200。第二个变量是复制的p3.V2_p3。
|
||||
|
||||
```
|
||||
var V1_p2 = trace.Trace("init v1_p2", 2)
|
||||
var V2_p2 = trace.Trace("init v2_p2", p3.V2_p3)
|
||||
|
||||
func init() {
|
||||
fmt.Println("init func in p2")
|
||||
V1_p2 = 200
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
包**p1**定义了变量和init函数。它的两个变量的值是复制的p2对应的两个变量值。
|
||||
|
||||
```
|
||||
var V1_p1 = trace.Trace("init v1_p1", p2.V1_p2)
|
||||
var V2_p1 = trace.Trace("init v2_p1", p2.V2_p2)
|
||||
|
||||
func init() {
|
||||
fmt.Println("init func in p1")
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
**main**定义了init函数和main函数。
|
||||
|
||||
```
|
||||
func init() {
|
||||
fmt.Println("init func in main")
|
||||
}
|
||||
|
||||
|
||||
func main() {
|
||||
fmt.Println("V1_p1:", p1.V1_p1)
|
||||
fmt.Println("V2_p1:", p1.V2_p1)
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
运行main函数会依次输出p3、p2、p1、main的初始化变量时的日志(变量初始化时的日志和init函数调用时的日志):
|
||||
|
||||
```
|
||||
// 包p3的变量初始化
|
||||
init v1_p3 : 3
|
||||
init v2_p3 : 3
|
||||
// p3的init函数
|
||||
init func in p3
|
||||
// p3的另一个init函数
|
||||
another init func in p3
|
||||
|
||||
// 包p2的变量初始化
|
||||
init v1_p2 : 2
|
||||
init v2_p2 : 300
|
||||
// 包p2的init函数
|
||||
init func in p2
|
||||
|
||||
// 包p1的变量初始化
|
||||
init v1_p1 : 200
|
||||
init v2_p1 : 300
|
||||
// 包p1的init函数
|
||||
init func in p1
|
||||
|
||||
// 包main的init函数
|
||||
init func in main
|
||||
// main函数
|
||||
V1_p1: 200
|
||||
V2_p1: 300
|
||||
|
||||
```
|
||||
|
||||
下面,我们再来看看goroutine对happens-before关系的保证情况。
|
||||
|
||||
## goroutine
|
||||
|
||||
首先,我们需要明确一个规则:**启动goroutine的go语句的执行,一定happens before此goroutine内的代码执行。**
|
||||
|
||||
根据这个规则,我们就可以知道,如果go语句传入的参数是一个函数执行的结果,那么,这个函数一定先于goroutine内部的代码被执行。
|
||||
|
||||
我们来看一个例子。在下面的代码中,第8行a的赋值和第9行的go语句是在同一个goroutine中执行的,所以,在主goroutine看来,第8行肯定happens before 第9行,又由于刚才的保证,第9行子goroutine的启动happens before 第4行的变量输出,那么,我们就可以推断出,第8行happens before 第4行。也就是说,在第4行打印a的值的时候,肯定会打印出“hello world”。
|
||||
|
||||
```
|
||||
var a string
|
||||
|
||||
func f() {
|
||||
print(a)
|
||||
}
|
||||
|
||||
func hello() {
|
||||
a = "hello, world"
|
||||
go f()
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
刚刚说的是启动goroutine的情况,goroutine退出的时候,是没有任何happens-before保证的。所以,如果你想观察某个goroutine的执行效果,你需要使用同步机制建立happens-before关系,比如Mutex或者Channel。接下来,我会讲Channel的happens-before的关系保证。
|
||||
|
||||
## Channel
|
||||
|
||||
Channel是goroutine同步交流的主要方法。往一个Channel中发送一条数据,通常对应着另一个goroutine从这个Channel中接收一条数据。
|
||||
|
||||
通用的Channel happens-before关系保证有4条规则,我分别来介绍下。
|
||||
|
||||
**第1条规则是**,往Channel中的发送操作,happens before 从该Channel接收相应数据的动作完成之前,即第n个send一定happens before第n个receive的完成。
|
||||
|
||||
```
|
||||
var ch = make(chan struct{}, 10) // buffered或者unbuffered
|
||||
var s string
|
||||
|
||||
func f() {
|
||||
s = "hello, world"
|
||||
ch <- struct{}{}
|
||||
}
|
||||
|
||||
func main() {
|
||||
go f()
|
||||
<-ch
|
||||
print(s)
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在这个例子中,s的初始化(第5行)happens before 往ch中发送数据, 往ch发送数据 happens before从ch中读取出一条数据(第11行),第12行打印s的值 happens after第11行,所以,打印的结果肯定是初始化后的s的值“hello world”。
|
||||
|
||||
**第2条规则是**,close一个Channel的调用,肯定happens before 从关闭的Channel中读取出一个零值。
|
||||
|
||||
还是拿刚刚的这个例子来说,如果你把第6行替换成 close(ch),也能保证同样的执行顺序。因为第11行从关闭的ch中读取出零值后,第6行肯定被调用了。
|
||||
|
||||
**第3条规则是**,对于unbuffered的Channel,也就是容量是0的Channel,从此Channel中读取数据的调用一定happens before 往此Channel发送数据的调用完成。
|
||||
|
||||
所以,在上面的这个例子中呢,如果想保持同样的执行顺序,也可以写成这样:
|
||||
|
||||
```
|
||||
var ch = make(chan int)
|
||||
var s string
|
||||
|
||||
func f() {
|
||||
s = "hello, world"
|
||||
<-ch
|
||||
}
|
||||
|
||||
func main() {
|
||||
go f()
|
||||
ch <- struct{}{}
|
||||
print(s)
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
如果第11行发送语句执行成功(完毕),那么根据这个规则,第6行(接收)的调用肯定发生了(执行完成不完成不重要,重要的是这一句“肯定执行了”),那么s也肯定初始化了,所以一定会打印出“hello world”。
|
||||
|
||||
这一条比较晦涩,但是,因为Channel是unbuffered的Channel,所以这个规则也成立。
|
||||
|
||||
**第4条规则是**,如果Channel的容量是m(m>0),那么,第n个receive一定happens before 第 n+m 个 send的完成。
|
||||
|
||||
前一条规则是针对unbuffered channel的,这里给出了更广泛的针对buffered channel的保证。利用这个规则,我们可以实现信号量(Semaphore)的并发原语。Channel的容量相当于可用的资源,发送一条数据相当于请求信号量,接收一条数据相当于释放信号。关于信号量这个并发原语,我会在下一讲专门给你介绍一下,这里你只需要知道它可以控制多个资源的并发访问,就可以了。
|
||||
|
||||
## Mutex/RWMutex
|
||||
|
||||
对于互斥锁Mutex m或者读写锁RWMutex m,有3条happens-before关系的保证。
|
||||
|
||||
1. 第n次的m.Unlock一定happens before第n+1 m.Lock方法的返回;
|
||||
1. 对于读写锁RWMutex m,如果它的第n个m.Lock方法的调用已返回,那么它的第n个m.Unlock的方法调用一定happens before 任何一个m.RLock方法调用的返回,只要这些m.RLock方法调用 happens after 第n次m.Lock的调用的返回。这就可以保证,只有释放了持有的写锁,那些等待的读请求才能请求到读锁。
|
||||
1. 对于读写锁RWMutex m,如果它的第n个m.RLock方法的调用已返回,那么它的第k (k<=n)个成功的m.RUnlock方法的返回一定happens before 任意的m.RUnlockLock方法调用,只要这些m.Lock方法调用happens after第n次m.RLock。
|
||||
|
||||
读写锁的保证有点绕,我再带你看看官方的描述:
|
||||
|
||||
>
|
||||
对于读写锁l的 l.RLock方法调用,如果存在一个**n**,这次的l.RLock调用 happens after 第n次的l.Unlock,那么,和这个RLock相对应的l.RUnlock一定happens before 第n+1次l.Lock。意思是,读写锁的Lock必须等待既有的读锁释放后才能获取到。
|
||||
|
||||
|
||||
我再举个例子。在下面的代码中,第6行第一次的Unlock一定happens before第二次的Lock(第12行),所以这也能保证正确地打印出“hello world”。
|
||||
|
||||
```
|
||||
var mu sync.Mutex
|
||||
var s string
|
||||
|
||||
func foo() {
|
||||
s = "hello, world"
|
||||
mu.Unlock()
|
||||
}
|
||||
|
||||
func main() {
|
||||
mu.Lock()
|
||||
go foo()
|
||||
mu.Lock()
|
||||
print(s)
|
||||
|
||||
```
|
||||
|
||||
## WaitGroup
|
||||
|
||||
接下来是WaitGroup的保证。
|
||||
|
||||
对于一个WaitGroup实例wg,在某个时刻t0时,它的计数值已经不是零了,假如t0时刻之后调用了一系列的wg.Add(n)或者wg.Done(),并且只有最后一次调用wg的计数值变为了0,那么,可以保证这些wg.Add或者wg.Done()一定 happens before t0时刻之后调用的wg.Wait方法的返回。
|
||||
|
||||
这个保证的通俗说法,就是**Wait方法等到计数值归零之后才返回**。
|
||||
|
||||
## Once
|
||||
|
||||
我们在[第8讲](https://time.geekbang.org/column/article/301113)学过Once了,相信你已经很熟悉它的功能了。它提供的保证是:**对于once.Do(f)调用,f函数的那个单次调用一定happens before 任何once.Do(f)调用的返回**。换句话说,就是函数f一定会在Do方法返回之前执行。
|
||||
|
||||
还是以hello world的例子为例,这次我们使用Once并发原语实现,可以看下下面的代码:
|
||||
|
||||
```
|
||||
var s string
|
||||
var once sync.Once
|
||||
|
||||
func foo() {
|
||||
s = "hello, world"
|
||||
}
|
||||
|
||||
func twoprint() {
|
||||
once.Do(foo)
|
||||
print(s)
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
第5行的执行一定happens before第9行的返回,所以执行到第10行的时候,sd已经初始化了,所以会正确地打印“hello world”。
|
||||
|
||||
最后,我再来说说atomic的保证。
|
||||
|
||||
## atomic
|
||||
|
||||
其实,Go内存模型的官方文档并没有明确给出atomic的保证,有一个相关的issue [go# 5045](https://github.com/golang/go/issues/5045)记录了相关的讨论。光看issue号,就知道这个讨论由来已久了。Russ Cox想让atomic有一个弱保证,这样可以为以后留下充足的可扩展空间,所以,Go内存模型规范上并没有严格的定义。
|
||||
|
||||
对于Go 1.15的官方实现来说,可以保证使用atomic的Load/Store的变量之间的顺序性。
|
||||
|
||||
在下面的例子中,打印出的a的结果总是1,但是官方并没有做任何文档上的说明和保证。
|
||||
|
||||
依照Ian Lance Taylor的说法,Go核心开发组的成员几乎没有关注这个方向上的研究,因为这个问题太复杂,有很多问题需要去研究,所以,现阶段还是不要使用atomic来保证顺序性。
|
||||
|
||||
```
|
||||
func main() {
|
||||
var a, b int32 = 0, 0
|
||||
|
||||
go func() {
|
||||
atomic.StoreInt32(&a, 1)
|
||||
atomic.StoreInt32(&b, 1)
|
||||
}()
|
||||
|
||||
for atomic.LoadInt32(&b) == 0{
|
||||
runtime.Gosched()
|
||||
}
|
||||
fmt.Println(atomic.LoadInt32(&a))
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
# 总结
|
||||
|
||||
Go的内存模型规范中,一开始有这么一段话:
|
||||
|
||||
>
|
||||
If you must read the rest of this document to understand the behavior of your program, you are being too clever.
|
||||
|
||||
|
||||
>
|
||||
Don't be clever.
|
||||
|
||||
|
||||
我来说说我对这句话的理解:你通过学习这节课来理解你的程序的行为是聪明的,但是,不要自作聪明。
|
||||
|
||||
谨慎地使用这些保证,能够让你的程序按照设想的happens-before关系执行,但是不要以为完全理解这些概念和保证,就可以随意地制造所谓的各种技巧,否则就很容易掉进“坑”里,而且会给代码埋下了很多的“定时炸弹”。
|
||||
|
||||
比如,Go里面已经有值得信赖的互斥锁了,如果没有额外的需求,就不要使用Channel创造出自己的互斥锁。
|
||||
|
||||
当然,我也不希望你畏手畏脚地把思想局限住,我还是建议你去做一些有意义的尝试,比如使用Channel实现信号量等扩展并发原语。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/dc/4d/dc68fc5f93a4af96c8f4d45d6282104d.jpg" alt="">
|
||||
|
||||
# 思考题
|
||||
|
||||
我们知道,Channel可以实现互斥锁,那么,我想请你思考一下,它是如何利用happens-before关系保证锁的请求和释放的呢?
|
||||
|
||||
欢迎在留言区写下你的思考和答案,我们一起交流讨论。如果你觉得有所收获,也欢迎你把今天的内容分享给你的朋友或同事。
|
||||
Reference in New Issue
Block a user