需要预先了解:


1. 接口与 channel 的底层实现

接口 里留下的 nil 陷阱、channel 里"收发是拷贝"的结论,都可以从运行时结构上得到解释。这一篇看 eface/iface/itab 和 hchan 的真实字段,以及一次发送、一次接收、一次关闭在底层分别发生了什么。


1.1 eface 与 iface

空接口和非空接口在运行时是两种结构:

type eface struct { // 空接口 any / interface{}
	_type *_type          // 动态类型
	data  unsafe.Pointer  // 指向动态值的指针
}

type iface struct { // 带方法的接口
	tab  *itab            // 类型 + 方法表
	data unsafe.Pointer   // 指向动态值的指针
}

两者都是两个指针宽,在 64 位平台上是 16 字节:

var a any = 42
e := (*eface)(unsafe.Pointer(&a))
fmt.Printf("eface: _type=%p data=%p\n", e._type, e.data)
fmt.Println("接口变量大小:", unsafe.Sizeof(a))
eface: _type=0x7ff740181540 data=0x7ff7400c2180
接口变量大小: 16

非空接口:

var g Greeter = Person{Name: "一撮灰"}
it := (*iface)(unsafe.Pointer(&g))
fmt.Printf("iface: tab=%p data=%p\n", it.tab, it.data)
iface: tab=0x7ff740191300 data=0x7ff7400c22a8

注意 data 指向的是动态值的一份副本。如果值很小(不超过一个指针宽),早期版本会直接把它存在 data 字段里("直接接口"优化);当前实现统一走指针,由编译器决定是否分配。


1.2 itab 与动态派发

itab 描述"某个具体类型如何实现某个接口":

type itab struct {
	Inter *interfacetype // 接口的类型信息
	Type  *_type         // 具体类型
	Hash  uint32         // Type.Hash 的副本,用于类型断言和 type switch
	Fun   [1]uintptr     // 变长的方法表,fun[0] == 0 表示未实现
}

Fun 是一个变长数组:按接口方法的顺序存放每个方法的函数地址。调用 g.Greet() 时,运行时从 itab.Fun[i] 取出地址并跳转,这就是动态派发。

itab 是全局缓存的:运行时维护一张哈希表,键是(接口类型,具体类型),同一个组合只会构造一次。所以接口调用的开销主要在"查表 + 间接跳转",比直接调用多几个纳秒。


1.3 类型断言与 type switch

v.(T) 的流程:

  1. 取出接口的 _type(或 itab.Type);
  2. 与目标类型 T 比较:相等则命中;
  3. 如果 T 是接口类型,需要检查 _type 是否实现了 T(查 itab 表);
  4. 命中则返回 data 指向的值(必要时拷贝),否则 ok = false 或 panic。

type switch 编译成一系列比较,顺序是源码书写顺序,所以把最可能命中的类型放在前面能省几次比较。itab.Hash 的存在就是为了加速这个过程。


1.4 用底层结构解释 nil 接口陷阱

接口为 nil 的条件是两个字段都是 nil:

var nilAny any
ne := (*eface)(unsafe.Pointer(&nilAny))
fmt.Println("nil 接口: _type == nil:", ne._type == nil, "data == nil:", ne.data == nil)
nil 接口: _type == nil: true data == nil: true

而一个 nil 指针装进接口后:

var p *Person
var boxed any = p
pe := (*eface)(unsafe.Pointer(&boxed))
fmt.Println("nil 指针装箱: _type != nil:", pe._type != nil, "data == nil:", pe.data == nil)
nil 指针装箱: _type != nil: true data == nil: true

_type 是 *Person,非 nil,所以整个接口不等于 nil。这就是 接口 里那个陷阱的完整解释。


1.5 值接收者与指针接收者的方法表差异

接口里存的是值的副本,这一点和接收者类型无关。区别在于方法表里的函数签名:

  • 用值接收者实现接口时,方法表里的函数接收的是 data 指向的那份副本;
  • 用指针接收者实现接口时,data 指向的是原对象,方法可以修改它。
c := Counter{n: 1}
var ic any = c
c.n = 100
fmt.Println("修改原值后接口里仍是:", ic.(Counter).Get()) // 1

var ip any = &c
c.n = 200
fmt.Println("存指针时看到最新值:", ip.(*Counter).Get()) // 200

