3. Go并发原语深入
Go 的并发能力不是靠线程库堆出来的,而是由运行时(runtime)统一编排。理解并发原语,首先要理解 goroutine 的调度模型:G(goroutine)、M(machine,操作系统线程)、P(processor,逻辑处理器)三者协作。G 是待执行的任务,M 是真正干活的线程,P 持有运行 G 所需的本地队列和资源。一个 M 必须绑定一个 P 才能执行 G,P 的数量由 GOMAXPROCS 决定,默认等于 CPU 逻辑核数。当某个 G 发生阻塞(如系统调用、channel 等待),运行时会把它从 M 上摘下,让 M 去执行其他 G,从而避免线程被白白占用。
调度器还维护全局队列和每个 P 的本地队列。新建的 goroutine 优先放入当前 P 的本地队列;本地队列满时,会转移一半到全局队列。当某个 P 空闲而其他 P 繁忙时,会触发 work-stealing,从别的 P 本地队列或全局队列“偷”任务。这套机制让大量 goroutine 能以较低开销并发运行,但并不意味着可以无限创建——每个 goroutine 仍有约 2KB 起始栈和调度成本,工程上应控制并发规模。
channel 是 Go 并发通信的核心。无缓冲 channel 的发送和接收必须同时就绪,否则阻塞;有缓冲 channel 在缓冲未满时发送不阻塞,缓冲非空时接收不阻塞。关闭 channel 后,继续接收会立即返回零值和 ok=false,而向已关闭 channel 发送会 panic。因此工程上通常由发送方关闭 channel,接收方只负责读取。判断 channel 是否关闭的标准写法是:
v, ok := <-ch
if !ok {
// channel 已关闭且无剩余数据
}
select 用于同时等待多个 channel 操作。它会评估所有 case,若多个就绪则随机选一个,避免饥饿;若都不就绪且存在 default,则执行 default,实现非阻塞收发。典型用法是超时控制:
select {
case msg := <-ch:
fmt.Println("收到:", msg)
case <-time.After(2 * time.Second):
fmt.Println("超时")
}
sync 包提供的是共享内存式的同步原语。Mutex 用于互斥保护临界区,RWMutex 适合读多写少场景,WaitGroup 用于等待一组 goroutine 完成,Once 保证初始化只执行一次。使用 Mutex 时要注意锁的粒度:锁内不要做耗时 I/O,也不要嵌套获取可能造成死锁。WaitGroup 的 Add 必须在 goroutine 启动前调用,Done 用 defer 保证执行。
并发安全与竞态检测是工程落地的关键。数据竞争指两个以上 goroutine 同时访问同一变量,且至少有一个是写操作,且没有同步。Go 提供 -race 检测器:
go run -race main.go
go test -race ./...
竞态检测器会在运行时记录内存访问,发现竞争时输出冲突的读写位置和 goroutine 栈。它不能证明没有竞争,但能发现已发生的竞争。工程实践中,优先用 channel 传递数据所有权,减少共享内存;必须共享时用 Mutex 保护,并让 -race 进入 CI 流程。下面给出一个综合示例,展示 WaitGroup、Mutex 与 channel 的配合:
var (
mu sync.Mutex
total int
wg sync.WaitGroup
)
results := make(chan int, 10)
for i := 0; i < 10; i++ {
wg.Add(1)
go func(n int) {
defer wg.Done()
mu.Lock()
total += n
mu.Unlock()
results <- n * n
}(i)
}
wg.Wait()
close(results)
for v := range results {
fmt.Println(v)
}
总结来看,goroutine 调度模型决定了并发执行的底层效率,channel 和 select 提供了通信与多路复用的表达力,sync 包补齐了共享内存场景的同步手段,而竞态检测则是验证并发正确性的工程防线。理解它们各自适用的边界,比记住 API 更重要。