go chan 设计与实现

在上一篇文章《深入理解 go chan》中,我们讲解了 chan 相关的一些概念、原理等东西,
今天让我们再深入一下,读一下它的源码,看看底层实际上是怎么实现的。

整体设计

我们可以从以下三个角度看 chan 的设计(源码位于 runtime/chan.go,结构体 hchan 就是 chan 的底层数据结构):

  • 存储:chan 里面的数据是通过一个环形队列来存储的(实际上是一个数组,但是我们视作环形队列来操作。无缓冲 chan 不用存储,会直接从 sender 复制到 receiver
  • 发送:数据发送到 chan 的时候,如果 chan 满了,则会将发送数据的协程挂起,将其放入一个协程队列中,chan 空闲的时候会唤醒这个协程队列。如果 chan 没满,则发送队列为空。
  • 接收:从 chan 中接收数据的时候,如果 chan 是空的,则会将接收数据的协程挂起,将其放入一个协程队列中,当 chan 有数据的时候会唤醒这个协程队列。如果 chan 有数据,则接收队列为空。

文中一些比较关键的名词解释:

  • sender: 表示尝试写入 changoroutine
  • receiver: 表示尝试从 chan 读取数据的 goroutine
  • sendq 是一个队列,存储那些尝试写入 channel 但被阻塞的 goroutine
  • recvq 是一个队列,存储那些尝试读取 channel 但被阻塞的 goroutine
  • g 表示一个协程。
  • gopark 是将协程挂起的函数,协程状态:_Grunning => _Gwaiting
  • goready 是将协程改为可运行状态的函数,协程状态: _Gwaiting => _Grunnable

现在,假设我们有下面这样的一段代码,通过这段代码,我们可以大概看一下 chan 的总体设计:

package main

func main() {
   
   
	// 创建一个缓冲区大小为 9 的 chan
	ch := make(chan int, 9)
	// 往 chan 写入 [1,2,3,4,5,6,7]
	for i := 0; i < 7; i++ {
   
   
		ch <- i + 1
	}
	// 将 1 从缓冲区移出来
	<-ch
}

现在,我们的 chan 大概长得像下面这个样子,后面会详细展开将这个图中的所有元素:

在这里插入图片描述

上图为了说明而在 recvq 和 sendq 都画了 3 个 G,但实际上 recvq 和 sendq 至少有一个为空。因为不可能有协程正在等待接收数据的时候,还有协程的数据因为发不出去数据而阻塞。

数据结构

在底层,go 是使用 hchan 这个结构体来表示 chan 的,下面是结构体的定义:

type hchan struct {
   
   
	qcount   uint           // 缓冲区(环形队列)元素个数
	dataqsiz uint           // 缓冲区的大小(最多可容纳的元素个数)
	buf      unsafe.Pointer // 指向缓冲区入口的指针(从 buf 开始 qcount * elemsize 大小的内存就是缓冲区所用的内存)
	elemsize uint16 // chan 对应类型元素的大小(主要用以计算第 i 个元素的内存地址)
	closed   uint32 // chan 是否已经关闭(0-未关闭,1-已关闭)
	elemtype *_type // chan 的元素类型
	sendx    uint   // chan 发送操作处理到的位置
	recvx    uint   // chan 接收操作处理到的位置
	recvq    waitq  // 等待接收数据的协程队列(双向链表)
	sendq    waitq  // 等待发送数据的协程队列(双向链表)

	// 锁
	lock mutex
}

waitq 的数据结构如下:

type waitq struct {
   
   
	first *sudog
	last  *sudog
}

waitq 用来保存阻塞在等待或接收数据的协程列表(是一个双向链表),在解除阻塞的时候,需要唤醒这两个队列中的数据。

对应上图各字段详细说明

hchan,对于 hchan 这个结构体,我们知道,在 go 里面,结构体字段是存储在一段连续的内存上的(可以看看《深入理解 go unsafe》),所以图中用了连续的一段单元格表示。

下面是各字段说明:

  • qcount: 写入 chan 缓冲区元素个数。我们的代码往 chan 中存入了 7 个数,然后从中取出了一个数,最终还剩 6 个,因此 qcount6
  • dataqsiz: hchan 缓冲区的长度。它在内存中是连续的一段内存,是一个数组,是通过 make 创建的时候传入的,是 9
  • bufhchan 缓冲区指针。指向了一个数组,这个数组就是用来保存发送到 chan 的数据的。
  • sendxrecvx:写、读操作的下标。指向了 buf 指向的数组中的下标,sendx 是下一个发送操作保存的下标,recvx 是下一个接收操作的下标。
  • recvqsendq: 阻塞在 chan 读写上的协程列表。底层是双向链表,链表的元素是 sudogsudog 是一个对 g 的封装),我们可以简单地理解为 recvqsendq 的元素就是 g(协程)。

