概述
文章总字数为:8398
大概阅读时间为:18 分钟
通过本文你将学习到:
- Go 语言 Channel 功能特点
- Go 语言 Channel 实现原理
- Go 哲学:Do not communicate by sharing memory, instead, Share memory by communicating. 是如何实现的。
- Channel 如何实现阻塞和恢复 Goroutine。
Go 语言一个非常吸引人的特性就是语言层面支持并发,而且使用起来非常简单,速度上甚至逼近 C 语言。今天我们就来讲讲 Go 语言的 Channel 原理和实现。
Channel 的功能特点
熟悉 Go 语言的你应该已经知道 Channel 的用法了,它被用来在 Goroutine 之间进行通讯,使用起来就像是生产者消费者模型中的缓冲池一样。我们就从一个基本 channel 的例子开始。
下面给出一个简单的生产者消费者的代码,为了把关注点放在重点上,我们只给出关键部分代码。
func main(){
//带缓冲的channel
ch := make(chan Task, 3)
//启动固定数量的worker
for i := 0; i< numWorkers; i++ {
go worker(ch)
}
//发送任务给worker
hellaTasks := getTaks()
for _, task := range hellaTasks {
ch <- task
}
...
}
func worker(ch chan Task){
for {
//接受任务
task := <- ch
process(task)
}
}
可以看到一个基本的 Goroutine 之间利用 Channel 通信的例子。我们总结一下 Channel 的特点:
- 线程安全(Goroutine-safe)
- 能够存储数据并在 Goroutine 之间传递数据
- 提供先进先出语义
- 能够使 goroutine 阻塞和恢复
这些功能 Go 是如何实现的呢?我们从三方面来讲。
- 从「channel 的创建」来讲解 hchan 数据结构。
- 从「channel 的发送和接收」来讲解如何实现 goroutine 的阻塞和恢复。
创建 Channel
我们来看一下创建 Channel 的时候发生了什么?
ch := make(chan Task, 3)
我们创建了一个 Task 类型且容量为 3 的 channel,此时,数据结构如下:

src/runtime/chan.go 中找到相应的数据结构 hchan 的定义,
// src/runtime/chan.go
type hchan struct {
qcount uint // 当前队列中剩余元素个数
dataqsiz uint // 环形队列长度,即可以存放的元素个数
buf unsafe.Pointer // 环形队列指针
elemsize uint16 // 每个元素的大小
closed uint32 // 标识关闭状态
elemtype *_type // 元素类型
sendx uint // 队列下标,指示元素写入时存放到队列中的位置
recvx uint // 队列下标,指示元素从队列的该位置读出
recvq waitq // 等待读消息的goroutine队列
sendq waitq // 等待写消息的goroutine队列
lock mutex // 互斥锁,用于互斥访问chan
}
当我们创建 channel 时候,qcount 为 0, dataqsiz 初始化为 channel 的大小(上面的例子中为3)。recvx 指示元素从队列的该位置读出, sendx 指示元素写入时存放到队列中的位置。
当我们向 channel 发送数据时:

可以看到向 channel 中发送元素时,元素个数 qcount 加一,sendx 加一,我们继续向 channel 发送两个元素:

可以看到此时 qcount == dataqsiz,表明队列已满。由于 buf 指向的是循环队列,sendx 又变成了 0。
接收的过程和发送的很像, channel 中发送元素时元素个数 qcount 减一,sendx 加一,我们从 channel 中接收一个元素:

