go channel 探索

0 阅读12分钟

前言

此篇笔记主要以自己的浅薄见解去讨论 go channel 数据结构的底层设计, 目前包括: 数据结构, makechan操作, send操作, recv操作, close操作, 并伴随着代码走读形式进行记录. 在过程中会跳过一些跟文章主题相关的关系不大代码,着重核心代码的解释, 故在后面的源码展示中出现// ... [explaination]格式的注释, 表示这里有被忽略的代码, 并可能会附上这段代码的一些解释

golang倡导 通过通信共享内存, 而不是通过共享内存进行通信.
通过共享内存进行通信 在形式上表现为两个协程通过共享一个外部变量(如全局变量)来实现信息的共享 通过通信共享内存 便是此篇笔记的主角, channel

数据结构

channel分为非缓冲channel缓冲channel
创建如下:

  // 必须要通过make创建, 使用一个nil channel 会panic
  noCacheChannel := make(chan int) // 非缓冲channel
  cacheChannel := make(chan int, 3) // 缓冲channel

两者区别为非缓冲channel遵守一进一出原则, 如果没有立即接收数据, sender端则会block, 反之, 没有立即发送数据, receiver端会block. 而缓冲channel则有个buffer 区域临时堆放数据, 就算receiver端没有及时接收数据, 数据会放在buffer里, sender端不会block, 直到buffer区域堆满为止, 而receiver则会不断从buffer读取数据直到buffer为空.

数据结构:

type hchan struct {
	qcount   uint // 当前buffer数据大小      
	dataqsiz uint // buffer数据容量          
	buf      unsafe.Pointer // buffer内存指针
	elemsize uint16 // 数据类型占用内存大小
	closed   uint32 // channel关闭状态
	elemtype *_type // 数据类型
	sendx    uint  // buffer 头指针(发送端数据从头部插入)
	recvx    uint  // buffer 尾指针(接收端数据从尾部插入)
        
  // 等待队列, 存储对应休眠的routine上下文, waitq 本质是一个双向链表
	recvq    waitq  // 接收端等待队列, 先进先出原则
	sendq    waitq  // 发送端等待队列, 先进先出原则

	lock mutex // 锁
}

其中buffer本身是一个环形链表, 数据从头部进列, 尾部出列, 即发送和接收也是按照先进先出进行的, 当sendx == recvx 时, 意味着buffer当前处于是full或者empty

如下图为一个 存储了3个数据的 4-buffer channel:

channel_v2.png

makechan解读

即通过make标识符进行通道创建触发的底层代码:

func makechan(t *chantype, size int) *hchan {
	elem := t.Elem

	// ... 编译器检查代码
      
	var c *hchan
	switch {
	case mem == 0: // mem 代表需要额外分配的buffer内存, 进入case说明是 no-buffer-channel
		c = (*hchan)(mallocgc(hchanSize, nil, true)) // 仅分配 hchan自身所需要的内存
		c.buf = c.raceaddr() // 没有缓冲区但为了应付竞态检测, 该值没有意义
	case elem.PtrBytes == 0: // 需要额外分配的buffer没有包含指针变量时进入该case
		c = (*hchan)(mallocgc(hchanSize+mem, nil, true)) // hchan和buffer内存一起分配
		c.buf = add(unsafe.Pointer(c), hchanSize) // 标记对应内存空间首指针
	default: // 进入此case说明buffer区域含有指针变量
		c = new(hchan)
		c.buf = mallocgc(mem, elem, true) // 此时buffer需要独立分配, 因为mallogc方法需要带类型, gc才会进行扫描
	}
        
    // 参数初始化
	c.elemsize = uint16(elem.Size_)
	c.elemtype = elem
	c.dataqsiz = uint(size)
	lockInit(&c.lock, lockRankHchan)
	}
	return c
}

首先我们先看下定义,该方法接收一个类型(*type)和容量(size), 返回对应的实例(hchan)

