Go-Channel底层原理剖析

104 阅读7分钟

channel作为 Go 核心的数据结构, Goroutine 之间的通信方式,Channel 是支撑 Go 语言高性能并发编程模型的重要结构,本节很多代码都是能看懂的,所以粘贴了一些源码解读,大家可以尝试理解一下,具体内容会介绍 Channel 数据结构和接发数据操作

数据结构

Go语言 Channel 在运行时使用runtime.hchan结构体实现接收发送数据的功能,下面展示一下各个字段的含义

源码位置:runtime.hchan

type hchan struct {
	qcount   uint //chan的len
	dataqsiz uint //chan的cap
	buf      unsafe.Pointer //底层循环数组(存储数据的地方)
	elemsize uint16 //chan类型大小
	closed   uint32 //1关闭 0无关闭
	elemtype *_type //chan类型元数据
	sendx    uint   //标记下一个发送点的数组索引
	recvx    uint   //标记下一个接受点的数组索引
	recvq    waitq  //因为没有容量或者容量满了阻塞接受Goroutine
	sendq    waitq  //因为没有容量或者容量满了阻塞发送Goroutine

	lock mutex //发送接受操作加锁
}

其中 recvq 和 sendq 表示因为接受或者发送阻塞的Goroutine,他们是一个双向链表结构
runtime.waitq

// 双向链表 链表的每个节点都是sudog,sudog表示等待的Goroutine
type waitq struct {
   first *sudog
   last  *sudog
}

sudog结构表示存储等待Goroutine的信息
runtime.sudog

初始化

通过如下代码,我们来分析一下channel的初始化过程

ch := make(chan int)

没有指定channel的容量,就是默认容量为0,make关键字通过转化最后会通过runtime.makechan去初始化channel

func makechan(t *chantype, size int) *hchan {
   elem := t.Elem
   。。。。。省略代码

   // 计算通道需要的内存大小
   mem, overflow := math.MulUintptr(elem.Size_, uintptr(size))
   if overflow || mem > maxAlloc-hchanSize || size < 0 {
      panic(plainError("makechan: size out of range"))
   }
   
   var c *hchan
   
   switch {
   case mem == 0:
      //如果当前 Channel 中不存在缓冲区,那么就只会分配一段内存空间大小为runtime.hchan内存对其后的大小
      c = (*hchan)(mallocgc(hchanSize, nil, true))
     
      c.buf = c.raceaddr()
      
   case elem.PtrBytes == 0:
      //这一行代码分配了 hchan 结构体和缓冲区所需的内存。
      //mallocgc函数用于分配内存,并且通过 (hchanSize+mem) 指定了需要的总内存大小,即 hchan 结构体的大小加上缓冲区大小
      c = (*hchan)(mallocgc(hchanSize+mem, nil, true))

      //这一行代码设置了通道的 buf 字段,使其指向分配的内存块中 hchan 结构体之后的位置,即缓冲区的起始位置。
      //unsafe.Pointer(c) 将 c 转换为指向 hchan 结构体的指针,
      //然后通过 add 函数将其偏移 hchanSize 字节,从而得到缓冲区的起始位置
      c.buf = add(unsafe.Pointer(c), hchanSize)
      
   default:
      //在默认情况下会单独为 runtime.hchan 和缓冲区分配内存
      c = new(hchan)
      c.buf = mallocgc(mem, elem, true)
   }

   c.elemsize = uint16(elem.Size_)
   c.elemtype = elem
   c.dataqsiz = uint(size)
   lockInit(&c.lock, lockRankHchan)

   if debugChan {
      print("makechan: chan=", c, "; elemsize=", elem.Size_, "; dataqsiz=", size, "\n")
   }
   return c
}
  • 当创建没有缓冲区的 Channel 的时候,那么就只会分配一段内存空间大小为hchan内存对其后的大小
  • 当创建有缓存区并且channel的类型不为指针的时候,会计算一块连续的内存地址使用
  • 其他情况,会单独申请hchan的内存大小和buf的内存大小

最后会统一更新elemsize,elemtype,dataqsiz字段

