go的chan通道原理和select机制

234 阅读13分钟

一、概述

分析的sdk版本:go 1.19 这部分的总结是走读部分的补充,也是对走读部分的一个全面整理整合。

二、chan的原理

2.1、结构

type hchan struct {
	qcount   uint           // total data in the queue
	dataqsiz uint           // size of the circular queue
	buf      unsafe.Pointer // points to an array of dataqsiz elements
	elemsize uint16
	closed   uint32
	elemtype *_type // element type
	sendx    uint   // send index
	recvx    uint   // receive index
	recvq    waitq  // list of recv waiters
	sendq    waitq  // list of send waiters
	lock mutex
}

type waitq struct {
	first *sudog
	last  *sudog
}

通道chan分带缓存和不带缓存,通过初始化来设置。带缓存则内部维护了一个队列。

和内部缓存队列相关的如下

qcount --- 当前缓存中存储的元素数量,send的时候加1,rcv接收的时候减1。

datasiz --- 初始的时候指定的缓存队列元素容纳的数量。

buf ---指向实际存储的数据的首地址处。

sendx --向通道中发送数据后sendx加1,当索引达到datasiz重新调整为0,用来记录存储的偏移位置

recvx -- 向通道中取数据后recvx加1,当索引达到datasiz重新调整为 0,用来记录存取的偏移位置

其他字段含义如下

elemsize -- 初始指定的类型占用的存储空间的大小

elemtype --初始的时候指定的元素的类型。

closed -- 关闭通道标识 1--关闭

lock -- 运行时锁,保护相应的hchan中的数据

sendq --- 通道发送的等待队列,当无法发送数据的时候,需要将当前协程放到发送等待队列中。

recvq -- 通道接收的等待队列,当无法接收数据的时候,需要将当前协程放到接收等待队列中。

2.2、实现机制

chan通道的实现不是很复杂,相对于sync包感觉简单多了。chan的核心本质是什么?

运行时锁+ 缓存队列+发送等待队列+接收等待队列+协程挂起唤醒

这几个元素的混搭。

首先来看无缓存模式

2.2.1、无缓存

send:

如果接收的等待队列中有数据,则直接将要发送的数据拷贝给接收的sudog的elem中。
如果接收等待队列无数据,则将发送的数据组装到sudog的elem中,组装好sudog后放到发送队列中,
然后挂起当前协程,等待接收的协程来唤醒。

recv:

1.如果发送的等待队列中有数据,则从发送队列中获取的sudog中的elem赋值给要接收的变量数据。
2.如果1不满足,则将接收的变量地址赋值给sudog的elem,然后放到接收队列中。将当前协程挂起。
  等待发送协程唤醒。

2.2.2、带缓存

send:

和无缓存相比较的区别是,如果hchan通道的本地缓存队列能放数据,则直接将数据拷贝到缓存队列中。
维护好写的位置,也就是sendx.

recv:

和无缓存相比较,如果本地通道缓存队列能取数据,则从缓存队列中取数据,维护好读位置recvx.

2.3、通道数据跟踪

通道实际是引用对象,内部也是指针的hchan对象。

我们先来考察初始化的时候的对象存储分配:

因为涉及到gc的处理,如果通道的元素数据类型不带指针则直接在hchan对象后延续存储,把这个当成整体开辟空间就可以,但是如果元素数据带指针需要gc回收处理所以要单独开辟空间。

知道了通道实际内部对象的存储以后,我们来看一下send 和recv 数据的走向是如何的?send和recv函数的参数的真正含义是什么?到底unsafe.Pointer是不是any(interface{})类型还是其他的?

我们的关注点在外部程序是怎么把数据存储到通道的,然后又是如何从通道中取的。

再有一个关注点我们来看一下通道是如何保证数据读写的并发安全的。

向通道发送数据(Send) -->

函数形式:

func chansend1(c *hchan, elem unsafe.Pointer) { chansend(c, elem, true, getcallerpc()) }

应用层的操作类似如下的形式:

ch:=make(chan int) var m int =200 println(&m) ch <- m

编译器内部调用的是chansend1,以上面的例子

ch ---- c

elem 是 unsafe.Pointer类型,那这个参数是什么类型呢?

我们跟踪上面的应用层的例子来看看。

我们在发送数据给通道前打印一下m的地址。
0xc00011df40
(dlv) args elem
elem = unsafe.Pointer(0xc00011df48)