而代码核心在中间switch条件判断那里:

  • 第一个case说明当前是一个无缓冲channel, 故只需分配channel自身的内存即可
    不过值得关注的是mallocgc的定义

    func mallocgc(size uintptr, typ *_type, needzero bool) unsafe.Pointer

    第二个参数是类型, 如果这个参数为nil, 说明channel自身是不需要gc扫描的, 也就是说里面是没有引用变量的, 下面引用一段官方注释, 解释了这种情况:

    // Hchan does not contain pointers interesting for GC when elements stored in buf do not contain pointers.
    // buf points into the same allocation, elemtype is persistent.
    

    也就是说只要buf中的elem不包含指针, 实际上hchan是不需要扫描的, 直接回收即可(没有引用到其他内存), 而唯一的指针*_type是随着程序的启动永久放在内存中的, 不需要gc回收.

  • 第二个case是一个优化, 当buffer里面没有包含指针变量时, 只需分配一次内存即可, buffer会跟着hchan一起被回收
    这里的没有包含指针变量是指elem本身不是指针或elem里没有包含指针, 如果一个结构体里有指针成员则会被识别带有指针, 这里感兴趣的可以通过debug观察hchan.elemtype.PtrBytes是不是为0来判断

image.png

  • 第三个case, 当buffer包含指针变量时, 需要被gc扫描, 所以buffer需要单独分配内存, 此时需要进行两次内存分配

send 解读

有两种调用方式:

   // ---one 直接使用
   ch := make(chan int)
   ch <- 1 // 这里接收端没有及时接收会block
   
   // ---two 配合select使用
   select {
    case ch <- 1:
    default:
       fmt.Println("no receiver, but no block") // 这里case分支走不通时总能跑到default, 故不会block
   }

核心源码:

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

   // -------section 1. nil channel
	if c == nil {
            if !block { // 如果channel未初始化, no-block mode下, 则不会panic
                    return false
            }
               
            // 丢出panic
            gopark(nil, nil, waitReasonChanSendNilChan, traceBlockForever, 2)
            throw("unreachable")
	}

   // ...
   
   // -------section 2 fast path
	if !block && c.closed == 0 && full(c) {
		return false
	} // no-block mode下, 通道已满, 返回nil

	// ...
        
	lock(&c.lock)

   // -------section 3 check closed
	// 如果通道已经close了, panic
	if c.closed != 0 {
		unlock(&c.lock)
		panic(plainError("send on closed channel"))
	}
   
   // -------section 4 exist reciver            
   // 存在等待中的接收端, 将数据传递过去, 唤醒该g
	if sg := c.recvq.dequeue(); sg != nil {
		send(c, sg, ep, func() { unlock(&c.lock) }, 3)
		return true
	}
  
  // -------section 5 buffer is no full
  // buffer有空闲的空间, 将数据放进buffer
	if c.qcount < c.dataqsiz { // 有缓存情况
		qp := chanbuf(c, c.sendx)	
           // ...                    
		typedmemmove(c.elemtype, qp, ep) // 放入缓存
		c.sendx++
		if c.sendx == c.dataqsiz {
			c.sendx = 0
		}
		c.qcount++
		unlock(&c.lock)
		return true
	}
    
   // -------section 6 g need to block
   // 跑到这里表示数据没有归处, 这里需要block了
                         
	if !block { // no-block mode 下, 直接结束
		unlock(&c.lock)
		return false
	}

	gp := getg()
	mysg := acquireSudog()
                                
	// ... 将当前g和上下文封装进mysg(sudog), 包括待发送的值指针, 放在mysg.elem
                                
	c.sendq.enqueue(mysg) // 进入发送端等待队列
   // 当前g 休眠
	gp.parkingOnChan.Store(true)
	gopark(chanparkcommit, unsafe.Pointer(&c.lock), waitReasonChanSend, traceBlockChanSend, 2)
    // -------section 7 g wake up
    // 当前g 唤醒
                                
	// ... 回收sudog
   
   //醒来发现通道已close的处理
	if closed {
		if c.closed == 0 {
			throw("chansend: spurious wakeup")
		}
		panic(plainError("send on closed channel"))
	}
	return true
}

