runtime.chansend1 是 Go 编译器将 ch

runtime.chansend1 是 Go 编译器将 ch 语句翻译后的底层调用入口,它不直接暴露给用户,但决定了发送行为的阻塞逻辑、panic 条件和锁竞争路径。理解它,不是为了手写汇编,而是避免在高并发或边界场景下掉进 runtime 的“静默陷阱”。
为什么 ch 会变成 <code>runtime.chansend1 而不是 chansend
Go 编译器对无缓冲 channel 和有缓冲 channel 的发送做了特化优化:runtime.chansend1 是编译期内联的单参数版本(即只传 channel 和值指针),而通用的 runtime.chansend 还要多传 block bool 和 callerpc。当你写 ch ,且 channel 类型确定、非 select 分支时,编译器默认走 <code>chansend1 路径——它本质上是 chansend(c, &v, true, ...) 的快捷封装。
这意味着:
- 你无法通过修改调用方式绕过阻塞逻辑:即使想做非阻塞发送,也得显式用
select { case ch ,此时编译器才生成对 <code>runtime.chansend的调用并传block=false -
chansend1永远是阻塞语义,哪怕 channel 是带缓冲且未满——它只是“不 panic”,但依然会加锁、检查、拷贝、更新索引,整个过程不可中断 - 汇编里看到多个
CALL runtime.chansend1,说明有多个独立的ch 语句,而非循环展开;每个调用都完整走一遍锁 + 判断 + 写入流程
runtime.chansend1 执行时的真实判断顺序
它不是“先看缓存再看 recvq”,而是按 runtime 源码中 chansend 函数的实际分支顺序执行。关键判断点如下(基于 go1.21.2):
- 若
c == nil:直接返回 false(非阻塞)或挂起 goroutine(阻塞),永不 panic - 若
c.closed != 0:立刻 panicsend on closed channel,**不检查缓存是否为空、不查 recvq 是否有等待者** - 若
c.recvq.first != nil:说明有 goroutine 卡在上,直接把值拷贝给那个 sudog 的 elem 字段,唤醒它,跳过 buf;这是无缓冲 channel 的核心通路 - 若
c.qcount :缓冲未满,把值拷贝进 <code>c.buf的c.sendx位置,sendx自增,qcount加一 - 否则(缓冲满 + 无 recvq):创建 sudog,入
c.sendq尾部,调用gopark挂起当前 goroutine
注意:closed 检查在 recvq / buf 检查之前,所以向已关闭 channel 发送,无论有没有等待接收者,都 panic。
容易被忽略的三个汇编/运行时细节
这些点不会报错,但会导致性能毛刺或行为反直觉:
-
chansend1总是获取c.lock,哪怕 channel 是无缓冲且 recvq 非空——也就是说,两个 goroutine 同时往同一个无缓冲 channel 发送(但只有一个能抢到 recvq),仍存在锁竞争;这不是“无锁通信” - 值拷贝发生在持有锁期间:对大 struct(比如 > 128B),
memmove会延长临界区,拖慢其他 goroutine 的 send/recv;建议传指针或小类型 - 汇编中看不到
sendx递增的原子性保护——它靠锁保证,不是 CAS;所以你在 gdb 里断在chansend1中间时,可能看到sendx == dataqsiz但qcount 的瞬态,这是正常环形队列翻转现象
真正难调试的,往往不是 panic 或死锁,而是 chansend1 在锁内做的那几件事:检查、拷贝、索引更新、唤醒——它们串行发生,且无法被 Go 代码打断。一旦 channel 成为热点,这里就是瓶颈根因。


















