数据所有权:预防 Go data race 的设计思维

从"怎么检测 data race"升级到"怎么设计没有 data race 的代码"——数据所有权心智模型,4 组自造代码 demo 验证。

封面

导读:每个 Go 开发者都遇到过 data race。很多 data race 不是出在复杂的并发场景,而是出在你觉得"这里不需要锁"的那行代码。


我写了一段代码——不是 1000 行的大型项目,只是一个计数器。10 个 goroutine 各加一次。看起来简单到不可能出错。

然后我跑了 -race。3 个 data race。结果丢了 40% 的数据。

这不是锁的问题。这是我写代码的时候没有想清楚一个问题:这份数据归谁管?

两个 goroutine 同时往切片 append

一、一段"没问题"的代码

你写过这样的代码吗?

var results []int
var wg sync.WaitGroup

for i := 0; i < 10; i++ {
    wg.Add(1)
    go func(id int) {
        defer wg.Done()
        results = append(results, id)
    }(i)
}
wg.Wait()

这段代码看起来没问题。没有复杂的并发模式,没有互斥锁的误用,没有 channel 的死锁。只是简单的 append。很多人第一反应是"append 怎么会不安全?"

我跑了一下:

race detector 报告了 3 个 data race,最终结果只收到 6 个元素:[0 3 7 6 4 9]

10 个 goroutine 往同一个切片里 append,结果只收到了 6 个。丢失了 4 个元素。

append 不是原子的。它在底层做的事情是:读切片的 len,检查容量,必要时 growslice,写入数据,更新 len。这些步骤之间,另一个 goroutine 可能已经改了同一个切片。多个 goroutine 同时读取并更新切片的 len 字段,导致两个 goroutine 认为自己写入的是同一个位置——实际上后写入的会覆盖前一个。

这不是你写错了什么锁。是你根本没意识到"这个切片归谁管"是一个问题。

这种"看起来没问题"的代码,是 data race 最隐蔽的来源。它们不像教科书里的死锁例子那么明显——没有两个 goroutine 持锁等待彼此,没有 select 的随机选择。它们就是你每天写的普通并发代码,多个 goroutine 碰了同一份数据。

我再给你看一个。共享 map 的场景同样常见:

var cache = make(map[string]int)
var wg sync.WaitGroup

for i := 0; i < 10; i++ {
    wg.Add(1)
    go func(n int) {
        defer wg.Done()
        cache[fmt.Sprintf("key-%d", n)] = n  // data race!
    }(i)
}
wg.Wait()

map 在 Go 里对并发写入更敏感——检测到 concurrent map writes 会直接 fatal crash,比 data race 更致命(并发读写的报错是 concurrent map read and map write,并发写写是 concurrent map writes)。但根因是一样的:多个 goroutine 共享同一个 map,没人说清楚"这个 map 归谁管"。

你可能会说:“那加个 sync.Mutex 不就行了?”

对,加锁能解决。但问题不在这里。问题是——你每次写并发代码的时候,都会意识到"这里需要加锁"吗?data race 最危险的地方不是"我知道有并发问题但忘了加锁",而是"我根本没意识到这段代码会被并发执行"。

举个例子:你写了一个函数,内部用了一个包级别的 map 做缓存。一开始只有一个 goroutine 调它,没问题。后来需求变了,你在另一个 goroutine 里也调了这个函数——data race 就出现了。代码没改,调用方式变了。这种 data race,加锁思维是防不住的。你需要的是一个在设计层面就帮你规避问题的框架。

data race 流程图——len/ptr/cap 竞争

二、问题不在锁,在"谁拥有数据"

大部分人对并发安全的直觉是这样的:多个 goroutine 访问同一份数据 → 加锁。或者:多个 goroutine 访问同一份数据 → 用 channel 通信。

这两种直觉都有道理,但都不够。加锁和 channel 解决的是操作级别的问题——这次访问是否安全。它们没有回答一个更根本的问题:这份数据到底归哪个 goroutine 管?

我拿一组真实数据来说明这个问题的普遍性。

Tu 等人 2019 年的研究(PDF)分析了 Docker、Kubernetes、etcd 等 6 个流行 Go 项目中的 171 个真实并发 bug,发现 channel 相关的 bug 比例高于 mutex,且大多数 bug 是同步模式错误而非 goroutine 泄漏。其中一个典型 case:两个 goroutine 通过 channel 传了结构体指针,发送方和接收方同时修改同一个结构体——data race。即使用对了同步原语,没想清楚数据归属,仍然会出问题。