(dlv) x -fmt hex -count 48 0xc00011df40
0xc00011df40:   0xc8   0x00   0x00   0x00   0x00   0x00   0x00   0x00
0xc00011df48:   0xc8   0x00   0x00   0x00   0x00   0x00   0x00   0x00
0xc00011df50:   0x40   0x82   0x01   0x00   0xc0   0x00   0x00   0x00
0xc00011df58:   0x40   0xdf   0x11   0x00   0xc0   0x00   0x00   0x00
0xc00011df60:   0x78   0x80   0x00   0x00   0xc0   0x00   0x00   0x00
0xc00011df68:   0xd0   0xe0   0x01   0x00   0xc0   0x00   0x00   0x00

从中我们很容易发现elem是存储的指针类型,指针指向内容就是声明通道的时候指定的具体类型。

对于int类型就是存储的值是200,占用8字节。

对于其他pointer类型存储的值就是地址,也占用8字节。

通过send的处理我们知道,当缓存满了或者没有缓存的时候发送的elem是存储到一个sudog结构的elem中的。

  mysg := acquireSudog()
	mysg.elem = ep
	mysg.g = gp //gp是当前的协程
	mysg.c = c

上面的mysg入队列放到等待的发送队列sendq中。

如果有缓存,且能放入数据,则放到c.buf的sendx对应的位置处。如下:

		qp := chanbuf(c, c.sendx)  //找到存放的位置
    //写屏障的拷贝数据到缓存队列中. 是将ep地址处的内容按照对应的类型大小直接拷贝到qp地址处。
		typedmemmove(c.elemtype, qp, ep)

上面的是发送的数据的情况。下面分析一下接收数据的情况

向通道获取数据-- recv:

函数形式:

// entry points for <- c from compiled code
//go:nosplit
func chanrecv1(c *hchan, elem unsafe.Pointer) {
	chanrecv(c, elem, true)
}

我们写个例子跟踪看看elem

ch:=make(chan int)
go func() {
 		var ret int
		println(&ret)
		ret = <-ch
}()
var m int =200
println(&m)
ch <- m

dlv调试跟踪如下:

&ret = 0xc00007dee8
形参 elem 的地址
(dlv) x -fmt hex -count 48 0xc00007df00
0xc00007df00:   0x00   0x00   0x00   0x00   0x00   0x00   0x00   0x00   
0xc00007df08:   0x40   0x82   0x01   0x00   0xc0   0x00   0x00   0x00   
0xc00007df10:   0xe8   0xde   0x07   0x00   0xc0   0x00   0x00   0x00
0xc00007df18:   0x00   0x00   0x00   0x00   0x00   0x00   0x00   0x00
0xc00007df20:   0x00   0x00   0x00   0x00   0x00   0x00   0x00   0x00
0xc00007df28:   0x00   0x00   0x00   0x00   0x00   0x00   0x00   0x00

(dlv) x -fmt hex -count 48 0xc00007dee8
0xc00007dee8:   0x00   0x00   0x00   0x00   0x00   0x00   0x00   0x00
0xc00007def0:   0x00   0x00   0x00   0x00   0x00   0x00   0x00   0x00
0xc00007def8:   0x00   0x00   0x00   0x00   0x00   0x00   0x00   0x00
0xc00007df00:   0x00   0x00   0x00   0x00   0x00   0x00   0x00   0x00
0xc00007df08:   0x40   0x82   0x01   0x00   0xc0   0x00   0x00   0x00
0xc00007df10:   0xe8   0xde   0x07   0x00   0xc0   0x00   0x00   0x00

从通道取完数据后
(dlv) x -fmt hex -count 48 0xc00007dee8
0xc00007dee8:   0x00   0x00   0x00   0x00   0x00   0x00   0x00   0x00
0xc00007def0:   0x00   0x00   0x00   0x00   0x00   0x00   0x00   0x00
0xc00007def8:   0x00   0x00   0x00   0x00   0x00   0x00   0x00   0x00
0xc00007df00:   0xc8   0x00   0x00   0x00   0x00   0x00   0x00   0x00

然后将形参的数据赋值给实际的变量数据.

通道接收的一个是如果缓存队列中有,从缓存队列取,如果没有则从发送等待队列中取。

从缓存队列取:

		qp := chanbuf(c, c.recvx)
		if ep != nil {
			typedmemmove(c.elemtype, ep, qp)
		}
		typedmemclr(c.elemtype, qp)
    //这段的功能是将队列中recvx处的数据内存拷贝到ep中,然后清除队列中的qp处的值,方便后续再放入
    新的数据。
    

