文章总结: 本文详细解析Go语言中channel的happened-before规则,重点阐述channel作为同步机制如何保证内存可见性。核心规则包括:发送操作同步到对应接收完成、关闭操作同步到因关闭而返回零值的接收、无缓冲channel的接收同步到发送完成、带缓冲channel的第k次接收同步到第k+C次发送完成。通过多个代码示例说明常见误区,如发送指针后修改数据可能导致竞争、无缓冲与有缓冲channel的语义差异等。最后给出排查并发问题的实用建议:重点检查发送前写的数据在接收后是否可读、close前写的数据在收到关闭信号后是否可读、无缓冲channel是否被误改为有缓冲、发送后是否仍在修改共享对象。 综合评分: 85 文章分类: 技术标准,安全开发,实战经验,代码审计,安全工具
Go 语言中关于 channel 的 happened-before 有哪些?
原创
go go
Go语言教程
2026年6月26日 18:28 陕西
在小说阅读器读本章
去阅读
线上偶发读到旧配置,我第一眼不会先怀疑 channel。
因为很多 Go 代码里,channel 被当成“队列”用,写着写着就忘了它还有一层更硬的东西:同步关系。也就是 Go 内存模型里的 happened-before。
这个东西不只是“谁先执行”。它真正管的是:一个 goroutine 里写过的内存,另一个 goroutine 到底能不能稳定看见。
Go 官方内存模型里,channel 相关的同步规则主要就这几条:发送同步到对应接收完成、关闭同步到因为关闭而返回零值的接收、无缓冲 channel 的接收同步到对应发送完成、带缓冲 channel 的第 k 次接收同步到第 k+C 次发送完成。这里 C 是 channel 容量。
看代码更直接。
type RouteTable struct {
Version int
Rules map[string]string
}
var table *RouteTable
func reload(done chan struct{}) {
table = &RouteTable{
Version: 17,
Rules: map[string]string{
"/pay": "pay-v2",
},
}
close(done)
}
func main() {
done := make(chanstruct{})
go reload(done)
<-done
// 这里能稳定看到 reload 里对 table 的写入
fmt.Println(table.Version, table.Rules["/pay"])
}
这段代码里,close(done) 不是单纯通知一下“结束了”。
它前面对 table 的写入,在 <-done 返回之后,对 main goroutine 是可见的。这里的 <-done 必须是因为 channel 被 close 了才返回。如果是从 buffered channel 里读到了 close 之前塞进去的普通值,那别把 close 的同步关系硬套上去。
再看发送和接收。
type BillJob struct {
ID int64
Amount int64
}
var lastJob BillJob
func producer(ch chan int64) {
lastJob = BillJob{ID: 10086, Amount: 39900}
ch <- lastJob.ID
}
func consumer(ch chan int64) {
id := <-ch
// 收到 id 以后,producer 在发送前写的 lastJob,对这里可见
fmt.Println(id, lastJob.Amount)
}
ch <- lastJob.ID happens-before 对应的 <-ch 完成。
所以发送前的写入,可以被接收后稳定观察到。这个规则在有缓冲和无缓冲 channel 上都成立。区别在后面。
我见过一种写法,表面看也用了 channel,实际还是埋雷:
type CacheItem struct {
Key string
Score int
}
func push(ch chan *CacheItem) {
item := &CacheItem{Key: "sku_7788", Score: 90}
ch <- item
// 这行就很别扭
item.Score = 100
}
func pull(ch chan *CacheItem) {
item := <-ch
fmt.Println(item.Score)
}
这里不能说接收方一定看到 90,也不能说一定看到 100。
channel 只能保证发送前的写入,对接收后可见。item.Score = 100 是发送之后发生的,接收方同时读这个字段,就有数据竞争的味道了。
这种代码我一般直接改掉。要么发送不可变快照,要么后续修改重新走一次同步。
func push(ch chan CacheItem) {
item := CacheItem{Key: "sku_7788", Score: 90}
ch <- item
// 后面再改,也只是改本地变量,接收方拿到的是发送时的值
item.Score = 100
}
无缓冲 channel 还有一条容易被忽略。
var auditID int64
func worker(ch chan struct{}) {
auditID = 9527
<-ch
}
func main() {
ch := make(chanstruct{})
go worker(ch)
ch <- struct{}{}
// 无缓冲 channel 下,worker 的接收完成
// happens-before main 里的发送完成
fmt.Println(auditID)
}
这段换成 make(chan struct{}, 1) 就不对了。
因为有缓冲 channel,ch <- struct{}{} 可能只是把数据放进缓冲区,发送方就继续往下跑了,不需要等接收方真的接住。很多人把无缓冲改成有缓冲,觉得只是“提高吞吐”,结果同步语义也顺手改没了。
带缓冲 channel 那条规则稍微绕一点:
容量是 C,第 k 次接收 happens-before 第 k+C 次发送完成。
它常用在限流信号量里。
func scanOrders(ids []int64) {
limit := make(chanstruct{}, 8)
var wg sync.WaitGroup
for _, id := range ids {
limit <- struct{}{}
wg.Add(1)
gofunc(orderID int64) {
defer wg.Done()
deferfunc() { <-limit }()
checkOrder(orderID)
}(id)
}
wg.Wait()
}
这个代码靠 buffered channel 控制最多 8 个任务并发。
limit <- struct{}{} 是占坑,<-limit 是释放坑。第 1 次释放,会同步到第 9 次占坑完成;第 2 次释放,会同步到第 10 次占坑完成。不是因为 channel 神奇,而是 Go 内存模型就这么定义。
还有个小坑,select 里也一样,只要某个 case 真正完成了 channel 发送、接收或者观察到 close,就按对应规则走。没选中的 case,没有同步关系。
select {
case task := <-taskCh:
handle(task)
case <-stopCh:
return
default:
// 这里没有和 taskCh、stopCh 建立任何 happened-before
}
default 跑了,只说明当时没等,不代表别的 goroutine 写过的东西你现在就能安全看。
所以 channel 的 happened-before,别背成概念。排查并发问题时按这个顺序看:
发送前写的数据,接收后能不能读; close 前写的数据,收到关闭信号后能不能读; 无缓冲 channel 有没有被改成带缓冲; 发送之后还在改共享对象没有。
前两种一般安全,后两种我会多看一眼。尤其是“发送指针,然后继续改字段”这种代码,看着省内存,线上出问题也最难赖别人。
免责声明:
本文所载程序、技术方法仅面向合法合规的安全研究与教学场景,旨在提升网络安全防护能力,具有明确的技术研究属性。
任何单位或个人未经授权,将本文内容用于攻击、破坏等非法用途的,由此引发的全部法律责任、民事赔偿及连带责任,均由行为人独立承担,本站不承担任何连带责任。
本站内容均为技术交流与知识分享目的发布,若存在版权侵权或其他异议,请通过邮件联系处理,具体联系方式可点击页面上方的联系我。
本文转载自:Go语言教程 go go《Go 语言中关于 channel 的 happened-before 有哪些?》
版权声明
本站仅做备份收录,仅供研究与教学参考之用。
读者将信息用于其他用途的,全部法律及连带责任由读者自行承担,本站不承担任何责任。









评论