Golang的Channel是其CSP并发模型的关键组成部分,也是Goroutine之间通信的桥梁。Channel在Golang中被频繁使用,深入了解其内部实现原理至关重要。本文将基于Go 1.13源码分析Channel的底层实现。
在正式分析Channel实现之前,先回顾其基本用法:
<code class="language-go">package main import "fmt" func main() { c := make(chan int) go func() { c <- 1 // 发送操作 }() x := <-c // 接收操作 fmt.Println(x) }</code>
这段代码展示了Channel的两个基本操作:
c <- 1
x := <-c
Channel分为缓冲Channel和非缓冲Channel。上述代码使用了非缓冲Channel。在非缓冲Channel中,如果当前没有其他Goroutine接收数据,发送方会在发送语句处阻塞。
初始化Channel时可以指定缓冲区大小,例如make(chan int, 2)
指定缓冲区大小为2。在缓冲区未满之前,发送方可以无阻塞地发送数据,无需等待接收方准备好。但如果缓冲区已满,发送方仍然会阻塞。
在深入Channel源码之前,需要找到Golang中Channel的具体实现位置。使用Channel时,实际上调用的是runtime.makechan
、runtime.chansend
和runtime.chanrecv
等底层函数。
可以使用go tool compile -N -l -S hello.go
命令将代码转换为汇编指令,或者使用在线工具Compiler Explorer (例如:go.godbolt.org/z/3xw5Cj)。通过分析汇编指令,可以发现:
make(chan int)
对应runtime.makechan
函数。c <- 1
对应runtime.chansend
函数。x := <-c
对应runtime.chanrecv
函数。这些函数的实现都位于Go源码的runtime/chan.go
文件中。
make(chan int)
会被编译器转换为runtime.makechan
函数,其函数签名如下:
<code class="language-go">func makechan(t *chantype, size int) *hchan</code>
其中,t *chantype
是Channel元素类型,size int
是用户指定的缓冲区大小(未指定则为0),返回值是*hchan
。hchan
是Golang中Channel的内部实现结构体,定义如下:
<code class="language-go">type hchan struct { qcount uint // 缓冲区中已放入元素的数量 dataqsiz uint // 用户构造Channel时指定的缓冲区大小 buf unsafe.Pointer // 缓冲区 elemsize uint16 // 缓冲区中每个元素的大小 closed uint32 // Channel是否关闭,==0表示未关闭 elemtype *_type // Channel元素的类型信息 sendx uint // 缓冲区中发送元素的索引位置(发送索引) recvx uint // 缓冲区中接收元素的索引位置(接收索引) recvq waitq // 等待接收的Goroutine列表 sendq waitq // 等待发送的Goroutine列表 lock mutex }</code>
hchan
中的属性大致分为三类:
buf
, dataqsiz
, qcount
等。当Channel的缓冲区大小不为0时,缓冲区用于存储待接收的数据,使用环形缓冲区实现。recvq
包含等待接收数据的Goroutine,sendq
包含等待发送数据的Goroutine。waitq
使用双向链表实现。lock
, elemtype
, closed
等。makechan
函数主要进行一些合法性检查和缓冲区、hchan
等属性的内存分配,这里不再深入讨论。
基于hchan
属性的简单分析,可以看出其中有两个重要的组成部分:缓冲区和等待队列。hchan
的所有行为和实现都围绕这两个组成部分展开。
Channel的发送和接收过程非常相似。先分析Channel的发送过程(例如c <- 1
)。
尝试向Channel发送数据时,如果recvq
队列不为空,则会先从recvq
头部取出一个等待接收数据的Goroutine,并将数据直接发送给该Goroutine。代码如下:
<code class="language-go">package main import "fmt" func main() { c := make(chan int) go func() { c <- 1 // 发送操作 }() x := <-c // 接收操作 fmt.Println(x) }</code>
recvq
包含等待接收数据的Goroutine。当一个Goroutine使用接收操作(例如x := <-c
)时,如果此时sendq
不为空,则会从sendq
中取出一个Goroutine,并将数据发送给它。
如果recvq
为空,则表示此时没有Goroutine等待接收数据,Channel会尝试将数据放入缓冲区:
<code class="language-go">func makechan(t *chantype, size int) *hchan</code>
这段代码的功能很简单,就是将数据放入缓冲区。这个过程涉及环形缓冲区的操作,dataqsiz
表示用户指定的缓冲区大小(未指定则默认为0)。
如果使用的是非缓冲Channel或者缓冲区已满(c.qcount == c.dataqsiz
),则会将待发送的数据和当前Goroutine打包成sudog
对象,放入sendq
,并将当前Goroutine设置为等待状态:
<code class="language-go">type hchan struct { qcount uint // 缓冲区中已放入元素的数量 dataqsiz uint // 用户构造Channel时指定的缓冲区大小 buf unsafe.Pointer // 缓冲区 elemsize uint16 // 缓冲区中每个元素的大小 closed uint32 // Channel是否关闭,==0表示未关闭 elemtype *_type // Channel元素的类型信息 sendx uint // 缓冲区中发送元素的索引位置(发送索引) recvx uint // 缓冲区中接收元素的索引位置(接收索引) recvq waitq // 等待接收的Goroutine列表 sendq waitq // 等待发送的Goroutine列表 lock mutex }</code>
goparkunlock
会解锁输入的互斥锁并挂起当前Goroutine,将其设置为等待状态。gopark
和goready
是成对出现的,是互逆的操作。
从用户角度来看,调用gopark
后,发送数据的代码语句会阻塞。
Channel的接收过程与发送过程基本类似,这里不再赘述。接收过程中涉及的缓冲区相关操作会在后面详细描述。
需要注意的是,Channel的整个发送和接收过程都使用了runtime.mutex
进行加锁。runtime.mutex
是runtime相关源码中常用的轻量级锁,整个过程并非最高效的无锁方案。Golang中存在一个关于无锁Channel的issue:go/issues#8899。
Channel使用环形缓冲区缓存写入的数据。环形缓冲区具有诸多优点,非常适合实现固定长度的FIFO队列。
Channel中环形缓冲区的实现如下:
hchan
中与缓冲区相关的两个变量:recvx
和sendx
。sendx
表示缓冲区中可写的索引,recvx
表示缓冲区中可读的索引。recvx
和sendx
之间的元素表示已正常放入缓冲区的数据。
可以直接使用buf[recvx]
读取队列的第一个元素,使用buf[sendx] = x
将元素放入队列的末尾。
当缓冲区未满时,将数据放入缓冲区的操作如下:
<code class="language-go">package main import "fmt" func main() { c := make(chan int) go func() { c <- 1 // 发送操作 }() x := <-c // 接收操作 fmt.Println(x) }</code>
chanbuf(c, c.sendx)
等价于c.buf[c.sendx]
。上述过程很简单,就是将数据复制到缓冲区sendx
位置。
然后,将sendx
移动到下一个位置。如果sendx
到达最后一个位置,则将其设置为0,这是一种典型的首尾相连的方法。
当缓冲区未满时,sendq
也必须为空(因为如果缓冲区未满,发送数据的Goroutine不会排队,而是直接将数据放入缓冲区)。此时Channel的读取逻辑chanrecv
比较简单,可以直接从缓冲区读取数据,也是一个移动recvx
的过程,与上面的缓冲区写入基本相同。
当sendq
中有等待的Goroutine时,缓冲区此时一定已满。此时Channel的读取逻辑如下:
<code class="language-go">func makechan(t *chantype, size int) *hchan</code>
ep
是接收数据的变量对应的地址(例如,在x := <-c
中,ep
就是x
的地址)。sg
表示从sendq
中取出的第一个sudog
。代码中:
typedmemmove(c.elemtype, ep, qp)
表示将缓冲区中当前可读的元素复制到接收变量的地址。typedmemmove(c.elemtype, qp, sg.elem)
表示将sendq
中Goroutine等待发送的数据复制到缓冲区。因为后面执行了recv
,所以相当于将sendq
中的数据放在队列的末尾。简单来说,这里Channel将缓冲区中的第一个数据复制到对应的接收变量,同时将sendq
中的元素复制到队列的末尾,从而实现FIFO(先进先出)。
Channel作为Golang中最常用的设施之一,理解其源码有助于更好地使用和理解Channel。同时,也不要过度迷信和依赖Channel的性能,当前Channel的设计仍有很大的优化空间。
优化建议:
最后,推荐一个非常适合部署Go服务的平台:Leapcell
更多信息请查看文档!
Leapcell Twitter: https://www.php.cn/link/7884effb9452a6d7a7a79499ef854afd
以上是Go 频道解锁:它们是如何工作的的详细内容。更多信息请关注PHP中文网其他相关文章!