发送数据

发送数据在代码编写做通过ch <- i实现,主要逻辑在runtime.chansend函数里
在向channel发送数据的时候,如果channel已经关闭会直接panic

func chansend(c *hchan, ep unsafe.Pointer, block bool, callerpc uintptr) bool {
lock(&c.lock)

if c.closed != 0 {
   unlock(&c.lock)
   panic(plainError("send on closed channel"))
}
....
}

因为函数实现比较复杂,所以我们这里将函数分成以下的三个部分分析:

  1. 当存在等待接收Goroutine时,直接将数据发送给阻塞的Goroutine;
  2. 当缓冲区存在空余空间时,将发送的数据写入 Channel 的缓冲区;
  3. 当不存在缓冲区或者缓冲区已满时,挂起当前Goroutine,并保存Goroutine 到sendq中;

直接发送

如果目标 Channel 没有被关闭并且有接收等待的 Goroutine,那么会从接收队列 recvq 中取出最先陷入等待的 Goroutine 直接向它发送数据

//直接发送数据给等待Goroutine
if sg := c.recvq.dequeue(); sg != nil {
   send(c, sg, ep, func() { unlock(&c.lock) }, 3)
   return true
}

func send(c *hchan, sg *sudog, ep unsafe.Pointer, unlockf func(), skip int) {
  ....
   if sg.elem != nil {
      //将发送的数据直接拷贝到 x = <-c 表达式中变量 x 所在的内存地址上;
      sendDirect(c.elemtype, sg, ep)
      sg.elem = nil
   }
   gp := sg.g
   unlockf()
   gp.param = unsafe.Pointer(sg)
   sg.success = true
   if sg.releasetime != 0 {
      sg.releasetime = cputicks()
   }
   //唤醒Goroutine
   goready(gp, skip+1)
}

runtime.send函数,会把发送的数据直接移动到接受 Goroutine 的 elem 字段的内存地址上

缓冲区

如果创建的 Channel 包含缓冲区并且 Channel 中的数据没有装满,会把数据发送到缓冲区中,然后channel的发送索引和长度加1

//缓存cap没有满
if c.qcount < c.dataqsiz {
    //取出当前发送索引所在的位置
   qp := chanbuf(c, c.sendx)
   if raceenabled {
      racenotify(c, c.sendx, nil)
   }
   //移动数据到qp
   typedmemmove(c.elemtype, qp, ep)
   //发送索引++
   c.sendx++
   if c.sendx == c.dataqsiz {
      c.sendx = 0
   }
   //channel长度++
   c.qcount++
   unlock(&c.lock)
   return true
}

阻塞发送

当channel没有缓冲区或者缓冲区已满的情况,会创建一个 runtime.sudog 结构并将其加入 Channel 的 sendq 队列中,并且把当前 Goroutine 陷入阻塞等待唤醒

//获取发送数据使用的Goroutine
gp := getg()
//获取到sudog结构
mysg := acquireSudog()
mysg.releasetime = 0
mysg.elem = ep
mysg.waitlink = nil
mysg.g = gp
mysg.isSelect = false
mysg.c = c
gp.waiting = mysg
gp.param = nil
//添加当前g到sendq队列中
c.sendq.enqueue(mysg)
gp.parkingOnChan.Store(true)

//将当前的 Goroutine 陷入沉睡等待唤醒
gopark(chanparkcommit, unsafe.Pointer(&c.lock), waitReasonChanSend, traceBlockChanSend, 2)
KeepAlive(ep)

// someone woke us up.
if mysg != gp.waiting {
   throw("G waiting list is corrupted")
}
//清空结构数值
gp.waiting = nil
gp.activeStackChans = false
closed := !mysg.success
gp.param = nil
if mysg.releasetime > 0 {
   blockevent(mysg.releasetime-t0, 2)
}
mysg.c = nil
releaseSudog(mysg)
if closed {
   if c.closed == 0 {
      throw("chansend: spurious wakeup")
   }
   panic(plainError("send on closed channel"))
}
return true