这就是"同一份数据?有写入?谁同步?“这个检查框架的价值——它帮你快速判断一段代码是否有 data race。但它的局限是,这是一个事后分析框架。你只能对着已经写好的代码问这三个问题。而"数据所有权"模型是事前设计框架——你在写代码之前就想清楚数据归谁管。

拿上面的 append 例子来对比:

  • 事后分析:同一个切片?是。有写入?是。谁同步?没人。→ 这是 data race。
  • 事前设计:这个切片归谁管?归某个 goroutine。那其他 goroutine 要写入怎么办?通过这个 goroutine 来写。

一个是被动检测,一个是主动设计。

这个区别很关键。被动检测的问题在于:它假设你"想到了要检测”。但大多数 data race 不是因为你不知道要检测——而是因为你根本没意识到那段代码有并发问题。那 171 个真实 bug 里,有多少是作者写了之后从来没跑过 -race?这是一个值得思考的问题。

事后分析 vs 事前设计对比图

三、数据所有权模型

数据所有权三原则示意图

数据所有权模型只有三个原则,但把它们想透、用透,能消灭大部分 data race。

原则一:主人唯一。 每个共享变量有且只有一个 goroutine 负责写入。其他 goroutine 可以读取,但如果有多个 goroutine 需要读取最新值,所有读操作也经过 owner channel 统一处理——否则可能出现"读到旧值"的问题。

这听起来简单,但能做到的人不多。举个例子:一个配置管理模块,多个 goroutine 都可能调用 UpdateConfigGetConfig。大部分人的写法是给 Config 加一个 RWMutex。但 RWMutex 只解决了"并发访问时不会崩溃",没有解决"这份配置到底归谁管"——任何 goroutine 都可以在任何时候修改它。所有权模型的答案是:只有一个 goroutine 负责写 Config,其他 goroutine 需要获取最新配置时,通过 channel 向它请求。

原则二:访问统一。 其他 goroutine 需要访问这份数据时,通过 channel 向"主人"发送请求,由主人代为操作。

这里有个常见的疑问:“每个共享变量都开一个 channel,那 channel 数量不就爆炸了?“实际上你不需要每个变量一个 channel——通常是每个"数据域”(一组逻辑上相关的变量)有一个 owner goroutine,管理一组相关变量。比如一个 worker pool 有一个管理者 goroutine,管理任务队列、worker 状态、结果收集这三组数据。

原则三:转移可控。 如果数据的所有权需要转移(从一个 goroutine 交给另一个),只能通过 channel 显式传递。

这个原则是 Go 的 channel 哲学的核心——“不要通过共享内存来通信,要通过通信来共享内存。“但大多数人只记得前半句,忘了后半句说的是"通过通信来共享内存”——本质上是所有权转移。

来看同一段代码,用所有权模型重新设计:

func scenarioWithOwnership(tasks []int) []int {
    results := make([]int, 0, len(tasks))
    resultCh := make(chan int, len(tasks))
    var wg sync.WaitGroup

    for _, t := range tasks {
        wg.Add(1)
        go func(task int) {
            defer wg.Done()
            resultCh <- task * 2  // 发送结果,不直接写 results
        }(t)
    }

    go func() {
        wg.Wait()
        close(resultCh)
    }()

    // 只有这个 goroutine 可以写 results
    for r := range resultCh {
        results = append(results, r)
    }
    return results
}

-race 跑一下:

场景 A(无所有权模型):race detector 报告 3 个 data race,结果只收到 7 个元素 [2 6 18 10 14 16 12]。 场景 B(有所有权模型):零 data race,结果完整 [2 4 6 8 10 12 14 16 18 20]

同一需求,两种设计。一个有 data race,丢失了 3 个结果。一个零 data race,结果完整。

区别不在"用没用锁”——两种方案都没有显式的锁。区别在谁拥有 results 这个变量。无所有权版本里,每个 goroutine 都觉得自己可以写 results。有所有权版本里,只有一个 goroutine 拥有 results 的写入权,其他 goroutine 通过 channel 间接写入。

你可能注意到了,有所有权版本的代码比无所有权版本长一些。这是常见的权衡——所有权模型通常需要更多代码,但换来的是可证明的安全性。你不再需要靠"小心"来避免 data race,代码结构本身就排除了这种可能。

四、所有权转移:channel 的正确用法

很多人说"Go 的哲学是通过 channel 通信,不通过共享内存”。但把数据塞进 channel 不等于安全。

看看这个:

data := make([]int, 100)
ch := make(chan []int)

go func() {
    received := <-ch
    received[0] = 999  // 接收方修改
}()

