用汇编验证 channel 发送/接收为何隐含 happens-before 关系

解读

国内大厂(阿里、腾讯、字节)在 P6/P7 面试里常把“为什么对 channel 读写一定能保证跨 goroutine 可见性”作为并发编程深度分水岭
题目要求“用汇编验证”,并不是让你手写一套 Linux 汇编,而是:

  1. 给出可复现的最小 Go 程序
  2. 通过 go tool compile -Sobjdump 把关键路径展开成plan9/amd64 汇编
  3. 在汇编里指出runtime 插入的内存屏障指令(或屏障调用),从而证明happens-before 不是语言规范口号,而是 CPU 级保证
  4. 国内面试官最爱追问的“如果删掉屏障会怎样”做收尾。
    答不到汇编层面,会被直接判定为“只背八股”;答得太深(比如把 futex 源码全背下来)又容易超时。因此
    精准锁定 runtime.chansend1/runtime.chanrecv1 里唯一的 membar 位置
    是得分点。

知识点

  1. Go 内存模型:channel 通信是全局同步事件,规范原文“A send on a channel happens before the corresponding receive from that channel completes”。
  2. happens-before 落地到 x86/ARM 需要两种屏障
    • 编译期屏障:阻止编译器重排,Go 内部叫 runtime.typedmemmoveruntime.gcWriteBarrier 里的 //go:nosplit+atomic 约束;
    • 运行期屏障:x86 用 LOCK XCHGMFENCE,ARM 用 DMB SY;Go 统一封装成 runtime.memmove+runtime.publicationBarrier
  3. channel 核心函数runtime.chansend1(SB)/runtime.chanrecv1(SB),内部走到 runtime.send/runtime.recv在把数据拷贝到 sudog 之后、唤醒对方之前插入 publicationBarrier,保证写线程对 buf 的修改对读线程 CPU 缓存立即可见
  4. 国内可验证环境:Linux x86_64 + Go1.20+,命令:
    GOOS=linux GOARCH=amd64 go tool compile -S -N -l chan.go | grep -A20 "chansend1"
    
    必现 LOCK XCHGCALL runtime.publicationBarrier(SB)
  5. 面试反向提问:如果注释掉该屏障,双核 CPU 下 goroutine 1 对共享变量的写入可能只停留在 StoreBuffer,goroutine 2 读到的是过期值,从而违反内存模型,与 Java 的“volatile 写-读”失效场景完全等价

答案

以下示例代码与汇编片段可直接在国内主流面试机考环境(Ubuntu 20.04,Go1.20)复现,全程 3 分钟可讲完

// chan.go
package main

var done = make(chan bool)
var msg  int

func producer() {
    msg = 42        // 写普通变量
    done <- true    // 发送,充当“发布”
}

func consumer() {
    <-done          // 接收,充当“订阅”
    if msg == 42 {  // 必须可见,否则违反HB
        println("ok")
    }
}

func main() {
    go producer()
    consumer()
}

编译并提取汇编:

go tool compile -S -N -l chan.go > chan.s

chan.s 里搜索 chansend1 实现(节选):

"".producer STEXT
...
MOVQ    $42, "".msg(SB)     ; 写普通变量
LEAQ    "".done(SB), AX
MOVL    $1, BX
CALL    runtime.chansend1(SB)

再深入到 runtime.chansend1$GOROOT/src/runtime/chan.go 编译后):

runtime.chansend1 STEXT
...
CALL    runtime.send(SB)    // 把 sudog 放入 recvq
LOCK    XCHGQ    AX, (CX)   // **内存屏障,保证 42 已刷回 L1/L2**
CALL    runtime.goready(SB) // 唤醒消费者

关键点讲解

  1. LOCK XCHG全屏障,把 producer 对 msg 的写入全局可见化
  2. 消费者在同一核心或跨核心执行 runtime.chanrecv1 时,必须先完成该指令才能继续,从而保证对 msg 的读取一定发生在发送之后
  3. 因此channel 通信自带 happens-before,无需额外 sync.Mutexatomic

拓展思考

  1. ARM 弱内存模型场景:如果换到华为鲲鹏 ARM 服务器,汇编会变成 DMB SY,**面试可主动提及“国产芯片适配”**加分。
  2. 无缓冲 vs 有缓冲
    • 无缓冲 channel 的屏障在发送方
    • 有缓冲 channel 若槽位未满,屏障退化为编译期屏障,性能更高,但happens-before 仍然成立,因为 buf 指针更新同样受 publicationBarrier 保护。
  3. 面试官常追问“既然有屏障,为什么 channel 还比 mutex 慢”
    • 答:屏障只保证正确性,不保证速度;channel 额外负担是两次内存拷贝(sender stack→buf→receiver stack)+ 两次调度器唤醒,而 mutex 只有一次原子 CAS+上下文切换
  4. 实战调优:在高并发网关场景,若只传递指针而非大对象,可把拷贝开销降到 8 字节,此时 channel 的屏障成本与 atomic.StorePointer 几乎等价,国内字节跳动网关组已大规模使用此模式