那么我们回过头来看一下源码,当我们创建 channel 的时候,都发生了什么?
// src/runtime/chan.go
// 你也可以从这个地址访问该代码: https://golang.org/src/runtime/chan.go
func makechan(t *chantype, size int) *hchan {
elem := t.elem
// 检查元素大小
if elem.size >= 1<<16 {
throw("makechan: invalid channel element type")
}
// 检查对齐
if hchanSize%maxAlign != 0 || elem.align > maxAlign {
throw("makechan: bad alignment")
}
// 检查内存溢出
mem, overflow := math.MulUintptr(elem.size, uintptr(size))
if overflow || mem > maxAlloc-hchanSize || size < 0 {
panic(plainError("makechan: size out of range"))
}
// 为 hchan 和 buf 分配内存。
var c *hchan
switch {
case mem == 0:
// Queue or element size is zero.
c = (*hchan)(mallocgc(hchanSize, nil, true))
// Race detector uses this location for synchronization.
c.buf = c.raceaddr()
case elem.ptrdata == 0:
// Elements do not contain pointers.
// Allocate hchan and buf in one call.
c = (*hchan)(mallocgc(hchanSize+mem, nil, true))
c.buf = add(unsafe.Pointer(c), hchanSize)
default:
// Elements contain pointers.
c = new(hchan)
c.buf = mallocgc(mem, elem, true)
}
// 初始化 elemsize,ememtype,dataqsiz
c.elemsize = uint16(elem.size)
c.elemtype = elem
c.dataqsiz = uint(size)
if debugChan {
print("makechan: chan=", c, "; elemsize=", elem.size, "; dataqsiz=", size, "\n")
}
return c
}
代码不难理解,可以看到在创建 channel 的时候,做了这些事情:
- 检查元素大小
- 检查对齐
- 检查内存溢出
- 为 hchan 和 buf 分配内存。
- 初始化 elemsize,ememtype,dataqsiz
你可能会问,还有很多东西没初始化呢,比如 qcount,sendx, recvx 这些,结构体创建的时候这些都默认初始化为零,所以不需要显式的初始化。
结构体分配内存的时候,会在堆(heap)中分配好 hchan 的空间,然后返回指向该区域的指针。这就是为什么我们在函数之间传递 只需要直接传递 channel ,而不需要传递 channel 的指针。channel 本身就是指针了。
发送和接收
我们还是从一个简单的例子出发:
// Goroutine 1 (简称 G1)
func main(){
...
for _, task := range tasks {
taskCh <- task
}
}
// Goroutine 2 (简称 G2)
func worker(){
for {
task := <-taskCh;
process(task)
}
}
上面是一个简单的发送和接收的例子,为了简化代码,只给出关键部分,并且出于简单考虑,我们假设只有一个发送者和一个接收者。
下面开始分析发送方(G1),还记得 channel 有一个特点是线程安全吗?为了保证 channel 互斥访问,就是加互斥锁啦。所以,发送者(G1)发送一个任务到 channel taskCh 中,过程大致分三步:
- 申请互斥锁
- 将数据入队到 buf 指向的循环队列(入队表明 sendx 已经向后移动)
- 释放互斥锁
还记得 hchan 的结构体中有个 mutex 类型的 lock吗?这个就是用来保证 Goroutine 访问 channel 互斥的锁,该锁保证了同一时间只能有一个 Goroutine 访问该 Channel。
将数据放入 buf 指向的循环队列时,Golang 使用的是内存复制 memmove,具体实现使用的是汇编语言,在 src/runtime/memmove_* 中,对应平台有不同的实现,这里对内存复制就不具体展开了。

现在 taskCh 的缓冲队列中已经有一个数据了,当 G2 从 channel 中获取数据时,基本上是相似的操作。
- 申请互斥锁
- 将数据从 buf 指向的循环队列出队(出队表明 recvx 已经向后移动)
- 释放互斥锁
同样的,这里从 buf 指向的循环队列取出数据的过程同样使用内存复制。
整个发送和接收的流程很简单。可以看到,G1 和 G2 之间的通信没有访问共享的空间(channel 除外)!整个过程中的通讯使用的是内存复制而非共享内存。发送方复制数据到 channel,接收方从 channel 复制数据,所以就解释了那句 Go 哲学:
Do not communicate by sharing memory, instead, Share memory by communicating.
所以其实这里的 Communicating 就是指的 「Memory Copy」。
现在我们回到上面的程序,G1 作为生产者,G2 作为消费者,Channel 队列初始状态为空。如图

假设此刻 G2 正在消费一个 task0,这个 task0 需要很久很久才能完成,就在这时生产者不断的向队列中生产数据,发送 task1, task2, task3, 当 G1 发送 task4 的时候,问题出现了,队列满了。如下图。

这时候G1 的执行会被阻塞,而当消费之从队列中取出数据之后,队列不满时 G1 就会恢复运行。这些可能你已经知道了。那么底层是怎么实现的呢? 上面在讲 hchan 结构体的时候,我们看到过,有两个元素叫做 sendq 和 recvq,这两个就是分别用来存放该 channel 发送和接收的阻塞队列,队列存放的元素数据结构叫做 sudog(src/runtime/runtime2.go),如图所示。