ch <- data
// 发送方继续使用 data
for i := 0; i < 100; i++ {
    data[i] = i
}

-race 报告了 1 个 data race。

channel 传的是切片头(ptr + len + cap)的副本——可以理解为 channel 只复制了指向数据的指针,底层数据还是同一份。发送方和接收方同时操作同一个底层数组 → data race。

这个问题的微妙之处在于:代码看起来完全符合 Go 的 channel 哲学——“通过通信来共享内存”。但 channel 只保证了"发送 happens-before 接收",并没有保证"发送后发送方不再碰这份数据"。Go 的 channel 没有所有权语义——它不知道你把一个切片发送出去后,自己还在用。

所以 channel 不是银弹。channel 传指针不自动给你所有权语义。

真正的做法是:发送后不再碰。

ch := make(chan []int)

// 生产者
go func() {
    data := make([]int, 100)
    for i := 0; i < 100; i++ {
        data[i] = i
    }
    ch <- data  // 发送——所有权转移给接收方
    // 发送后不再碰 data
}()

// 消费者
go func() {
    received := <-ch  // 接收——拿到所有权
    received[0] = 999 // 安全
}()

-race 结果:零 data race。

这个原则在 Go 的 Memory Model 中也有理论支撑:对一个 channel 的发送操作 happens-before 对应的接收操作完成。这意味着发送方在发送前的所有写入,接收方都能看到——前提是发送后不再写入。

这里有一个常见的追问:“那我发完 data 之后,再创建一个新的切片继续用,行不行?"——行。因为新切片和旧切片是两个独立的内存块。关键不是"我不能再碰切片”,而是"我不能再碰那个底层数组"。新切片有自己的底层数组,和已经发送出去的那个没有关系。

channel 传切片底层示意图

五、实战:三 goroutine 协作 pipeline

光有理论不够,看一个真实场景:生产者 → 处理器 → 消费者 的三阶段 pipeline。

无所有权版本:

func pipelineNoOwnership(items []int) {
    var processed []int
    var results []int
    var wg sync.WaitGroup

    // 阶段 1:所有 goroutine 共享 processed
    for _, item := range items {
        wg.Add(1)
        go func(v int) {
            defer wg.Done()
            processed = append(processed, v*2)  // data race!
        }(item)
    }
    wg.Wait()

    // 阶段 2:所有 goroutine 共享 results
    for _, v := range processed {
        wg.Add(1)
        go func(v int) {
            defer wg.Done()
            results = append(results, v+1)  // data race!
        }(v)
    }
    wg.Wait()
}

-race 结果:5 个 data race,最终结果可能只收到部分元素。

有所有权版本:

func producer(items []int, ch1 chan<- int) {
    for _, item := range items {
        ch1 <- item
    }
    close(ch1)
}

func processor(ch1 <-chan int, ch2 chan<- int) {
    for v := range ch1 {
        ch2 <- v * 2
    }
    close(ch2)
}

func consumer(ch2 <-chan int) []int {
    var results []int
    for v := range ch2 {
        results = append(results, v+1)
    }
    return results
}

-race 结果:零 data race,结果完整。

关键区别在数据的所有权边界:

阶段 拥有什么数据 通过什么传递
producer 原始数据 channel(ch1)
processor 处理中的数据 channel(ch2)
consumer 最终结果

每个 goroutine 只拥有当前阶段的数据。数据通过 channel 传递后,发送方的所有权就结束了。这是 pipeline 模式的正确打开方式——不只是"把数据塞进 channel",而是把数据的所有权也一并塞进去。

这个模式还有一个隐藏的好处:goroutine 泄漏风险降低。无所有权版本的 pipeline 中,如果第一阶段的一个 goroutine 卡住了,所有 goroutine 都在等 wg.Wait()——但没有任何机制告诉它们"不用等了"。而有所有权版本中,pipeline 使用无缓冲 channel,天然是背压感知的——如果 consumer 处理不过来,channel 满的时候 producer 会阻塞,形成自然的流量控制。如果换成带大缓冲的 channel,背压特性会减弱。

pipeline 三阶段所有权流动图

六、所有权模型的边界

说到这,我得坦诚一点:所有权模型不是万能的。

场景 1:性能敏感

在高并发 HTTP 请求处理、大量日志写入这类场景中,channel 的开销不可忽视。每个 channel 操作涉及锁、可能的内存分配;在缓冲区满或空时还会触发 goroutine 阻塞和重新调度。如果你需要极致性能,共享内存 + 原子操作可能是更好的选择。

