Go语言中关于channel的happened-before有哪些?

admin 2026-06-30 10:42:40 网络安全文章 来源:ZONE.CI 全球网 0 阅读模式

文章总结: 本文详细解析Go语言中channel的happened-before规则,重点阐述channel作为同步机制如何保证内存可见性。核心规则包括:发送操作同步到对应接收完成、关闭操作同步到因关闭而返回零值的接收、无缓冲channel的接收同步到发送完成、带缓冲channel的第k次接收同步到第k+C次发送完成。通过多个代码示例说明常见误区,如发送指针后修改数据可能导致竞争、无缓冲与有缓冲channel的语义差异等。最后给出排查并发问题的实用建议:重点检查发送前写的数据在接收后是否可读、close前写的数据在收到关闭信号后是否可读、无缓冲channel是否被误改为有缓冲、发送后是否仍在修改共享对象。 综合评分: 85 文章分类: 技术标准,安全开发,实战经验,代码审计,安全工具


cover_image

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)

&nbsp;<-done

// 这里能稳定看到 reload 里对 table 的写入
&nbsp;fmt.Println(table.Version, table.Rules["/pay"])
}

这段代码里,close(done) 不是单纯通知一下“结束了”。

它前面对 table 的写入,在 <-done 返回之后,对 main goroutine 是可见的。这里的 <-done 必须是因为 channel 被 close 了才返回。如果是从 buffered channel 里读到了 close 之前塞进去的普通值,那别把 close 的同步关系硬套上去。

再看发送和接收。

type&nbsp;BillJob&nbsp;struct&nbsp;{
&nbsp;ID &nbsp; &nbsp;&nbsp;int64
&nbsp;Amount&nbsp;int64
}

var&nbsp;lastJob BillJob

func&nbsp;producer(ch&nbsp;chan&nbsp;int64)&nbsp;{
&nbsp;lastJob = BillJob{ID:&nbsp;10086, Amount:&nbsp;39900}
&nbsp;ch <- lastJob.ID
}

func&nbsp;consumer(ch&nbsp;chan&nbsp;int64)&nbsp;{
&nbsp;id := <-ch

// 收到 id 以后,producer 在发送前写的 lastJob,对这里可见
&nbsp;fmt.Println(id, lastJob.Amount)
}

ch <- lastJob.ID happens-before 对应的 <-ch 完成。

所以发送前的写入,可以被接收后稳定观察到。这个规则在有缓冲和无缓冲 channel 上都成立。区别在后面。

我见过一种写法,表面看也用了 channel,实际还是埋雷:

type&nbsp;CacheItem&nbsp;struct&nbsp;{
&nbsp;Key &nbsp;&nbsp;string
&nbsp;Score&nbsp;int
}

func&nbsp;push(ch&nbsp;chan&nbsp;*CacheItem)&nbsp;{
&nbsp;item := &CacheItem{Key:&nbsp;"sku_7788", Score:&nbsp;90}

&nbsp;ch <- item

// 这行就很别扭
&nbsp;item.Score =&nbsp;100
}

func&nbsp;pull(ch&nbsp;chan&nbsp;*CacheItem)&nbsp;{
&nbsp;item := <-ch
&nbsp;fmt.Println(item.Score)
}

这里不能说接收方一定看到 90,也不能说一定看到 100。

channel 只能保证发送前的写入,对接收后可见。item.Score = 100 是发送之后发生的,接收方同时读这个字段,就有数据竞争的味道了。

这种代码我一般直接改掉。要么发送不可变快照,要么后续修改重新走一次同步。

func&nbsp;push(ch&nbsp;chan&nbsp;CacheItem)&nbsp;{
&nbsp;item := CacheItem{Key:&nbsp;"sku_7788", Score:&nbsp;90}
&nbsp;ch <- item

&nbsp;// 后面再改,也只是改本地变量,接收方拿到的是发送时的值
&nbsp;item.Score =&nbsp;100
}

无缓冲 channel 还有一条容易被忽略。

var&nbsp;auditID&nbsp;int64

func&nbsp;worker(ch&nbsp;chan&nbsp;struct{})&nbsp;{
&nbsp;auditID =&nbsp;9527

&nbsp;<-ch
}

func&nbsp;main()&nbsp;{
&nbsp;ch :=&nbsp;make(chanstruct{})

go&nbsp;worker(ch)

&nbsp;ch <-&nbsp;struct{}{}

// 无缓冲 channel 下,worker 的接收完成
// happens-before main 里的发送完成
&nbsp;fmt.Println(auditID)
}

这段换成 make(chan struct{}, 1) 就不对了。

因为有缓冲 channel,ch <- struct{}{} 可能只是把数据放进缓冲区,发送方就继续往下跑了,不需要等接收方真的接住。很多人把无缓冲改成有缓冲,觉得只是“提高吞吐”,结果同步语义也顺手改没了。

带缓冲 channel 那条规则稍微绕一点:

容量是 C,第 k 次接收 happens-before 第 k+C 次发送完成。

它常用在限流信号量里。

func&nbsp;scanOrders(ids []int64)&nbsp;{
&nbsp;limit :=&nbsp;make(chanstruct{},&nbsp;8)
var&nbsp;wg sync.WaitGroup

for&nbsp;_, id :=&nbsp;range&nbsp;ids {
&nbsp; limit <-&nbsp;struct{}{}
&nbsp; wg.Add(1)

gofunc(orderID&nbsp;int64)&nbsp;{
&nbsp; &nbsp;defer&nbsp;wg.Done()
&nbsp; &nbsp;deferfunc()&nbsp;{ <-limit }()

&nbsp; &nbsp;checkOrder(orderID)
&nbsp; }(id)
&nbsp;}

&nbsp;wg.Wait()
}

这个代码靠 buffered channel 控制最多 8 个任务并发。

limit <- struct{}{} 是占坑,<-limit 是释放坑。第 1 次释放,会同步到第 9 次占坑完成;第 2 次释放,会同步到第 10 次占坑完成。不是因为 channel 神奇,而是 Go 内存模型就这么定义。

还有个小坑,select 里也一样,只要某个 case 真正完成了 channel 发送、接收或者观察到 close,就按对应规则走。没选中的 case,没有同步关系。

select&nbsp;{
case&nbsp;task := <-taskCh:
&nbsp;handle(task)
case&nbsp;<-stopCh:
&nbsp;return
default:
&nbsp;// 这里没有和 taskCh、stopCh 建立任何 happened-before
}

default 跑了,只说明当时没等,不代表别的 goroutine 写过的东西你现在就能安全看。

所以 channel 的 happened-before,别背成概念。排查并发问题时按这个顺序看:

发送前写的数据,接收后能不能读; close 前写的数据,收到关闭信号后能不能读; 无缓冲 channel 有没有被改成带缓冲; 发送之后还在改共享对象没有。

前两种一般安全,后两种我会多看一眼。尤其是“发送指针,然后继续改字段”这种代码,看着省内存,线上出问题也最难赖别人。


免责声明:

本文所载程序、技术方法仅面向合法合规的安全研究与教学场景,旨在提升网络安全防护能力,具有明确的技术研究属性。

任何单位或个人未经授权,将本文内容用于攻击、破坏等非法用途的,由此引发的全部法律责任、民事赔偿及连带责任,均由行为人独立承担,本站不承担任何连带责任。

本站内容均为技术交流与知识分享目的发布,若存在版权侵权或其他异议,请通过邮件联系处理,具体联系方式可点击页面上方的联系我

本文转载自:Go语言教程 go go《Go 语言中关于 channel 的 happened-before 有哪些?》

评论:0   参与:  0