Golang 笔记 | Go 语言 Channel 实现原理

780 阅读9分钟

概述

文章总字数为: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,此时,数据结构如下:

我们可以在对应的 Go 源码的 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

你可能会问,还有很多东西没初始化呢,比如 qcountsendx, 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 结构体的时候,我们看到过,有两个元素叫做 sendqrecvq,这两个就是分别用来存放该 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

具体我们另写文章,不在此详细展开。


本文参考:

[1] src/runtime/proc.go

[2] Scalable Go Scheduler Design Doc

[3] Scheduling Multithreaded Computations by Work Stealing

[4] GopherCon 2017: Kavya Joshi - Understanding Channels