第一章:Go并发编程入门
1.1 Go语言并发模型概述
说到Go语言,最让我着迷的就是它的并发模型。你想想看,传统的并发编程有多痛苦?Java里搞个线程池,写个Future,还得小心翼翼处理共享内存。Go呢?它直接换了个思路。
Go的并发模型,核心就是一句话:不要通过共享内存来通信,而要通过通信来共享内存。这句话我刚开始学的时候觉得挺玄乎的,后来在项目中真正用起来,才明白它的妙处。
这个模型基于CSP(Communicating Sequential Processes)理论,说白了就是让不同的执行单元通过channel来传递消息。每个Goroutine专注于自己的事,需要数据了?从channel里拿。处理完了?往channel里丢。这样一来,锁的问题就少了很多。
核心要点:
- Goroutine是轻量级的执行单元,不是操作系统线程
- Channel是Goroutine之间的通信桥梁
- select机制可以同时处理多个channel
- Go运行时负责调度Goroutine到系统线程上
我在项目中遇到过这样一个场景:需要同时从多个数据源拉取信息,然后合并结果。如果用传统的线程模型,我得自己管理线程池、处理超时、还要小心死锁。用Go的Goroutine+channel,代码量直接砍了一半,而且逻辑清晰得多。
1.2 Goroutine基础
Goroutine是什么?说白了就是一个轻量级的"线程"。但它比系统线程轻量太多了——初始栈大小只有几KB,创建和销毁的开销也极小。你可以在一个Go程序里轻松创建成千上万个Goroutine,这在传统线程模型里是想都不敢想的。
创建一个Goroutine有多简单?看代码:
package main
import (
"fmt"
"time"
)
func sayHello() {
fmt.Println("Hello from Goroutine!")
}
func main() {
// 启动一个Goroutine
go sayHello()
// 主Goroutine继续执行
fmt.Println("Hello from main!")
// 等一下,不然主Goroutine退出了,子Goroutine还没执行
time.Sleep(time.Second)
}
嗯,这里要注意:go关键字后面跟一个函数调用,就启动了一个新的Goroutine。主函数和这个新Goroutine是并发执行的。但有个坑——如果主Goroutine退出了,所有子Goroutine都会被强制结束。所以我上面加了个time.Sleep,实际项目中可不能这么干,得用WaitGroup或者channel来同步。
小技巧:我个人习惯用匿名函数启动Goroutine,这样可以把上下文直接传进去:
go func(msg string) {
fmt.Println(msg)
}("Hello from anonymous Goroutine!")
我曾经犯过一个低级错误:在循环里启动Goroutine时,直接引用了循环变量。结果所有Goroutine打印出来的都是最后一个值。为什么会这样?因为Goroutine启动时捕获的是变量的引用,而不是当时的快照。正确的做法是把变量作为参数传进去。
1.3 Go调度器简介
Go的调度器,我愿称之为"幕后英雄"。你写的go func(),操作系统并不知道有Goroutine这回事。它只看到几个系统线程在跑。真正把Goroutine调度到系统线程上执行的,是Go运行时自带的调度器。
这个调度器基于GMP模型:
| 组件 | 全称 | 作用 |
|---|---|---|
| G | Goroutine | 代表一个待执行的任务 |
| M | Machine | 代表操作系统线程 |
| P | Processor | 代表执行Goroutine所需的上下文 |
你想想看,M个系统线程,要调度N个Goroutine(N通常远大于M)。Go调度器是怎么做到的?它维护了一个全局的Goroutine队列,每个P也有自己的本地队列。当一个Goroutine发生阻塞(比如等待I/O),P就会从队列里取一个新的Goroutine来执行。这个过程对开发者是完全透明的。
我记得有一次排查性能问题,发现程序明明开了很多Goroutine,但CPU利用率就是上不去。后来发现是GOMAXPROCS设置得太小了。这个参数控制的是P的数量,也就是可以并行执行Goroutine的系统线程数。默认值是CPU核心数,但如果你跑在容器里,可能检测到的核心数不对。
避坑指南:我曾经在Kubernetes容器里部署Go服务,没设置GOMAXPROCS,结果调度器检测到了宿主机的所有核心,导致大量线程争抢有限的CPU配额。后来用uber-go/automaxprocs这个库自动适配容器限制,问题才解决。
1.4 并发与并行的区别
这个问题,我面试别人的时候经常问。很多人张口就来:"并发是逻辑上的同时执行,并行是物理上的同时执行。"说得没错,但太抽象了。
我换个说法:并发是处理多个事情的能力,并行是同时做多个事情的能力。
举个例子:你一边写代码一边听音乐,这是并发。你同时写代码、听音乐、还吃零食,这是并行。但你的大脑只有一个(单核CPU),你只能快速切换——写几行代码,切过去听两句音乐,再切回来。这就是并发。如果你有两个大脑(多核CPU),一个负责写代码,一个负责听音乐,这才是并行。
在Go里:
- 并发:多个Goroutine在单个系统线程上轮流执行。调度器负责切换。
- 并行:多个Goroutine在多个系统线程上同时执行。需要多核CPU支持。
看个直观的例子:
package main
import (
"fmt"
"runtime"
"sync"
)
func main() {
// 设置使用1个核心,此时是并发
runtime.GOMAXPROCS(1)
var wg sync.WaitGroup
for i := 0; i < 10; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
fmt.Printf("Goroutine %d\n", id)
}(i)
}
wg.Wait()
// 设置使用所有核心,此时可能并行
runtime.GOMAXPROCS(runtime.NumCPU())
// ... 同样的代码
}
当GOMAXPROCS=1时,所有Goroutine都在一个线程上跑,是并发。当GOMAXPROCS=4时(假设你有4核),最多有4个Goroutine可以同时执行,是并行。
记住这个:
- 并发是关于结构的——你如何组织代码来处理多个任务
- 并行是关于执行的——你的硬件能否同时做多件事
- Go的并发模型让你可以轻松写出并发代码,但并行需要硬件支持
我个人习惯在设计系统时先考虑并发结构,把任务拆分成独立的Goroutine,用channel串联起来。至于能不能并行,那是部署时考虑的事。你想想看,如果你的代码结构本身就是并发的,那放到多核机器上自然就并行起来了。这就是Go的设计哲学——让你专注于并发结构,并行是自然而然的事。
好了,第一章的内容就到这里。并发编程的入门概念我们已经聊完了。下一章我们会深入Goroutine的生命周期管理,包括如何优雅地启动、停止和同步Goroutine。到时候见。