这时候怎么做?你仍然可以用所有权模型来思考,但把"主人"的实现从 channel 换成 atomic 或 mutex:

// 所有权模型的思想——"计数器归这个结构体管"
// 但内部实现用 atomic,不用 channel
type Counter struct {
    value atomic.Int64
}

func (c *Counter) Add(delta int64) {
    c.value.Add(delta)  // 内部用 atomic,对外仍然是"归我管"
}

这里的关键是:结构体 Counter 是这个计数器的"主人"。外部调用者不需要知道内部是用 atomic 还是 channel 实现的——他们只知道"要操作计数器,得通过 Counter 的方法"。

场景 2:goroutine pool

如果你有几十万个 goroutine 往同一个 channel 发送数据,channel 本身会成为瓶颈。这种情况下,你可能需要用 mutex 保护共享结构,或者分片(sharding)——把数据拆成多份,每份有一个主人。

分片是所有权模型的一个有趣变体:你不是在找一个"全局主人",而是把数据分成 N 份,每份有一个主人。这样每个 channel 的负载降到了 1/N。例如开源库 orcaman/concurrent-map 就用了分片思想。

举个例子:假设你有一个日志收集系统,每秒有 10 万条日志需要写入一个共享的 buffer。如果所有 goroutine 都通过同一个 channel 发送日志,channel 的容量和消费速度会成为瓶颈。分片的思路是:创建 10 个 buffer,每个 buffer 有一个 owner goroutine,10 万个日志根据 producer ID 的 hash 分散到这 10 个 buffer 中。这样每个 channel 只需要处理 1 万条日志——压力降到 1/10。

分片架构图

场景 3:只读共享

如果有多个 goroutine 只读同一份数据(比如共享的配置表),不需要所有权模型。只读不需要同步。但"只读"这个前提要确认——如果有人在某个路径下偷偷写了,你就回到了 data race 的老路上。

我遇到过的一个情况是:一个配置对象被标记为"只读共享",所有人都通过 getter 读取。但后来有人加了一个"运行时热更新"的功能,直接写入了这个配置对象的字段——没有加锁,没有通知,没有任何同步。因为所有人都以为"这是只读的"。

所有权模型帮你想清楚的,不是"怎么加锁",而是"这个数据到底归谁管,谁有权修改它"。只读共享是一种特殊情况,但你必须确认"真的没有人写"。

核心判断

所有权模型不是一个技术方案,它是一个设计思维。它的价值不是代替 atomic、mutex、channel,而是在你选择用哪个之前,先想清楚一个问题:

这份数据归谁管?

想清楚了,剩下的就是实现选择。想不清楚,用什么同步原语都可能写出 data race。

所有权模型决策流程图

七、从框架到习惯

回到开头那段代码。那个简单的 append,丢了 4 个结果。

你现在可以给那段代码加上锁。也可以把 append 换成 channel。但如果你只做到了这一步,下一次你还会写出新的 data race——因为问题不是那段代码,是你写代码时的思考方式。

数据所有权模型是一个思考框架,不是代码模板。写并发代码前,先问"这个变量归谁管";如果答案是"多个 goroutine",主动设计一个主人。数据通过 channel 传递后,发送方不再碰——这是 channel 的正确用法。pipeline 的每一级,数据的所有权只在一个 goroutine 里。只读共享要确认"真的没人写"。性能敏感场景用 atomic 或 mutex 来实现"主人",所有权思想不变。

race detector 能告诉你有问题,但所有权模型能帮你从一开始就不制造问题。

下次写并发代码的时候,在写 go func() 之前,先停一秒,问自己:

这份数据归谁管?


附录:实验代码和原始数据

本文 4 组实验的代码已开源:

GitHub:zhiyulab-evidence/go-data-race-beyond

实验 目录 说明
data-race-demo data-race-demo/ 10 goroutine 并发 append 无保护 + 共享 map 并发写入,演示 -race 检测效果
ownership-model-demo ownership-model-demo/ 同一需求两种实现——无所有权 vs 有所有权,对比 data race 数量和结果完整性
ownership-transfer-demo ownership-transfer-demo/ channel 传指针的场景——发送方继续使用 vs 发送后不再碰,演示所有权转移
pipeline-demo pipeline-demo/ 三阶段 pipeline——无所有权 vs 有所有权,展示数据所有权边界的设计

每个子目录都有独立 README,说明如何复现。跑实验前 go build 即可。

原文发布于 止语Lab


关于止语Lab

一个工程师的深度技术笔记。

不写入门教程,不追热点。只写那些真正折腾过、想通了的东西。

了解更多 →