10秒钟,想想如何回答好面试官口中的Channel问题

302 阅读11分钟

「第17期」 距离大叔的80期小目标还有63期, 大家好我是大叔,今天要跟大家分享基础知识点是Golang的一种数据结构 —— Channel,Channel 在 Golang 中的重要性不言而喻,一起看看吧。

Golang面试必问的四种数据结构 sync.MapdefersliceChannel,这四种数据结构以及其原理需要搞透,前三种大叔在前面的文章中以及做了相应分析,需要的小伙伴可以点赞阅读:

本文主要从 Channle 的设计思想并结合代码实现来分析其运行特性。

接下来,请自助用餐。

Channel设计原理

在 Go Blog 提到这么一句话:

go-blog.png

大体意思是: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 的接收操作要读取循环数组的位置;
  • sendqrecvq 存储了当前 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-wirte.png

从 Channel 接收数据

Go 语言中可以使用两种不同的方式去接收 Channel 中的数据:

i <- ch
i, ok <- ch  # 第二个值ok是布尔类型,false 表示 Channel 已关闭
  • 如果 Channel 已经关闭
    • 缓冲区没有任何数据,接收者会直接返回,并且收到一个Channel 类型的零值和一个布尔值false表示 channel 已关闭。
    • 缓冲区有数据,读取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.返回成功
}

整体读流程如下:

mermaid-read.png

关闭管道

使用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,将 recvqsendq 两个队列中的数据加入到 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对象,加入到 sendqrecvq 队尾。 当阻塞的 goroutine 别唤醒时,会从 sendqrecvq队头取出阻塞的 goroutine进行执行。

另外,Channel 的底层数据结构中还包含了代表缓冲的循环数组buf,以及循环数组的索引sendxrecvx,通过这个两个索引来保证 Channel 读写的有序性。

向一个已经关闭的 Channel 读时,如果 Channel 缓冲区有数据,直接返回缓冲区recvx索引位置的数据;如果缓冲区没数据或者无缓冲区,直接返回该 Channel 类型的零值

向一个已经关闭的 Channel 写时,会抛出panic,除此之外,关闭已经关闭的 Channel和关闭一个 nil 的 Channel 都会panic。

向一个nil 的 Channel 发送或者读取数据会永久堵塞。

以上就是有关 Channel 知识点的简易回答,如果需要更具体的运行原理,可参考上文。

关注gzh「大叔说码」 获取更多基础和面试干货

今天的分享就到此为止,我们下期见~