从 GMP 调度模型到 Channel 通信,深入理解 Go 并发的核心原理
Go 语言中的并发基本单位,比线程更轻量
Goroutine 是 Go 运行时管理的轻量级线程。它不是操作系统的线程,而是由 Go Runtime 在用户态自行调度的执行单元。你可以把它想象成操作系统线程内部更小的"微线程"——创建和切换都极其廉价。
当你写 go func() 时,Go 会在当前 P 的本地队列里创建一个新的 G,等待被调度执行。整个过程不需要操作系统介入,这也是 Go 并发性能强大的根源。
// 创建一个 goroutine 只需要一个 go 关键字 go func() { fmt.Println("我在另一个协程中运行") }() // 主协程 fmt.Println("我是主协程")
Go 并发调度的核心三要素
go func() 创建出来的东西。GOMAXPROCS 修改。下面的静态图展示了 GMP 三者的关系:每个 M 绑定一个 P,每个 P 持有一个本地 G 队列,全局还有一个 G 队列。
理解 Go 是如何高效调度百万协程的
当 P 的本地队列空了,它不会闲着。Go Runtime 会随机选择另一个 P,从它的本地队列尾部偷一半的 G 过来执行。这个过程叫做 Work Stealing,保证了负载均衡。
窃取策略:每次偷取 len(queue)/2 + 1 个 G,从尾部取走。随机选择目标 P 是为了避免所有空闲 P 同时去偷同一个 P,减少竞争。
当 M 正在执行的 G 发生系统调用(如文件 I/O、网络请求)会阻塞时,P 不会跟着等待。P 会与这个 M 解绑,转而绑定到另一个空闲的 M(或创建新 M)继续执行其他 G。等原来的 M 从系统调用返回后,会尝试重新获取一个空闲 P。
这个机制确保了即使某个 G 在做阻塞操作,P 也不会被浪费,其他 G 照样能被调度执行。
Go 1.14 之后采用基于信号的异步抢占。运行时通过向 M 发送 SIGURG 信号,在安全的时刻(函数序言)强制让 G 让出 CPU。这防止了某个 G 长时间占用 CPU 而饿死其他协程。
抢占时机:每个 G 执行约 10ms 后,调度器会检查是否需要抢占。配合时间片轮转,保证公平调度。
亲手操作,观察协程是如何被创建、调度、窃取的
从创建到销毁,一个协程经历的完整过程
调用 go func() 时,Runtime 创建一个新的 G,初始栈只有 2KB。G 被放入当前 P 的本地队列。如果本地队列满了(超过 256 个),则放一半到全局队列。
G 在本地队列或全局队列中等待。调度循环中,P 会优先从本地队列取 G,如果本地队列为空,则从全局队列取(每次最多取 GOMAXPROCS 个),还不够就从其他 P 窃取。
P 从队列取出 G,放到 M 上执行。每个 G 运行约 10ms 后会被抢占检查。运行期间 G 的栈会按需动态伸缩(最小 2KB,最大可达 GB 级别,通常 1-8MB)。
G 执行 channel 操作、syscall、select、mutex、time.Sleep 等会进入等待状态。此时 G 会被从 P 的队列中移除,M 继续执行其他 G(Hand Off)。
等待条件满足时(如 channel 有数据了、锁释放了、sleep 到时间了),G 被唤醒,放回某个 P 的本地队列继续等待执行。
函数执行完毕或 runtime.Goexit() 被调用时,G 被销毁。它的栈内存会被回收,资源被释放。
不要通过共享内存来通信,而应通过通信来共享内存
Channel 是 Go 实现协程间通信的核心机制。它本质上是一个线程安全的 FIFO 队列,内部使用互斥锁和等待队列来管理并发访问。发送方把数据放入队列,接收方从队列取出数据。
// Channel 基本用法 ch := make(chan int, 3) // 缓冲大小 3 // 发送 ch <- 42 // 接收 val := <-ch // 关闭 (不再发送) close(ch) // 遍历已关闭的 channel for v := range ch { fmt.Println(v) }
掌握这些模式,应对 90% 的并发场景
启动多个 worker 协程并行处理,然后汇聚结果。
func fanOutFanIn(jobs []int) []int { // Fan-out: 启动多个 worker numWorkers := 3 jobsCh := make(chan int, len(jobs)) resultsCh := make(chan int, len(jobs)) for i := 0; i < numWorkers; i++ { go func() { for job := range jobsCh { resultsCh <- process(job) // 处理并发送结果 } }() } // 分发任务 for, j := range jobs { jobsCh <- j } close(jobsCh) // Fan-in: 收集结果 var results []int for i := 0; i < len(jobs); i++ { results = append(results, <-resultsCh) } return results }
同时监听多个 channel,哪个先就绪就处理哪个。
func handleWithTimeout(ch chan string) { timeout := time.After(3 * time.Second) select { case result := <-ch: fmt.Println("收到结果:", result) case <-timeout: fmt.Println("超时了!") case <-interrupt: fmt.Println("被中断") } }
用 Context 实现取消传播、超时控制、值传递。
func worker(ctx context.Context, id int) { for { select { case <-ctx.Done(): fmt.Printf("worker %d 退出\n", id) return default: doWork(id) time.Sleep(time.Second) } } } // 带超时的 context,5秒后自动取消所有 worker ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() for i := 0; i < 10; i++ { go worker(ctx, i) }
等待一组协程全部完成后再继续。
var wg sync.WaitGroup for i := 0; i < 5; i++ { wg.Add(1) go func(id int) { defer wg.Done() doWork(id) }(i) } wg.Wait() // 阻塞直到所有协程完成 fmt.Println("所有任务完成!")
横向对比不同语言的并发方案
| 特性 | Go Goroutine | Java Thread | Python Coroutine | JS async/await |
|---|---|---|---|---|
| 调度方式 | Runtime M:N 调度 | OS 1:1 调度 | 事件循环单线程 | 事件循环单线程 |
| 栈大小 | 2KB 起步,动态伸缩 | 固定 512KB-1MB | 堆分配 | 调用栈 |
| 创建成本 | 极低 (~KB 级) | 高 (~MB 级) | 低 | 极低 |
| 切换成本 | ~100ns (用户态) | ~1-10μs (内核态) | 低 | 极低 |
| 真并行 | 是 (多 M 并行) | 是 | 否 (单线程) | 否 (单线程) |
| 通信方式 | Channel (CSP) | 共享内存 + 锁 | await / Queue | Promise |
| 编程复杂度 | 低 | 高 (锁/死锁) | 中 | 低 |
Go 选择了 CSP (Communicating Sequential Processes) 模型,鼓励通过 Channel 通信而非共享内存。这从根本上避免了数据竞争和死锁的大部分问题。配合 M:N 调度,你在写代码时可以随意创建协程而不用担心资源耗尽,这是 Go 在高并发场景下如此受欢迎的根本原因。
初学者最容易困惑的问题