「第17期」 距离大叔的80期小目标还有63期, 大家好我是大叔,今天要跟大家分享基础知识点是Golang的一种数据结构 —— Channel,Channel 在 Golang 中的重要性不言而喻,一起看看吧。
Golang面试必问的四种数据结构 sync.Map、defer 、slice 和 Channel,这四种数据结构以及其原理需要搞透,前三种大叔在前面的文章中以及做了相应分析,需要的小伙伴可以点赞阅读:
本文主要从 Channle 的设计思想并结合代码实现来分析其运行特性。
接下来,请自助用餐。
Channel设计原理
在 Go Blog 提到这么一句话:
大体意思是:Go 鼓励使用 Channel 在 goroutine 之间传递数据引用,而不是显式地使用锁来协调对共享数据的访问。并引用《Effective Go》再次强调:不要通过共享内存的方式进行通信,而是应该通过通信的方式共享内存
这是Go最经典的设计模式。Go主张两个不同的goroutine能够独立运行并且不存在直接关联,但是能通过channel间接完成通信。
基于这种思想,Go 开发团队在设计 channel 的时候遵循了两大设计原则,分别是:先进先出和无锁管道
先进先出
Channel 的收发操作均遵循了先进先出的设计原则:
先从 Channel 读取数据的 Goroutine 会先接收到数据;先向 Channel 发送数据的 Goroutine 会得到先发送数据的权利;
带缓冲区和不带缓冲区的 Channel 都会遵循先入先出发送和接收数据
无锁管道
严格来讲,Channel 是一个用于同步和通信的有锁队列,Channel内部的hchan结构体中包含了用于保护成员变量的互斥锁。
但这并不妨碍go追求无锁管道的设计追求
比如,当发送数据时,如果存在阻塞的goroutine(接收者),会直接从recvq双向链表头部(最先陷入等待的 goroutine )取出阻塞的goroutine(接收者),将数据发送给阻塞的接收者。
Channel的优异特性得益于其巧妙的数据结构设计,了解其数据结构对我们掌握 Channel 尤为重要。
Channel数据结构
type hchan struct {
qcount uint
dataqsiz uint
buf unsafe.Pointer
elemsize uint16
closed uint32
elemtype *_type
sendx uint
recvx uint
recvq waitq
sendq waitq
lock mutex
}
qcount— Channel 中循环数组的元素个数;dataqsiz— Channel 中的循环数组的长度;buf— Channel 的缓冲区数据指针,指向一个循环数组;sendx— Channel 的发送操作要写入循环数组的位置;recvx— Channel 的接收操作要读取循环数组的位置;sendq和recvq存储了当前 Channel 由于缓冲区空间不足而阻塞的 Goroutine 列表,这些等待队列使用双向链表 runtime.waitq 表示,链表中所有的元素都是 runtime.sudog 结构:
type waitq struct {
first *sudog
last *sudog
}
runtime.sudog 表示一个在等待列表中的 Goroutine,该结构中存储了两个分别指向前后 runtime.sudog 的指针以构成链表。
创建 Channel
使用make关键字来创建管道,如:make(chan int, 10),第二个参数代表缓冲区大小,
- 不传默认是0,表示创建的是无缓冲区的管道
- 传N,N>0,表示创建的是有缓冲区为N的管道
向 Channel 发送数据
使用 ch <- i 表达式向 Channel 发送数据:
- 如果 Channel 已经关闭,此时向 Channel 写数据会触发
Panic:
a := make(chan int)
close(a)
a <- 1
b := <-a
fmt.Println(b) // panic: send on closed channel
- 如果 Channel 为nil,会永久堵塞,例如:
var c chan int
c <- 1
fmt.Println(<-c)
- 如果当前 Channel 的
recvq队列上存在已经被阻塞的 goroutine(只有没有缓冲区或者缓冲区没有数据时才会阻塞读取的 goroutine ),那么会直接将数据发送给当前 goroutine 并将其设置成下一个运行的 goroutine,具体过程是:
-
- 将发送的数据直接拷贝到
x = <-c表达式中变量x所在的内存地址上, - 将等待接收数据的 goroutine 标记成可运行状态, 并把该 goroutine 放到发送方所在的处理器的
runnext上等待执行,该处理器在下一次调度时会立刻唤醒数据的接收方;关于阻塞调度,可参考 这样回答GMP调度模型,不给面试官二次提问的机会! 这篇文章
- 将发送的数据直接拷贝到
func chansend(c *hchan, ep unsafe.Pointer, block bool, callerpc uintptr) bool {
lock(&c.lock) //加锁
// 1、当存在等待接收的Goroutine
if sg := c.recvq.dequeue(); sg != nil {
// Found a waiting receiver. We pass the value we want to send
// directly to the receiver, bypassing the channel buffer (if any).
send(c, sg, ep, func() { unlock(&c.lock) }, 3) // 直接把正在发送的值拷贝给等待接收的goroutine
return true
}
}
func send(c *hchan, sg *sudog, ep unsafe.Pointer, unlockf func(), skip int) {
// 将ep写入sg中的elem
if sg.elem != nil {
t:=c.elemtype
dst := sg.elem
// memmove copies n bytes from "from" to "to".
memmove(dst, ep, t.size)
sg.elem = nil // 数据已经被写入到<- c变量,因此sg.elem指针可以置空了
}
gp := sg.g
unlockf()
gp.param = unsafe.Pointer(sg)
sg.success = true
// 唤醒receiver协程gp
goready(gp, skip+1)
}
// 唤醒receiver协程gp,将其放入可运行队列中等待调度执行
func goready(gp *g, traceskip int) {
systemstack(func() {
ready(gp, traceskip, true)
})
}
- 如果 Channel 的
recvq队列上没有阻塞的 goroutine,当存在缓冲区并且其中还有空闲的容量,我们会直接将数据存储到缓冲区sendx所在的位置上;并对sendx索引和qcount计数器分别加1,因为这里的buf是一个循环数组,所以当sendx等于dataqsiz时会重新回到数组开始的位置。整个过程需要加锁操作
func chansend(c *hchan, ep unsafe.Pointer, block bool, callerpc uintptr) bool {
lock(&c.lock) //加锁
// 当缓冲区未满时
if c.qcount < c.dataqsiz {
// Space is available in the channel buffer. Enqueue the element to send.
qp := chanbuf(c, c.sendx) // 获取指向缓冲区数组中位于sendx位置的元素的指针
typedmemmove(c.elemtype, qp, ep) // 将当前发送的值拷贝到缓冲区
c.sendx++
if c.sendx == c.dataqsiz {
c.sendx = 0 // 因为是循环队列,sendx等于队列长度时置为0
}
c.qcount++
unlock(&c.lock)
return true
}
}
- 如果 Channel的
recvq上没有阻塞的 Goroutine,且缓存区已满,会创建一个 runtime.sudog 结构并将其加入 Channel 的sendq队列中,当前 goroutine 也会陷入阻塞,等待其他的协程从 Channel 接收数据
-
sendq队列中阻塞的goroutine(简称g1)何时才能被唤醒呢?- 答案是:当遇到另一个goroutine(简称g2)读数据时,G2从缓存队列中取出数据,channel会将等待队列中的G1推出,将G1当时send的数据推到缓存中,然后调用Go的调度器,唤醒G1,并把G1放到可运行的Goroutine队列中。
如果 Channel 已经关闭,那么向该 Channel 发送数据时会报 “send on closed channel” 错误并中止程序。
func chansend(c *hchan, ep unsafe.Pointer, block bool, callerpc uintptr) bool {
lock(&c.lock) //加锁
// Block on the channel.
// 将当前的Goroutine封装成一个sudog对象,并加入到阻塞写队列sendq里
gp := getg()
mysg := acquireSudog()
mysg.elem = ep
mysg.g = gp
mysg.c = c
gp.waiting = mysg
c.sendq.enqueue(mysg) // 入sendq等待队列
// 调用gopark将当前Goroutine设置为等待状态并解锁,进入休眠等待被唤醒,触发协程调度
gopark(chanparkcommit, unsafe.Pointer(&c.lock), waitReasonChanSend, traceEvGoBlockSend, 2)
// 被唤醒之后执行清理工作并释放sudog结构体
gp.waiting = nil
gp.activeStackChans = false
closed := !mysg.success // gp被唤醒的原因是因为数据传递还是通道被关闭
gp.param = nil
mysg.c = nil
releaseSudog(mysg)
// 因关闭被唤醒则panic
if closed {
panic(plainError("send on closed channel"))
}
// 数据成功传递
return true
}
整体写流程如下:
从 Channel 接收数据
Go 语言中可以使用两种不同的方式去接收 Channel 中的数据:
i <- ch
i, ok <- ch # 第二个值ok是布尔类型,false 表示 Channel 已关闭
- 如果 Channel 已经关闭
-
- 缓冲区没有任何数据,接收者会直接返回,并且收到一个Channel 类型的零值和一个布尔值false表示
channel已关闭。
- 缓冲区没有任何数据,接收者会直接返回,并且收到一个Channel 类型的零值和一个布尔值false表示
-
- 缓冲区有数据,读取
recvx索引所在位置的数据返回;
- 缓冲区有数据,读取
-
如果 Channel 的
sendq队列中存在阻塞的写 goroutine,:
-
- 如果缓冲区没有剩余空间,直接读取写 goroutine 的数据,并唤醒写 goroutine
-
- 如果缓冲区有剩余空间,直接读取缓冲中
recvx索引对应的数据;将sendq队列头部的写 goroutine 中的数据写入缓冲区sendx索引至指定的位置,同时唤醒写 goroutine;
- 如果缓冲区有剩余空间,直接读取缓冲中
func chanrecv(c *hchan, ep unsafe.Pointer, block bool) (selected, received bool) {
// 省略...
lock(&c.lock)
// Just found waiting sender with not closed.
// 等待发送的队列sendq里存在Goroutine
if sg := c.sendq.dequeue(); sg != nil {
// Found a waiting sender. If buffer is size 0, receive value
// directly from sender. Otherwise, receive from head of queue
// and add sender's value to the tail of the queue (both map to
// the same buffer slot because the queue is full).
// 如果无缓冲区,那么直接从sender接收数据;否则,从buf队列的头部接收数据,并把sender的数据加到buf队列的尾部
recv(c, sg, ep, func() { unlock(&c.lock) }, 3)
return true, true // 接收成功
}
// 省略...
}
// recv processes a receive operation on a full channel c.
func recv(c *hchan, sg *sudog, ep unsafe.Pointer, unlockf func(), skip int) {
// channel无缓冲区,直接从sender读
if c.dataqsiz == 0 {
if ep != nil {
// copy data from sender
t := c.elemtype
src := sg.elem
typeBitsBulkBarrier(t, uintptr(ep), uintptr(src), t.size)
memmove(dst, src, t.size)
}
} else {
// 从队列读,sender再写入队列
qp := chanbuf(c, c.recvx)
// copy data from queue to receiver
if ep != nil {
typedmemmove(c.elemtype, ep, qp)
}
// copy data from sender to queue
typedmemmove(c.elemtype, qp, sg.elem)
c.recvx++
if c.recvx == c.dataqsiz {
c.recvx = 0
}
c.sendx = c.recvx // c.sendx = (c.sendx+1) % c.dataqsiz
}
// 唤醒sender队列协程sg
sg.elem = nil
gp := sg.g
unlockf()
gp.param = unsafe.Pointer(sg)
sg.success = true
// 唤醒协程
goready(gp, skip+1)
}
- 如果 Channel 不存在阻塞的写 goroutine,但缓冲区有数据,直接从环形缓冲区的
recvx索引的位置读取数据
func chanrecv(c *hchan, ep unsafe.Pointer, block bool) (selected, received bool) {
// 省略...
lock(&c.lock)
// 缓冲区buf中有元素,直接从buf拷贝元素到当前协程(在已关闭的情况下,队列有数据依然会读)
if c.qcount > 0 {
// Receive directly from queue
qp := chanbuf(c, c.recvx)
if ep != nil {
typedmemmove(c.elemtype, ep, qp)// 将从buf中取出的元素拷贝到当前协程
}
typedmemclr(c.elemtype, qp) // 同时将取出的数据所在的内存清空
c.recvx++
if c.recvx == c.dataqsiz {
c.recvx = 0
}
c.qcount--
unlock(&c.lock)
return true, true // 接收成功
}
// 省略...
}
- 如果 Channel 不存在阻塞的写 goroutine,且缓冲区无数据,将当前的读 goroutine 封装成sudog节点,加入channel的阻塞读队列
recvq中,调用gopark将当前协程设置为等待状态并解锁,触发调度其它协程运行。
func chanrecv(c *hchan, ep unsafe.Pointer, block bool) (selected, received bool) {
// 省略...
lock(&c.lock)
// no sender available: block on this channel.
// 阻塞模式,获取当前Goroutine,打包一个sudog,并加入到channel的接收队列recvq里
gp := getg()
mysg := acquireSudog()
mysg.elem = ep
gp.waiting = mysg
mysg.g = gp
mysg.c = c
gp.param = nil
c.recvq.enqueue(mysg) // 入接收队列recvq
// 挂起当前Goroutine,设置为_Gwaiting状态,进入休眠等待被唤醒
gopark(chanparkcommit, unsafe.Pointer(&c.lock), waitReasonChanReceive, traceEvGoBlockRecv, 2)
// 因通道关闭或者读到数据被唤醒
gp.waiting = nil
success := mysg.success
gp.param = nil
mysg.c = nil
releaseSudog(mysg)
return true, success // 10.返回成功
}
整体读流程如下:
关闭管道
使用close 关键字来关闭管道
- 当 Channel 是一个空指针或者已经被关闭时,Go 语言运行时都会直接崩溃并抛出异常
func closechan(c *hchan) {
if c == nil {
panic(plainError("close of nil channel"))
}
lock(&c.lock)
if c.closed != 0 {
unlock(&c.lock)
panic(plainError("close of closed channel"))
}
}
- 接着设置hchan的close属性的值为1,将
recvq和sendq两个队列中的数据加入到 Goroutine 列表gList中,与此同时该函数会清除所有 runtime.sudog
c.closed = 1
var glist gList
for {
sg := c.recvq.dequeue()
if sg == nil {
break
}
if sg.elem != nil {
typedmemclr(c.elemtype, sg.elem)
sg.elem = nil
}
gp := sg.g
gp.param = nil
glist.push(gp)
}
for {
sg := c.sendq.dequeue()
...
}
for !glist.empty() {
gp := glist.pop()
gp.schedlink = 0
goready(gp, 3)
}
总结
以上就是 Channel 的核心知识点了。下次面试如果再遇到类似 说说 Channel 的设计思想和底层原理 相关问题,简易的回答可以是这样:
Channel 的发送和接收操作均遵循了先进先出的设计原则,即:先从 Channel 读取数据的 Goroutine 会先接收到数据,先向Channel发送数据的 Goroutine 会先得到发送数据的权利。
这得益于其底层的数据结构。Channel的底层数据结构包含了两个阻塞队列(双向链表实现),分别为发送阻塞队列sendq和接收阻塞队列recvq,遵循FIFO原则。
当写 goroutine 或读 goroutine被阻塞时,它们会被封装成runtime.sudog对象,加入到 sendq或recvq 队尾。
当阻塞的 goroutine 别唤醒时,会从 sendq或recvq队头取出阻塞的 goroutine进行执行。
另外,Channel 的底层数据结构中还包含了代表缓冲的循环数组buf,以及循环数组的索引sendx和recvx,通过这个两个索引来保证 Channel 读写的有序性。
向一个已经关闭的 Channel 读时,如果 Channel 缓冲区有数据,直接返回缓冲区recvx索引位置的数据;如果缓冲区没数据或者无缓冲区,直接返回该 Channel 类型的零值
向一个已经关闭的 Channel 写时,会抛出panic,除此之外,关闭已经关闭的 Channel和关闭一个 nil 的 Channel 都会panic。
向一个nil 的 Channel 发送或者读取数据会永久堵塞。
以上就是有关 Channel 知识点的简易回答,如果需要更具体的运行原理,可参考上文。
关注gzh「大叔说码」 获取更多基础和面试干货
今天的分享就到此为止,我们下期见~