老规矩, 先看定义 func chansend(c *hchan, ep unsafe.Pointer, block bool, callerpc uintptr) (selected bool), 这里参数依次为 channel自身(c), 需传递的值(cp), 是否为no-block mode(block), 调用者的pc指针(callerpc), callerpc跟主流程关系不大, 不做讨论.
返回值适用于select情景, 表示该case分支是否block
定义这边需要着重探讨的是参数block, 如果为false, 表示在一些情况下, 将不会panic/block, 而是直接结束流程, 我这里把这种场景称为no-block mode
那什么时候需要no-block mode, 什么时候不需要呢? 这个问题就需要看其入口了:

  1. 类似 c<-x用法时, block->true
// entry point for c <- x from compiled code.
//
//go:nosplit
func chansend1(c *hchan, elem unsafe.Pointer) {
	chansend(c, elem, true, getcallerpc())
}
  1. 通过配合select使用时, block -> false, 此时是否block将由select进行控制
// compiler implements
//
//	select {
//	case c <- v:
//		... foo
//	default:
//		... bar
//	}
//
// as
//
//	if selectnbsend(c, v) {
//		... foo
//	} else {
//		... bar
//	}
func selectnbsend(c *hchan, elem unsafe.Pointer) (selected bool) {
	return chansend(c, elem, false, getcallerpc())
}

接下来是核心源码的讨论, 这里我们按照从上到下划分的区域进行讨论:

  1. section 1, channel未初始化, no-block mode: 直接返回, 否则, panic. 这里引出了channel的一个特性, 往nil channel 写入会panic, 而在select-case中则不会, 所处的case将不会执行
  2. section 2, 这里是一个no-block mode 场景下的快速判断, 在no-block mode下, 如果通道没有接收端且buffer已满, 则直接结束. 因为后面的逻辑要加锁, 所以这里其实是一个优化
func full(c *hchan) bool {
	if c.dataqsiz == 0 {
		return c.recvq.first == nil // 没有等待中的接收端
	}
	return c.qcount == c.dataqsiz // full buffer
}
  1. section 3, 往一个已关闭的channel写数据, panic
  2. section 4, 如果有等待中的接收端, 唤醒, 并将值传递给接收端, success, 这里sg(sudog)是g的一个封装, 带着上下文信息
func send(c *hchan, sg *sudog, ep unsafe.Pointer, unlockf func(), skip int) {
   // ...
    
	if sg.elem != nil { // 有可能接收端没有接收方, 如: <-ch
		sendDirect(c.elemtype, sg, ep) //直接将值拷贝给接收方
		sg.elem = nil
	}
	gp := sg.g
	unlockf()
	gp.param = unsafe.Pointer(sg)
// sg 上下文更新
	sg.success = true
	if sg.releasetime != 0 {
		sg.releasetime = cputicks()
	}
	goready(gp, skip+1) // 唤醒 g
}
                                                    
func sendDirect(t *_type, sg *sudog, src unsafe.Pointer) {
	dst := sg.elem
  // 这里是直接进行两个g之间的栈拷贝, 需要对对应的接收方(dst)进行写屏障处理, 不然gc不会扫描dst 
	typeBitsBulkBarrier(t, uintptr(dst), uintptr(src), t.Size_)
	memmove(dst, src, t.Size_) // 内存拷贝
}                                                    
  1. section 5, 有空闲的buffer, 将值暂时放到buffer, success
func chanbuf(c *hchan, i uint) unsafe.Pointer {
	return add(c.buf, uintptr(i)*uintptr(c.elemsize)) // 以随机地址的方式获取第i元素的地址
}
  1. section 6, 数据无处可去 no-block mode下, 这里直接结束, 否则, 将当前g封装, 放入发送端等待队列并休眠, 直到被接收端唤醒
  2. section 7, 被唤醒, 回收sudog, 如果此时发现channel已经close, 表示是因为close操作导致的唤醒, 直接panic, 否则, 便是被接收端唤醒, success

recv解读

