Golang-channel底层实现精要

Golang-channel底层实现精要

一.channel 背景知识

  • channel是Go语言内置的核心类型,可以将其看做一个管道,channel和goroutine一起为go并发编程提供了最优雅和便利的方案
  • 在Go中有一句经典名言,永远不要通过共享内存来通信,而是要通过通信来共享内存,channel便是用于实现goroutine间通信的
  • channel提供了三种类型
    • 单向只能发送:chan<- struct{} 只能发送struct (箭头指向channel,则代表发送)
    • 单向只能接收:<-chan struct{} 只能从chan里接收struct (箭头远离channel,则代表接收)
    • 双向即可发送也可接收:chan string 既能接收也能发送
  • nil是channel的零值,对值是nil的channel发送和接收总是会阻塞

二.channel 底层实现

1.channel底层结构

简要说明:

  • buf是带缓冲的channle所特有的结构,是个循环链表,用来存储缓存数据
  • sendxrecvx是用于记录buf中发送和接收的index
  • lock是个互斥锁,目的是为了保证goroutine以先进先出FIFO的方式进入结构体
  • recvqsendq分别是往channel接收或发送数据的goroutine所抽象出来的数据结构,是个双向链表

channel结构体的源码位于/runtime/chan.go中,结构体为hchan,源码如下(版本1.11)

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 protects all fields in hchan, as well as several
// fields in sudogs blocked on this channel.
//
// Do not change another G's status while holding this lock
// (in particular, do not ready a G), as this can deadlock
// with stack shrinking.
lock mutex
}

2.channel实现原理

1) channel 创建

ch := make(chan int, 3)

因为 channel 的创建全部调用的 mallocgc(),在堆上开辟的内存空间,所以channel 本身会被 GC 自动回收。回收的条件是没有goroutine引用

简要说明:

  • 创建channel实际上就是在内存中实例化了一个hchan结构体,并返回一个chan指针
  • channle在函数间传递都是使用的这个指针,这就是为什么函数传递中无需使用channel的指针,而是直接用channel就行了,因为channel本身就是一个指针

2) channel 发送数据

以有缓冲的channel为例

ch <- 1
ch <- 2
ch <- 3

简要说明:

  • 发送数据前,会先锁住hchan这个结构体
  • 然后逐步往buf中填充数据(从goroutine中copy数据到buf),然后解锁
  • 注意sendx的变化,其记录了发送数据的index

3) channel 接收数据

<-ch
<-ch
<-ch

简要说明:

  • 接收数据前,同样会先锁住hchan这个结构体
  • 然后逐步往buf中获取数据(buf中copy数据到goroutine),然后解锁
  • 注意recvx的变化,其记录了接收数据的index

4) channel存储满了,底层如何处理的?

我们都知道,当channle缓存满了的时候,会阻塞当前goroutine,但是,这是如何实现的呢?

  • goroutine的阻塞操作,实际上是调用send (ch <- xx)或者recv ( <-ch)主动触发的
//goroutine1 中,记做G1
ch := make(chan int, 3)
ch <- 1
ch <- 1
ch <- 1

这个时候,G1在正常运行,当再次调度send操作的时候,会主动调用Go的调度器,让当前协程G1等待,并且让出内核线程M,交给其他G使用

同时,G1也会被抽象成含有G1指针和send元素的sudog结构体,保存到*sendq中等待被唤醒,那G1什么时候被唤醒呢?在有其他协程(G2)接收数据后被唤醒

G2执行了recv,于是会发生以下操作:

  • 1)G2从buf中取出数据
  • 2)channel从sendq中推出G1,将G1当时的send数据推到buf中
  • 3)调用Go的调度器scheduler,唤醒G1,并把G1放到可运行的Goroutine队列中

5) channel是空的,底层如何处理的?

当channel中无数据时,先执行G2的接收数据操作,G2会阻塞,这又是如何实现的呢?其实跟上面相差不大,可以顺着思路反推

  • 1)G2首先会主动调用Go调度器,让G2等待,并且让出M,交给其他G使用
  • 2)然后G2还好被抽象成含有G2指针和recv空元素的sudog结构体,保存到recvq中等待被唤醒

此时,如果G1向channel中发送数据,会发生一个有意思的事情:

  • G1并没有锁住channel,然后将数据放入buf中,而是直接将数据从G1 copy到了 G2,这种方式非常好
  • 这样的话,在唤醒G2的过程中,G2无需再获得channel的锁,然后从buf中取数据,减少了内存cpoy,提高了效率

  • 通过Go的调度器唤醒G2,将G2加入到GPM模型中P的本地可运行G队列中

3.总结

  • channel缓冲器满或空,其底层的处理都非常的精妙,主动调用调度器,阻塞当前G,将M交给其他G使用,然后将G指针和其他数据组装成sudog,加入recvq或者sendq队列,等待被调度,
  • 唤醒的流程也非常有趣,当G2接收但channel空阻塞时,G1发送数据,采用了直接copy方式,并没有锁住channel,将数据放入buf,而是直接从G1 复制到G2,减少了内存copy

本文参考:

posted @ 2022-01-05 16:05  西*风  阅读(868)  评论(0编辑  收藏  举报