从发送等待队列中取:

取得时候会判断如果有缓存,表示缓存满了,然后还是从缓存中取。
如果没有缓存则送发送队列得sudog的elem中取。
func recvDirect(t *_type, sg *sudog, dst unsafe.Pointer) {
	src := sg.elem
	typeBitsBulkBarrier(t, uintptr(dst), uintptr(src), t.size)
	memmove(dst, src, t.size)
}

总结一下通过通道传递数据的链路。

向通道发送数据:(也是send的处理流程)

画板

向通道接收数据:(recv的处理流程)

画板

形参elem中取到数据后,上层就是正常的局部变量从形参中获取数据就可以了。

2.4、通道如何保证并发安全

1、共享的本地缓存队列的读写时加锁访问的。

2、唤醒等待队列也是加锁访问的。在唤醒等待协程之前数据交换已经完成。

综合1和2,通道传递数据是并发安全的。

2.5、注意项

1、通道使用完就可以关闭,关闭后还能从通道获取数据。 2、通道对象可以为nil,就是不make,直接声明一个通道,然后直接从通道获取数据就能实现类似 select{} 一样的效果,就是协程一直阻塞下去,不再被唤醒了。 具体的写法如下: var ch struct{} go func() { <-ch }() 通过代码我们知道通道对象为nil ,实际调用的是 gopark(nil, nil, waitReasonChanReceiveNilChan, traceEvGoStop, 2) 3、可以有单通道的变量,就是只是单向的,只能接收或者只能发送。在函数参数或者变量中都可以传入这样的类型。开源库中经常能见到。 类似这样 c := make(chan int) var send chan<- int = c var recv <-chan int = c

三、select的原理

3.1、结构

type scase struct {
	c    *hchan         // chan
	elem unsafe.Pointer // data element
}

3.2、实现机制

根据代码走读我们知道select { case ......} ,编译器内部实际调用的是selectgo。(在runtime/select.go)

selectgo函数的形式如下:


func selectgo(cas0 *scase, order0 *uint16, pc0 *uintptr, nsends, nrecvs int, block bool) (int, bool) {

}

我们简单来看看函数参数意义

cas0 --- 顾名思义,cas对应的是选择分支,每个分支都是cas0的一个元素,cas0是个指向数组的指针,数组为[1<<16]scase。

order0 -- 排序用的,因为选择cas分支是随机的,用来实现随机分支功能。

pc0 -- 没有race的时候为nil。

nsends -- 向通道发送的分支数量

nrecvs -- 所有向通道接收的分支的数量

block -- 是否阻塞标志 . false 不阻塞。select有default 为false , 没有default为true.

我们在总结chan的时候发现无乱是向通道发送数据还是从通道接收数据,必备的参数一个是hchan对象,一个是外部发送或者接收的形参数据.也就是elem。所以scase中的设计中也要这样才能在每个分支的通道发送接收完成对应的处理。

也就是scase中要有hchan和elem。

selectgo的内部实现分3个阶段:

阶段一:
1)随机分支选项
2)从scase数组中取出每个分支,判断是否能够通道发送或者接收数据的情况,
   如果该分支能触发则直接按照chan的发送或者接收来处理,然后处理完毕返回。
   如果不能会一直轮训完所有分支。
          
阶段二:
  功能:所有的通道入等待队列。
  每个分支的chan 都要构造一个sudog对象,然后将sudog.g=getg(当前协程),
  sudog.elem=scase.elem
  注意当前协程也要维护一个等待队列,就是将所有sudog对象挂到当前协程的等待队列中。如下图所示
   

然后将这些sudog放到对应通道的sendq或者recvq中。
最后是挂起当前协程。
当有通道触发唤醒后,会将触发的sudog放到getg.param中。
所以我们就得到了是哪个chan触发了唤醒。

阶段三:
   这部分主要是有一个触发了,其他的通道中要从队列中删除。也就是清理其他通道上等待队列中当前协程的
   那个sudog要出队列。

至此,select的基本流程就理顺完了。

3.3、关于select{}

有时候我们会看到这样的一种写法 select{}, 那这个相比较于for {} 的区别在哪里呢?

通过测试用例反编译,我们知道select内部调用的是select.go中的block.

func block() { gopark(nil, nil, waitReasonSelectNoCases, traceEvGoStop, 1) // forever }

很明显是永久挂起当前的协程了。而for 是一直循环,for是抢占cpu资源一直死循环轮询。