有以下几种调用方式:

   ch := make(chan int)
   // 如果没有发送端, 会block
   v := <-ch // 有接收值
   <-ch // 无接收值
   v, ok := <-ch // 不会block, 如果通道close, 返回 nil, false, 不会panic
   
   // 配合select使用
   select {
    case <-ch:
    case v = <-ch:
    case v, ok = <-ch:
    default:
       fmt.Println("no sender, but no block") // 这里case分支走不通时总能跑到default, 故不会block

核心源码:

func chanrecv(c *hchan, ep unsafe.Pointer, block bool) (selected, received bool) {
   // ...
   
   // section 1: nil channel                    
	if c == nil {
		if !block {
			return
		}
		gopark(nil, nil, waitReasonChanReceiveNilChan, traceBlockForever, 2)
		throw("unreachable")
	}

	// section 2: fast path
	if !block && empty(c) {
		if atomic.Load(&c.closed) == 0 {
			return
		}
		if empty(c) {
			// ...

			if ep != nil {
				typedmemclr(c.elemtype, ep) // 将ep置为零值
			}
			return true, false
		}
	}

	// ...

	lock(&c.lock)

	if c.closed != 0 {
           // section 3: channel closed and no unhandled data
		if c.qcount == 0 {
			// ...

			unlock(&c.lock)
			if ep != nil {
				typedmemclr(c.elemtype, ep) // 将ep置为零值
			}
			return true, false
		}
	} else {
		// section 4: exist waiting sender
		if sg := c.sendq.dequeue(); sg != nil {
			recv(c, sg, ep, func() { unlock(&c.lock) }, 3)
			return true, true
		}
	}

	// section 5: exist buffer data
	if c.qcount > 0 {
		qp := chanbuf(c, c.recvx)

		// ...

		if ep != nil { // // 有可能接收端没有接收方, 如: <-ch
			typedmemmove(c.elemtype, ep, qp) // 值拷贝
		}
		typedmemclr(c.elemtype, qp) // ? 这里不知道为什么要多出这一步
		c.recvx++
		if c.recvx == c.dataqsiz {
			c.recvx = 0
		}
		c.qcount--
		unlock(&c.lock)
		return true, true
	}

   // section 6: asleep
	if !block {
		unlock(&c.lock)
		return false, false
	}

	// 没有数据且通道活跃, 此时休眠
	gp := getg()
	mysg := acquireSudog()

	// ... 将当前g和上下文封装进mysg(sudog), 包括待接收的值指针, 放在mysg.elem

	c.recvq.enqueue(mysg) // 接收端等待队列入队
	gp.parkingOnChan.Store(true)
	gopark(chanparkcommit, unsafe.Pointer(&c.lock), waitReasonChanReceive, traceBlockChanRecv, 2) // 休眠
   // section 7: wakeup

	// ... check & 回收sudog

	return true, success
}

定义: func chanrecv(c *hchan, ep unsafe.Pointer, block bool) (selected, received bool), 基本跟chansend大差不差, 不过多赘述, 明显的区别是返回多了个received, 这里指明了接收端的另一种语法:

v, ok := <-ch // ok 即是返回值received

接下来跟着核心源码讨论流程:

  1. section 1: nil channel 场景, 往nil channel读数据会panic, no-block mode下, selected 返回false

  2. section 2: no-block mode 的fastpath 场景, 这里可能有疑惑的点是三次判断: isEmpty -> isClose -> isEmpty
    如果反过来这样判断: isClose -> isEmpty, 在判断close时, channel是open, 而在判断empty时, channel是close,此时程序认为channel状态为open, empty, 而实际上应该是close, empty
    而按照源码的顺序的话, 在第二次判断时, 如果channel是open, 那就代表在第一次判断中channel应该是empty, open 状态, 而第三次判断时为了处理channel关闭时, 是否还有数据的情况. 而如果系统认为是非empty, 而实际上是empty的情况, 在后面的代码也有做二次处理.

  3. section 3: 再次检查channel状态是否close, empty, 是直接结束

  4. section 4: 是否有休眠中的发送端, 有的话唤醒, 如果此时存在buffer, buffer一定是full, 此时优先从buffer取值, 并将唤醒的发送端的值入列buffer, 如果是unbuffered channel, 则直接从发送端取值

func recv(c *hchan, sg *sudog, ep unsafe.Pointer, unlockf func(), skip int) {
	if c.dataqsiz == 0 { // buffer没有数据再从等待中的发送端获取
		// ...

		if ep != nil { // 有可能接收端没有接收方, 如: <-ch
			recvDirect(c.elemtype, sg, ep) // 直接从发送端获取
		}
	} else { // buffer 存在数据优先从buffer获取
		qp := chanbuf(c, c.recvx) // 从buffer中取出一个值

		// ...

		if ep != nil { // 有可能接收端没有接收方, 如: <-ch
			typedmemmove(c.elemtype, ep, qp) // 值拷贝
		}

           // 将发送端的值拷贝到buffer
		typedmemmove(c.elemtype, qp, sg.elem)
		c.recvx++
		if c.recvx == c.dataqsiz {
			c.recvx = 0
		}
		c.sendx = c.recvx // 当queue为full时, sendx == recvx
	}
  //更新sg上下文并唤醒
	sg.elem = nil
	gp := sg.g
	unlockf()
	gp.param = unsafe.Pointer(sg)
	sg.success = true
	if sg.releasetime != 0 {
		sg.releasetime = cputicks()
	}

	goready(gp, skip+1) // 唤醒
}

func recvDirect(t *_type, sg *sudog, dst unsafe.Pointer) {
	src := sg.elem

   // 写屏障处理                             
	typeBitsBulkBarrier(t, uintptr(dst), uintptr(src), t.Size_)
	memmove(dst, src, t.Size_) // 值拷贝
}
  1. section 5: buffer有值, 从buffer获取

  2. section 6, 没有数据 no-block mode下, 这里直接结束, 否则, 将当前g封装, 放入接收端等待队列并休眠, 直到被发送端唤醒

  3. section 7, 被唤醒, 回收sudog, success

close 解读

通过close关键字关闭channel触发:

close(ch)

channel close后, 所有等待中的发送端Panic, 所有等待中的接收端收到零值
核心源码:

func closechan(c *hchan) {
	if c == nil { // close未初始化的channel, panic
		panic(plainError("close of nil channel"))
	}

	lock(&c.lock)
	if c.closed != 0 { // close已经关闭的channel, panic
		unlock(&c.lock)
		panic(plainError("close of closed channel"))
	}

	// ...

	c.closed = 1

	var glist gList
   // 预处理所有的接收端
	// release all readers
	for {
		sg := c.recvq.dequeue()
		if sg == nil {
			break
		}
		if sg.elem != nil {
			typedmemclr(c.elemtype, sg.elem) // 所有接收方值置0
			sg.elem = nil
		}

		// ...

		gp := sg.g
		gp.param = unsafe.Pointer(sg)
		sg.success = false

		// ...

		glist.push(gp)
	}
   // 预处理所有的发送端
	// release all writers (they will panic)
	for {
		sg := c.sendq.dequeue()
		if sg == nil {
			break
		}
		sg.elem = nil

		//...

		gp := sg.g
		gp.param = unsafe.Pointer(sg)
		sg.success = false

		// ...

		glist.push(gp)
	}
	unlock(&c.lock)

   // 唤醒所有的发送端和接收端
	// Ready all Gs now that we've dropped the channel lock.
	for !glist.empty() {
		gp := glist.pop()
		gp.schedlink = 0
		goready(gp, 3)
	}
}

这里源码相对比较简单,
首先检查, 如果关闭一个未初始化或者已关闭的channel, 将会panic; 预处理所有等待中的发送端和接收端, 然后进行唤醒
如果发送端因此被唤醒, 将会panic, 这部分逻辑写在chanshend里

后记

该笔记有些代码涉及到竞态检测(race), 垃圾回收, 协程模型, 内存模型等相关的内容, 这部分内容未来可能会在本账号的其他文章有所体现, 也可能不会.

初稿编辑于 2026.3.2