第一条输出 1,说明接口里保存的是装箱那一刻的副本;第二条输出 200,说明存的是指针。这也解释了为什么"值接收者方法不能修改接口里的值"。


1.6 hchan 结构

channel 变量本身是一个指针(8 字节),指向运行时的 hchan:

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          // channel 自带的锁
}

两个关键点:

  1. channel 自带锁。每次收发都要先加锁,所以 channel 不是"无锁队列"。这也是"高并发下 channel 可能成为瓶颈"的原因。
  2. buf 是环形队列,sendx 和 recvx 是读写指针,绕回到 0 时继续使用前面的空间。

recvq 和 sendq 是等待队列,元素是 sudog(被阻塞的 goroutine 的包装)。无缓冲 channel 的 dataqsiz 是 0,buf 为 nil。


1.7 发送与接收的完整流程

发送 ch <- v

加锁
 ├── recvq 非空(有等待的接收者)
 │     └── 直接把 v 拷贝到接收者的栈上,唤醒它 —— 快路径,不经过 buf
 ├── 缓冲区有空位(qcount < dataqsiz)
 │     └── 拷贝 v 到 buf[sendx],sendx = (sendx+1) % dataqsiz,qcount++
 └── 缓冲区满(或无缓冲且无人等待)
       └── 把当前 goroutine 包装成 sudog 挂到 sendq,解锁,阻塞
             └── 被唤醒后重新加锁,完成发送或 panic

接收 <-ch

加锁
 ├── sendq 非空(有等待的发送者)
 │     ├── 有缓冲:从 buf[recvx] 取一个,把发送者的值放进缓冲区空位
 │     └── 无缓冲:直接从发送者那里把值拷贝过来,唤醒发送者
 ├── 缓冲区非空
 │     └── 从 buf[recvx] 取值,recvx = (recvx+1) % dataqsiz,qcount--
 └── 缓冲区空
       └── 挂到 recvq,解锁,阻塞
             └── 被唤醒后从暂停点继续

"直接拷贝到接收者栈上"这条快路径,就是无缓冲 channel 不需要缓冲区也能工作的原因。


1.8 close 的底层动作

加锁
 ├── 设置 closed = 1
 ├── 唤醒 recvq 中所有等待者:它们读到零值,ok = false
 ├── 唤醒 sendq 中所有等待者:它们会 panic(send on closed channel)
 └── 解锁

这解释了 channel 里的三条规则:

  • 关闭后接收立即返回零值(recvq 里的等待者被直接唤醒);
  • 关闭后发送会 panic(sendq 里的等待者被唤醒后走 panic 分支);
  • 重复关闭 panic(closed 已经是 1)。

1.9 为什么是拷贝语义

发送时执行的是 memmove(dst, src, elemsize)——把值按字节复制过去。接收同理。所以:

type Big struct {
	Data [1024]byte
	Tag  int
}

bch := make(chan Big, 1)
b := Big{Tag: 1}
bch <- b       // 复制 1032 字节
b.Tag = 2      // 改的是发送方自己的变量
got := <-bch   // 又复制 1032 字节
fmt.Println(b.Tag, got.Tag) // 2 1
发送方: 2 接收方: 1

大结构体作为 channel 元素时,每次收发都是完整拷贝。传 *Big 可以避免拷贝,但要注意所有权:channel 传递指针意味着多个 goroutine 都能访问同一块内存,需要用约定或同步机制保证不并发写。

为什么无缓冲是 rendezvous

无缓冲 channel 的 dataqsiz 为 0,没有缓冲区可放。发送方必须找到一个正在等待的接收者,把值直接递过去;找不到就挂到 sendq 上睡觉。所以它表达的不是"把值放到某处",而是"把值交到对方手上"——这就是 rendezvous(会合)语义。


1.10 练习

  1. 用 unsafe.Sizeof 比较 chan int、chan struct{}、chan [1024]byte 变量的大小,解释为什么都是 8 字节。
  2. 写一个程序,在发送前打印发送方变量的地址、接收后打印接收方变量的地址,验证两个变量是不同的内存。
  3. 用两个无缓冲 channel 实现"两个 goroutine 严格交替执行"的接力(A 发 → B 收 → B 发 → A 收),观察输出顺序是否稳定。