当我们的 channel 已经满了,新的发送者发送数据时,就会连同数据一同放入等待队列 sendq 中,
话不多说,Show me the code! 那么我们直接去看源码src/runtime/chan.go:
func chansend(c *hchan, ep unsafe.Pointer, block bool, callerpc uintptr) bool {
// 如果 channel 为无缓冲区 Channel,调用 gopark 阻塞该 goroutine.
if c == nil {
if !block {
return false
}
gopark(nil, nil, waitReasonChanSendNilChan, traceEvGoStop, 2)
throw("unreachable")
}
if debugChan {
print("chansend: chan=", c, "\n")
}
if raceenabled {
racereadpc(c.raceaddr(), callerpc, funcPC(chansend))
}
if !block && c.closed == 0 && full(c) {
return false
}
var t0 int64
if blockprofilerate > 0 {
t0 = cputicks()
}
// 对 hchan 加锁
lock(&c.lock)
// 如果 channel 已经关闭,解锁并抛出异常。
if c.closed != 0 {
unlock(&c.lock)
panic(plainError("send on closed channel"))
}
// 如果阻塞的接收队列中有阻塞的接收者,那么直接将元素发送给接收者,而不必经过缓冲区。
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)
return true
}
// 如果缓冲区还有空间。
if c.qcount < c.dataqsiz {
// 如果缓冲区还有空间,将元素放入缓冲区中。
qp := chanbuf(c, c.sendx)
if raceenabled {
raceacquire(qp)
racerelease(qp)
}
// 开始复制元素(内存拷贝)并完成入队操作
typedmemmove(c.elemtype, qp, ep)
c.sendx++
if c.sendx == c.dataqsiz {
c.sendx = 0
}
c.qcount++
// 解锁 channel。
unlock(&c.lock)
return true
}
if !block {
unlock(&c.lock)
return false
}
// Block on the channel. Some receiver will complete our operation for us.
// 获取当前 goroutine
gp := getg()
// 申请 sudog
mysg := acquireSudog()
// 下面代码对 sudog 进行初始化,将该 goroutine 和需要发送的数据保存在 sudog 中。
mysg.releasetime = 0
if t0 != 0 {
mysg.releasetime = -1
}
// No stack splits between assigning elem and enqueuing mysg
// on gp.waiting where copystack can find it.
mysg.elem = ep
mysg.waitlink = nil
mysg.g = gp
mysg.isSelect = false
mysg.c = c
gp.waiting = mysg
gp.param = nil
// 将 sudog 入队到发送者阻塞队列 sendq 中。
c.sendq.enqueue(mysg)
// 调用 gopark 阻塞当前 goroutine。
gopark(chanparkcommit, unsafe.Pointer(&c.lock), waitReasonChanSend, traceEvGoBlockSend, 2)
// 为了保证被发送的数据保持存活(不被 GC 清除),sudog 有一个指针指向栈中的数据对象。
KeepAlive(ep)
// 其他人把我们唤醒。
if mysg != gp.waiting {
throw("G waiting list is corrupted")
}
gp.waiting = nil
gp.activeStackChans = false
if gp.param == nil {
if c.closed == 0 {
throw("chansend: spurious wakeup")
}
panic(plainError("send on closed channel"))
}
gp.param = nil
if mysg.releasetime > 0 {
blockevent(mysg.releasetime-t0, 2)
}
mysg.c = nil
// 释放 sudog。
releaseSudog(mysg)
return true
}
关于接收过程的分析大致与此类似,篇幅原因就不在此赘述,大家可以自己阅读源码src/runtime/chan.go。
关于 Go 的调度可以参考文章:Sceduling in go
但 Goroutine 实际上运行在系统线程上,由 runtime 调度器来将这些 Goroutine 安排到系统线程中。Go 语言通过 M:N 调度 将 N 个 goroutine 分配到最多 GOMAXPROCS 个处理器的 M 个系统线程中。 Goroutine 是用户级别线程,由 Go 语言的 runtime 来管理,相比系统线程更加轻量。 Goroutine 采用 M:N 调度模型即 M 个系统线程上运行 N 个 Goroutine。Thread Models
具体我们另写文章,不在此详细展开。
本文参考:
[2] Scalable Go Scheduler Design Doc