Go 并发 Goroutine — WaitGroup、Mutex 与 GMP 模型
并发是 Go 的王牌能力。其他语言里要写多线程,往往得扛着"线程很贵、共享内存要加锁、上下文切换开销大"的包袱;而 Go 提供了轻量级的协程 goroutine——几 KB 栈起步、由运行时调度、写起来和普通函数调用几乎一样。再加上 go 关键字一行就能启动,并发在 Go 里变成了"默认选项"。这一篇我们讲清 goroutine 的基本用法和并发同步的几件必备工具。
一、go 关键字启动 goroutine
在普通函数调用前加一个 go 关键字,这个函数就在一个新的 goroutine 里异步执行,主流程不等它返回。goroutine 没有返回值(返回了也没人接),没有用户可见的 ID,它就是"一段并行跑的代码"。下面三个协程交替打印,你能直观看到并发的效果。
package main
import (
"fmt"
"time"
)
func sayHi(name string) {
for i := 0; i < 3; i++ {
fmt.Println(name, ":", i)
time.Sleep(100 * time.Millisecond)
}
}
func main() {
// go 关键字:让函数在一个新的 goroutine 里异步执行
go sayHi("A") // 新协程
go sayHi("B") // 新协程
sayHi("M") // 主 goroutine 也跑一遍
// 演示专用:用 Sleep 等子协程(生产代码请用 WaitGroup)
time.Sleep(time.Second)
}注意结尾的 time.Sleep 是演示专用的偷懒写法——主 goroutine 一返回,整个程序就退出,所有子协程会被强制中断。生产代码请用下面的 sync.WaitGroup 来等。
二、GMP 模型简介
goroutine 不是 OS 线程。Go 运行时用 GMP 模型把它们映射到少量 OS 线程上:
- G(Goroutine):你用
go创建的协程。 - M(Machine):OS 线程,真正执行代码的载体。
- P(Processor):调度上下文,持有本地的 goroutine 队列。
GOMAXPROCS决定有多少个 P,也就决定了能并行执行的 goroutine 数。
这种 M:N 调度(M 个协程跑在 N 个线程上)让 goroutine 极轻:切换只需保存几个寄器,没有内核参与。当某个 goroutine 阻塞在系统调用时,P 会被"摘下来"交给另一个 M,保持其他 goroutine 不被拖累。这也是为什么 Go 服务能轻松撑住十万级并发连接。
三、sync.WaitGroup:等一组协程完成
最经典的并发模式是"分派 N 个任务、等它们全部做完"。sync.WaitGroup 就是为这个而生的计数器:Add(n) 加 n,每个协程 Done()(即 Add(-1)),主线程 Wait() 阻塞到归零。注意 Add 要在起 goroutine 之前调用,否则可能 Wait 已经返回了计数还没加上。
package main
import (
"fmt"
"sync"
)
func main() {
var wg sync.WaitGroup
for i := 1; i <= 5; i++ {
wg.Add(1) // 计数器 +1
go func(id int) {
defer wg.Done() // 计数器 -1,defer 保证出错也执行
fmt.Println("worker", id, "完成")
}(i) // 把 i 当参数传,避免闭包陷阱
}
wg.Wait() // 阻塞到计数器归零
fmt.Println("全部完成")
}特别注意代码里把 i 当作参数传给了匿名函数——这是为了避免经典的闭包变量陷阱(下文细讲)。
四、sync.Mutex:互斥锁
多个 goroutine 同时改一个变量会产生数据竞争(data race),结果不可预测,而且不会报错、只会埋雷。sync.Mutex 提供 Lock() / Unlock():同一时刻只有一个 goroutine 能进入临界区。sync.RWMutex 进一步支持 RLock() / RUnlock(),允许多个读者并发、写者独占——读多写少的场景能显著提速。
package main
import (
"fmt"
"sync"
)
// 计数器:用互斥锁保护共享变量
type Counter struct {
mu sync.Mutex
count int
}
func (c *Counter) Inc() {
c.mu.Lock() // 进入临界区
c.count++
c.mu.Unlock() // 离开临界区
}
func (c *Counter) Get() int {
c.mu.Lock()
defer c.mu.Unlock() // 用 defer 解锁更安全
return c.count
}
func main() {
c := &Counter{}
var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
c.Inc()
}()
}
wg.Wait()
fmt.Println("最终计数:", c.Get()) // 1000
}如果把上面 Inc() 里的 Lock / Unlock 注释掉,最终计数很可能不是 1000 而是个随机偏小的数——这就是数据竞争在作怪。检测可以用 go run -race main.go,race 检测器会把竞争点标出来。
五、sync.Once:只执行一次
单例模式、配置加载、全局初始化——这些"必须且只执行一次"的逻辑,用 sync.Once 最稳妥。它的 Do(func) 保证传入的函数全局只执行一次,即使有 100 个 goroutine 同时调用。比 init() 函数更灵活,因为它可以在"真正第一次用到时"才触发(延迟初始化)。
package main
import (
"fmt"
"sync"
)
var (
config string
once sync.Once
)
func loadConfig() {
// once.Do 保证传入的函数全局只执行一次
once.Do(func() {
fmt.Println("正在加载配置...(只应打印一次)")
config = "loaded"
})
}
func main() {
var wg sync.WaitGroup
for i := 0; i < 5; i++ {
wg.Add(1)
go func() {
defer wg.Done()
loadConfig() // 5 个协程并发调用,真正执行一次
}()
}
wg.Wait()
fmt.Println("config =", config) // config = loaded
}六、闭包变量陷阱
这是 Go 并发里最经典的 bug。在循环里直接 go func() { fmt.Println(i) }(),所有 goroutine 共享同一个 i——等它们真正跑起来时循环早已结束,i 已经是终值,于是打印出一堆相同的数字。修复办法有两种:把 i 当参数传进去(上面 cWaitGroup 用的就是这种),或者在循环体内重新绑定 j := i 再捕获 j。这是面试高频题,务必吃透。
七、GOMAXPROCS 与并发安全
runtime.GOMAXPROCS(n) 设置使用的 P 数量,默认等于 CPU 核数。绝大多数情况下不需要动它;只有在调优或写特殊基准测试时才可能调整。最后强调一点:并发安全不是 goroutine 自带的属性——只要多个 goroutine 访问同一份共享数据且至少一个在写,就必须用 Mutex、channel 等手段同步,否则就是数据竞争。Go 的哲学是"不要通过共享内存来通信,而要通过通信来共享内存",这正是下一篇 Channel 的主题。
小结
go一行启动 goroutine,轻量、由运行时调度(GMP 模型)。- 用
sync.WaitGroup等待、sync.Mutex/RWMutex保护共享变量、sync.Once保证只执行一次。 - 循环里起 goroutine,务必把循环变量当参数传入或重新绑定,避开闭包陷阱。
- 数据竞争不会报错但会埋雷,用
go run -race检测。
上一篇 Go 错误处理 | 下一篇 Go Channel 通道。