g 和 sudog 是什么?

上面提到了 gsudogg 是底层用来表示协程的结构体,而 sudog 是对 g 的封装,记录了一些额外的信息,比如关联的 hchan

在 go 里面,协程调度的模型是 GMP 模型,G 代表协程、M 代表线程、P 表示协程调度器。我上图里面的 G 就是代表协程(当然,实际上是 sudog)。
还有一个下面会提到的就是 g0g0 表示 P 上启动的第一个协程。

GMP 模型是另外一个庞大的话题了,大家可以自行去了解一下,对理解本文也很有好处。因为在 chan 阻塞的时候实际上也是一个协程调度的过程。
具体来说,就是从 g 的栈切换到 g0 的栈,然后重新进行协程调度。这个时候 g 因为从运行状态修改为了等待状态,所以在协程调度中不会将它调度来执行,
而是会去找其他可执行的协程来执行。

创建 chan

我们的 make(chan int, 9) 最终会调用 makechan 方法:

// chantype 是 chan 元素类型,size 是缓冲区大小
func makechan(t *chantype, size int) *hchan {
   
   
	elem := t.elem

	// compiler checks this but be safe.
	// 检查元素个数是否合法(不能超过 1<<16 个)
	if elem.size >= 1<<16 {
   
   
		throw("makechan: invalid channel element type")
	}
	// 判断内存是否对齐
	if hchanSize%maxAlign != 0 || elem.align > maxAlign {
   
   
		throw("makechan: bad alignment")
	}

	// mem 是 chan 缓冲区(环形队列)所需要的内存大小
	// mem = 元素大小 * 元素个数
	mem, overflow := math.MulUintptr(elem.size, uintptr(size))
	if overflow || mem > maxAlloc-hchanSize || size < 0 {
   
   
		panic(plainError("makechan: size out of range"))
	}

	// 定义 hchan
	var c *hchan
	switch {
   
   
	case mem == 0:
		// 队列或者元素大小是 0(比如 make(chan int, 0))
		// 只需要分配 hchan 所需要的内存
		c = (*hchan)(mallocgc(hchanSize, nil, true))
		// ... 
	case elem.ptrdata == 0:
		// elem 类型里面不包含指针
		// 分配的内存 = hchan 所需内存 + 缓冲区内存
		c = (*hchan)(mallocgc(hchanSize+mem, nil, true))
		// 分配的是连续的一段内存,缓冲区内存在 hchan 后面
		c.buf = add(unsafe.Pointer(c), hchanSize)
	default:
		// 元素类型里面包含指针
		c = new(hchan)
		// buf 需要另外分配内存
		c.buf = mallocgc(mem, elem, true)
	}

	// 单个元素的大小
	c.elemsize = uint16(elem.size)
	// 元素类型
	c.elemtype = elem
	// 缓冲区大小
	c.dataqsiz = uint(size)
	// ...
}

创建 chan 的过程主要就是给 hchan 分配内存的过程:

  • 非缓冲 chan,只需要分配 hchan 结构体所需要的内存,无需分配环形队列内存(数据会直接从 sender 复制到 receiver
  • 缓冲 chan(不包含指针),分配 hchan 所需要的内存和环形队列所需要的内存,其中 buf 会紧挨着 hchan
  • 缓冲 chan(含指针),hchan 和环形队列所需要的内存单独进行分配

对应到文章开头的图就是,底下的 hchanbuf 那两段内存。

发送数据

<- 语法糖

《深入理解 go chan》中,我们说也过,<- 这个操作符号是一种语法糖,
实际上,<- 会被编译成一个函数调用,对于发送操作而言,c <- x 会编译为对下面的函数的调用:

// elem 是被发送到 chan 的数据的指针。
// 对于 ch <- x,ch 对应参数中的 c,unsafe.Pointer(&x) 对应参数中的 elem。
func chansend1(c *hchan, elem unsafe.Pointer) {
   
   
    chansend(c, elem, true, getcallerpc())
}

另外,对于 select 里面的调用,chansend 会返回一个布尔值给 select 用来判断是否是要选中当前 case 分支。
如果 chan 发送成功,则返回 true,则 select 的那个分支得以执行。(select...case 本质上是 if...else,返回 false 表示判断失败。)

chansend 第二个参数的含义

chansend 第二个参数 true 表示是一个阻塞调用,另外一种是在 select 里面的发送操作,在 select 中的操作是非阻塞的。

package main

func main() {
   
   
	ch <
评论 2
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

张无忌打怪兽

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值