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 线程上:

这种 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 错误处理 | 下一篇 Go Channel 通道

✈️💬