
本文详解 go 语言中 goroutine 的同步机制,重点说明为何直接启动 goroutine 后立即读取共享变量会导致数据未就绪,并通过 sync.waitgroup 给出可靠、简洁的解决方案。
在 Go 中使用 goroutine 实现并发非常简单——只需在函数调用前加上 go 关键字。但一个常见误区是:启动 goroutine 并不意味着它会立即执行完毕,甚至不一定已开始执行。主 goroutine(即 main 函数)会继续向下运行,若未显式等待,很可能在子 goroutine 完成前就打印了未修改的变量值,导致看似“无副作用”的现象。
以原始代码为例:
package main
import "fmt"
var (
b1 []float64
b2 []float64
)
func main() {
go fill(&b1, 10)
go fill(&b2, 10)
fmt.Println(b1, b2) // ⚠️ 此时 b1 和 b2 极大概率仍为空切片
var s string
fmt.Scanln(&s) // 临时阻塞,但属不可靠、非生产级做法
}该代码的问题在于:fmt.Println 执行时,两个 fill goroutine 可能尚未调度、正在运行,或刚执行几轮循环——Go 不提供隐式同步保证。依赖 fmt.Scanln 等输入阻塞属于竞态调试技巧,绝不可用于正式逻辑。
✅ 正确做法是使用显式同步原语。最常用、语义清晰的是 sync.WaitGroup:
package main
import (
"fmt"
"math/rand"
"sync"
"time"
)
var (
b1 []float64
b2 []float64
)
func main() {
var wg sync.WaitGroup
wg.Add(2) // 声明需等待 2 个 goroutine
go fill(&b1, 10, &wg)
go fill(&b2, 10, &wg)
wg.Wait() // 主 goroutine 阻塞至此,直到所有 Done() 被调用
fmt.Println("b1:", b1)
fmt.Println("b2:", b2)
}
func fill(a *[]float64, n int, wg *sync.WaitGroup) {
defer wg.Done() // 确保无论何种退出路径,都标记完成
for i := 0; i < n; i++ {
*a = append(*a, rand.Float64()*100)
}
}? 关键点说明:wg.Add(2) 必须在 go 语句之前调用,避免竞态(如 Add 和 Done 交错导致计数错误);defer wg.Done() 是惯用写法,保障函数退出时自动通知;rand 包需初始化种子(如 rand.Seed(time.Now().UnixNano())),否则每次运行生成相同序列(本例为简洁省略,实际应补充)。
⚠️ 注意事项:
- 切勿在 goroutine 中直接操作未同步的全局变量——虽本例因 append 修改指针所指底层数组而“偶然”可行,但仍是危险模式;
- 更推荐函数式风格:让 fill 返回新切片,而非修改传入指针(符合 Go “prefer value semantics” 原则):
func fill(src []float64, n int) []float64 { for i := 0; i < n; i++ { src = append(src, rand.Float64()*100) } return src } // 调用:b1 = fill(b1, 10) - 其他同步方式包括 channel(适合传递结果)、sync.Once(单次初始化)或 context(带超时/取消的协作式等待),应依场景选择。
总结:Go 的并发模型强调显式同步。理解 WaitGroup 的生命周期(Add → Go → Done → Wait)是掌握 goroutine 协作的基础。始终问自己:“谁负责等待?何时等待?如何确保不漏等?”——这比写出 go 更重要。