总结

向 Channel 里面发送数据流程图

image.png

接受数据

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

i <- ch
i, ok <- ch

两种方式最后都会使用 runtime.chanrecv去接收数据

如果当前 Channel 已经被关闭并且缓冲区中不存在任何数据,那么会清除 ep 指针中的数据并立刻返回

func chanrecv(c *hchan, ep unsafe.Pointer, block bool) (selected, received bool) {
....
   lock(&c.lock)

   if c.closed != 0 {
      if c.qcount == 0 {
         if raceenabled {
            raceacquire(c.raceaddr())
         }
         unlock(&c.lock)
         if ep != nil {
            typedmemclr(c.elemtype, ep)
         }
         return true, false
      }
      // The channel has been closed, but the channel's buffer have data.
   } else {
....

从 Channel 接收数据时还包含以下三种不同情况:

  • 存在等待发送Goroutine时,通过runtime.recv获取数据;
  • 当缓冲区存在数据时,从缓冲区中接收数据;
  • 当缓冲区中不存在数据或者没有缓冲区时,挂起当前Goroutine,并保存挂起当前 Goroutine 信息到recvq中;

直接接收

当接收数据,存在等待发送 Goroutine 时:

  • 如果接收的 Channel 没有缓冲区,就直接从发送 Goroutine 中接收数据;
  • 如果有缓冲区,会接收recvx索引点数据,并把发送Goroutine的数据移动到recvx索引点,因为要遵守先进先出的原则
if sg := c.sendq.dequeue(); sg != nil {
		recv(c, sg, ep, func() { unlock(&c.lock) }, 3)
		return true, true
	}
        
func recv(c *hchan, sg *sudog, ep unsafe.Pointer, unlockf func(), skip int) {
   if c.dataqsiz == 0 {
      if raceenabled {
         racesync(c, sg)
      }
      if ep != nil {
         //如果没有缓冲区,直接从G中copy数据
         recvDirect(c.elemtype, sg, ep)
      }
   } else {
      qp := chanbuf(c, c.recvx)
      if raceenabled {
         racenotify(c, c.recvx, nil)
         racenotify(c, c.recvx, sg)
      }
     //获取c.recvx索引点的数据
      if ep != nil {
         typedmemmove(c.elemtype, ep, qp)
      }
      //移动G的中数据到c.recvx索引点
      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
   }
   //清空G的数据
   sg.elem = nil
   gp := sg.g
   unlockf()
   gp.param = unsafe.Pointer(sg)
   sg.success = true
   if sg.releasetime != 0 {
      sg.releasetime = cputicks()
   }
   //唤醒G
   goready(gp, skip+1)
}

缓冲区

当 Channel 的缓冲区中有数据,直接从缓冲区中 recvx 的索引位置中取出数据进行处理

func chanrecv(c *hchan, ep unsafe.Pointer, block bool) (selected, received bool) {
	...
	if c.qcount > 0 {
        //取出数据
		qp := chanbuf(c, c.recvx)
		if ep != nil {
			typedmemmove(c.elemtype, ep, qp)
		}
                
		typedmemclr(c.elemtype, qp)
                
		c.recvx++
                
          //等同于c.recvx %= c.dataqsiz
		if c.recvx == c.dataqsiz {
			c.recvx = 0
		}
		c.qcount--
		return true, true
	}
	...
}

阻塞接收

当 Channel 的发送队列中不存在等待的 Goroutine 并且缓冲区中也不存在任何数据时,阻塞当前的Goroutine,并把信息写入recvq,代码内容跟发送类似

总结

接收流程图

image.png

关闭通道

编译器会将用于关闭管道的 close 关键字转换成 runtime.closechan 函数。

当 Channel 是一个空指针或者已经被关闭时,Go 语言运行时会直接panic:

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"))
	}

然后会closechan函数将 recvq 和 sendq 两个队列中的数据加入到 Goroutine 列表 gList 中,与此同时该函数会清除所有 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)
	}
}

最后会唤起所有被阻塞的 Goroutine。

参考文献

go语言设计于实现