Appearance
并发基础:进程、线程与协程
并发编程是 Go 语言最引以为傲的能力之一。从诞生之初,Go 就把「让普通开发者也能写出高效的并发程序」作为核心设计目标。本篇是 Go 并发编程系列的第一篇,我们将从最基础的概念讲起:什么是并发、什么是并行,进程、线程、协程到底有什么区别,Go 的 GMP 调度器是如何工作的,以及为什么 goroutine 比传统线程更轻量。理解这些底层概念,是写出正确、高效并发代码的前提。
一、并发 vs 并行
很多人把「并发(Concurrency)」和「并行(Parallelism)」混为一谈,但它们其实是两个不同的概念。Go 语言之父 Rob Pike 曾用一句话精辟地概括过两者的区别:
并发是关于应对多件事情,并行是关于同时做多件事情。 Concurrency is about dealing with lots of things at once. Parallelism is about doing lots of things at once.
1. 并发(Concurrency)
并发指的是程序的结构设计能力——一个程序被设计成可以同时管理多个任务,即使这些任务并不是在同一时刻真正同时执行的。并发的关键在于「调度」和「切换」:操作系统或运行时会在多个任务之间快速切换,让每个任务都有机会向前推进,从外部看就好像它们在同时运行。
举个生活中的例子:你在厨房做饭,把水烧上后,趁着等水开的间隙去切菜,切菜切到一半去翻一下正在煎的鸡蛋,然后再回来继续切菜。你只有一个人(一个执行单元),但在多个任务之间切换,这就是并发。
2. 并行(Parallelism)
并行指的是真正的「同一时刻」同时执行多个任务,这通常需要多个执行单元(多个 CPU 核心、多个线程)。并行关注的是「同时执行」这个物理事实。
继续用厨房的例子:如果厨房里有三个厨师,一个烧水、一个切菜、一个煎鸡蛋,三个人在同一时刻各做各的事,这才是并行。
3. 两者的关系
- 并发是结构,并行是执行。
- 并发程序可以在单核 CPU 上运行(通过时间片轮转),也可以在多核 CPU 上并行运行。
- 并行不一定需要并发设计——你可以简单地启动多个独立进程各自干活。
- 并发程序如果运行在多核机器上,运行时会自动把不同的 goroutine 分配到不同的核心上并行执行。
可以用下面这段代码直观感受并发与并行的区别:
go
package main
import (
"fmt"
"runtime"
"sync"
"time"
)
func task(name string, wg *sync.WaitGroup) {
defer wg.Done()
for i := 0; i < 3; i++ {
fmt.Printf("%s: 第 %d 次执行\n", name, i+1)
time.Sleep(100 * time.Millisecond)
}
}
func main() {
// 限制只用 1 个 CPU 核心,模拟「并发但不并行」
runtime.GOMAXPROCS(1)
var wg sync.WaitGroup
start := time.Now()
wg.Add(2)
go task("任务A", &wg)
go task("任务B", &wg)
wg.Wait()
fmt.Printf("单核并发耗时: %v\n", time.Since(start))
// 现在放开所有核心,让运行时并行执行
runtime.GOMAXPROCS(runtime.NumCPU())
var wg2 sync.WaitGroup
start2 := time.Now()
wg2.Add(2)
go task("任务A", &wg2)
go task("任务B", &wg2)
wg2.Wait()
fmt.Printf("多核并行耗时: %v\n", time.Since(start2))
fmt.Printf("CPU 核心数: %d\n", runtime.NumCPU())
}在单核模式下,两个 goroutine 通过时间片轮转交替执行,总耗时约等于两个任务串行的时间;而在多核模式下,两个 goroutine 可以在不同核心上真正同时运行,总耗时接近单个任务的时间。
理解了这个区别后,你就会明白:写并发程序的目的是用一种结构化的方式管理多个任务,至于能不能并行加速,那是运行时和硬件的事。
二、进程、线程与协程
要理解 goroutine 的优势,必须先搞清楚进程、线程、协程这三个执行单元的区别。
1. 进程(Process)
进程是操作系统进行资源分配的最小单位。每个进程都有自己独立的内存空间(虚拟地址空间)、文件描述符表、信号处理表等。进程之间的内存是隔离的,一个进程不能直接访问另一个进程的内存,必须通过进程间通信(IPC)机制如管道、消息队列、共享内存等来交换数据。
进程的代价:
- 创建开销大:创建一个进程需要复制父进程的内存空间(虽然有写时复制技术优化),分配独立的页表。
- 切换开销大:进程切换需要切换页表、刷新 TLB(Translation Lookaside Buffer),代价较高。
- 通信开销大:进程间通信需要内核介入,数据要在用户态和内核态之间拷贝。
2. 线程(Thread)
线程是 CPU 调度的最小单位,也叫轻量级进程(LWP)。一个进程可以包含多个线程,这些线程共享进程的内存空间(代码段、数据段、堆),但每个线程有自己独立的栈、寄存器和程序计数器。
线程的优势:
- 创建比进程轻量(共享进程内存,无需复制地址空间)。
- 切换比进程快(不需要切换页表)。
- 通信方便(直接读写共享内存,但需要同步机制保护)。
线程的代价:
- 仍需内核调度,切换时要陷入内核态。
- 每个线程有固定的栈大小(通常 1MB~8MB),创建几千个线程就会消耗数 GB 内存。
- 线程切换涉及寄存器保存恢复、缓存失效等,仍有不可忽视的开销。
3. 协程(Coroutine)
协程是一种用户态的轻量级线程。与线程最大的区别在于:线程的调度由操作系统内核完成,而协程的调度由用户程序(或运行时库)自己完成,不需要内核介入。
协程的特点:
- 用户态调度:协程的切换在用户态完成,没有内核态切换开销。
- 极轻量:协程的栈可以很小(Go 的 goroutine 初始栈只有 2KB),可以轻松创建数十万个。
- 协作式或抢占式:传统协程多为协作式(需要显式 yield),Go 的 goroutine 在 1.14 之后支持基于信号的抢占式调度。
4. 三者对比
| 特性 | 进程 | 线程 | 协程(goroutine) |
|---|---|---|---|
| 调度者 | 操作系统内核 | 操作系统内核 | Go 运行时(用户态) |
| 切换开销 | 大 | 中 | 极小 |
| 内存占用 | 独立地址空间 | 固定栈(MB 级) | 动态栈(KB 级) |
| 通信方式 | IPC(管道等) | 共享内存+锁 | channel |
| 创建数量级 | 几十~几百 | 几百~几千 | 几万~几十万 |
| 是否共享内存 | 否 | 是 | 是(但鼓励用 channel) |
5. 与 Java 线程的对比
对于有 Java 背景的开发者,理解 goroutine 与 Java 线程的区别非常重要。
Java 线程(Thread):
- Java 的
java.lang.Thread在早期(JDK 1.1)是用户态线程(绿色线程),从 JDK 1.2 起改为直接映射到操作系统线程(1:1 模型)。 - 每个 Java 线程对应一个操作系统线程,创建和切换成本与系统线程一致。
- 默认栈大小通常是 512KB~1MB,因此一台普通服务器能创建的 Java 线程数通常在几千到一两万的量级。
- JDK 21 引入了虚拟线程(Virtual Thread / Project Loom),其理念与 goroutine 类似,也是用户态轻量级线程,但这是较新的特性。
Go goroutine:
- goroutine 是 Go 运行时管理的用户态轻量级线程,采用 M:N 调度模型(M 个 goroutine 映射到 N 个操作系统线程)。
- 初始栈仅 2KB,按需增长(最大可达 1GB),可以轻松创建几十万个 goroutine。
- 调度在用户态完成,切换只需要保存/恢复少量寄存器和栈指针,开销在几百纳秒级别。
下面这个例子直观展示了 goroutine 的轻量性——创建 10 万个 goroutine 在 Go 中是轻而易举的事:
go
package main
import (
"fmt"
"sync"
"sync/atomic"
)
func main() {
var count int64
var wg sync.WaitGroup
// 创建 10 万个 goroutine
const N = 100000
wg.Add(N)
for i := 0; i < N; i++ {
go func() {
defer wg.Done()
atomic.AddInt64(&count, 1)
}()
}
wg.Wait()
fmt.Printf("成功创建并运行 %d 个 goroutine\n", count)
}如果换成 Java 的 Thread,同样的代码大概率会因为内存不足(10 万个线程 × 1MB 栈 = 100GB)而无法运行。这就是 goroutine 的核心优势所在。
三、Go 的并发哲学
Go 语言社区有一句广为流传的名言,它体现了 Go 区别于其他语言的核心并发思想:
不要通过共享内存来通信,而要通过通信来共享内存。 Do not communicate by sharing memory; instead, share memory by communicating.
1. 传统方式:通过共享内存来通信
在 Java、C++ 等语言中,多线程协作的典型方式是:多个线程访问同一块共享内存(变量),通过加锁(Mutex、synchronized)来保证访问的安全性。线程之间通过读写共享变量来「通信」。
这种方式的问题:
- 容易出错:忘记加锁、加错锁、死锁等问题层出不穷。
- 难以扩展:锁粒度太粗影响并发度,太细又容易死锁。
- 难以推理:要理解一段并发代码,必须在脑子里模拟所有可能的执行顺序。
2. Go 的方式:通过通信来共享内存
Go 鼓励使用 channel 在 goroutine 之间传递数据。一个 goroutine 把数据发送到 channel,另一个 goroutine 从 channel 接收数据。数据在同一时刻只归一个 goroutine 所有,不需要显式加锁。
go
package main
import "fmt"
func main() {
// 通过 channel 传递数据,而不是共享变量
ch := make(chan int)
go func() {
// 这里的数据被「发送」给主 goroutine
ch <- 42
}()
// 主 goroutine 通过接收来「获取」数据
value := <-ch
fmt.Println("收到数据:", value)
}这段代码没有用任何锁,但它是并发安全的——因为数据在同一时刻只被一个 goroutine 持有。这就是「通过通信来共享内存」的思想。
3. 并不是禁止用锁
需要强调的是,Go 并不是禁止使用共享内存和锁。sync 包提供了 Mutex、RWMutex 等工具,在很多场景下使用锁是合理且高效的。Go 的建议是:
- 默认优先考虑 channel,特别是 goroutine 之间需要传递数据或协调时。
- 保护共享状态时,用 Mutex 往往更简单直接。
- 不要为了用 channel 而用 channel,选择最清晰表达意图的方式。
后面我们会详细讨论 channel 和锁各自适用的场景。
四、GMP 调度器详解
goroutine 之所以能如此轻量且高效,关键在于 Go 运行时的调度器。Go 的调度器采用 GMP 模型,这是一个精心设计的 M:N 调度器。
1. G、M、P 是什么
- G(Goroutine):即 goroutine,每个
go关键字会创建一个 G。它包含执行的函数、栈、状态等信息。 - M(Machine):代表操作系统的线程,是真正执行代码的载体。M 由操作系统调度。
- P(Processor):逻辑处理器,是 G 和 M 之间的中间层。P 持有一个本地 G 队列,M 必须绑定一个 P 才能执行 G。P 的数量由
GOMAXPROCS决定。
可以这样理解三者的关系:
- G 是任务(要做什么)。
- M 是工人(谁来干活)。
- P 是工位(在哪儿干),工位上放着待办任务清单(本地 G 队列)。
- 一个 M 必须拿到一个 P(站到工位上),才能从 P 的队列里取 G(任务)来执行。
2. 调度流程
- 当我们用
go func()创建一个新 goroutine 时,运行时会创建一个 G,并把它放入当前 P 的本地运行队列。 - M 绑定一个 P,从 P 的本地队列取出 G 执行。
- 如果本地队列为空,M 会尝试从全局队列偷 G,或者从其他 P 的队列偷 G(这叫 work stealing 工作窃取)。
- 当 G 发生系统调用阻塞时,M 会和 P 分离,P 会绑定到另一个 M(或创建新 M)继续执行其他 G,避免 P 闲置。
- 当 G 调度点(函数调用、channel 操作等)触发时,运行时会检查是否需要切换到其他 G。
3. 为什么需要 P
有人会问:为什么不直接让 M 持有 G 队列?引入 P 的好处是:
- 限制并行度:P 的数量等于
GOMAXPROCS,直接控制了同时执行 Go 代码的操作系统线程数,避免线程过多导致频繁切换。 - 本地队列减少锁竞争:每个 P 有自己的本地 G 队列(无锁),大部分时候 M 只操作本地队列,不需要全局锁。
- 简化调度:P 作为调度的上下文,保存了调度所需的状态,使调度逻辑更清晰。
4. work stealing(工作窃取)
当一个 P 的本地队列空了,它不会傻等,而是去「偷」别的 P 的 G:
- 先看自己的本地队列——空。
- 看全局队列——如果有,取一半过来。
- 看其他 P 的队列——如果有,偷一半过来。
这种机制保证了所有 P 的负载尽量均衡,避免出现「有的 P 忙死,有的 P 闲死」的情况。
5. 抢占式调度
早期的 Go(1.14 之前)是协作式调度,goroutine 只有在主动让出(调用某些函数)时才会被切换。如果一个 goroutine 执行密集计算不主动让出,就会饿死其他 goroutine。
Go 1.14 引入了基于信号的抢占式调度:运行时会周期性地向长时间运行的 goroutine 发送信号,强制它让出执行权。这样即使是无休止的 for {} 循环也不会阻塞整个调度器了。
下面用一个例子感受调度器的行为:
go
package main
import (
"fmt"
"runtime"
"time"
)
func busyTask(id int) {
for i := 0; ; i++ {
if i%100000000 == 0 {
fmt.Printf("goroutine %d: 运行中, i=%d\n", id, i)
}
}
}
func main() {
// 设置只用 1 个 P
runtime.GOMAXPROCS(1)
// 启动一个死循环 goroutine
go busyTask(1)
// 由于是抢占式调度,主 goroutine 仍有机会执行
time.Sleep(2 * time.Second)
fmt.Println("主 goroutine 退出,程序结束")
}在 Go 1.14 之前,如果 GOMAXPROCS=1,busyTask 会霸占唯一的 P,主 goroutine 的 time.Sleep 之后永远没机会执行,程序会卡死。有了抢占式调度后,busyTask 会被强制让出,主 goroutine 能正常结束程序。
五、goroutine 与操作系统线程的区别
总结一下 goroutine 相对于操作系统线程的几个关键差异:
1. 栈大小
- 操作系统线程:栈大小固定,通常在创建时就分配 1MB~8MB,无论用没用到都占着。
- goroutine:初始栈只有 2KB,且按需增长(最大可达 1GB),用多少分配多少。这意味着同样 1GB 内存,能创建约 50 万个 goroutine,却只能创建约 1000 个线程。
2. 调度成本
- 操作系统线程:切换需要陷入内核态,保存/恢复寄存器、刷新 TLB,开销在 1~10 微秒级别。
- goroutine:切换完全在用户态完成,只需保存/恢复少量寄存器和栈指针,开销在几百纳秒级别,约为线程切换的 1/10 到 1/100。
3. 调度方
- 操作系统线程:由操作系统内核调度,全系统的线程共享 CPU。
- goroutine:由 Go 运行时调度,运行时决定哪个 goroutine 在哪个 M 上执行。
4. 创建销毁
- 操作系统线程:创建和销毁都要系统调用,开销较大。
- goroutine:创建销毁只是用户态的数据结构操作,开销极小。
下面这个对比程序可以让你直观感受:
go
package main
import (
"fmt"
"sync"
"time"
)
func main() {
const N = 1000000 // 一百万个
// 测试 goroutine
var wg sync.WaitGroup
start := time.Now()
wg.Add(N)
for i := 0; i < N; i++ {
go func() {
defer wg.Done()
}()
}
wg.Wait()
fmt.Printf("创建 %d 个 goroutine 耗时: %v\n", N, time.Since(start))
// 作为对比,创建同样数量的操作系统线程会直接 OOM
// 这里只展示概念,实际不要这样做
fmt.Println("(作为对比,创建 100 万个操作系统线程会导致内存耗尽)")
}六、设置 GOMAXPROCS
GOMAXPROCS 是一个非常重要的环境变量和运行时参数,它决定了 Go 程序能同时用多少个操作系统线程来执行 Go 代码——也就是 P 的数量。
1. 默认值
从 Go 1.5 开始,GOMAXPROCS 的默认值等于 CPU 的逻辑核心数(runtime.NumCPU())。也就是说,在一台 8 核机器上,默认会有 8 个 P,最多同时有 8 个 M 在执行 Go 代码。
2. 如何设置
有三种方式:
go
package main
import (
"fmt"
"runtime"
)
func main() {
// 方式一:通过环境变量 GOMAXPROCS=4 设置(在程序启动前)
// 方式二:通过 runtime.GOMAXPROCS 设置
// 参数 0 表示不修改,只返回当前值
fmt.Println("当前 GOMAXPROCS:", runtime.GOMAXPROCS(0))
// 设置为 2
runtime.GOMAXPROCS(2)
fmt.Println("修改后 GOMAXPROCS:", runtime.GOMAXPROCS(0))
// 恢复为 CPU 核心数
runtime.GOMAXPROCS(runtime.NumCPU())
fmt.Println("CPU 核心数:", runtime.NumCPU())
// 方式三:通过自动探测(Go 1.25+ 支持 runtime.GOMAXPROCS 自动适配容器限制)
}3. 设置建议
- 大多数情况保持默认:让
GOMAXPROCS等于 CPU 核心数是最佳选择,运行时已经做过优化。 - CPU 密集型任务:不要超过核心数,否则会增加上下文切换开销。
- I/O 密集型任务:可以适当调高,但通常默认值也够用(因为 I/O 阻塞的 M 会释放 P)。
- 容器环境注意:在 Docker 等容器中,旧版 Go 会读到宿主机的 CPU 数而不是容器的 CPU 限制,可能导致 P 过多。Go 1.25 引入了自动适配 cgroup 限制的特性,建议升级或使用
automaxprocs库。
下面这个例子展示 GOMAXPROCS 对 CPU 密集型任务的影响:
go
package main
import (
"fmt"
"runtime"
"sync"
"time"
)
func cpuBound(n int) int {
sum := 0
for i := 0; i < n; i++ {
sum += i
}
return sum
}
func main() {
const tasks = 8
const n = 1_000_000_000
for _, procs := range []int{1, 2, 4, 8} {
runtime.GOMAXPROCS(procs)
var wg sync.WaitGroup
start := time.Now()
wg.Add(tasks)
for i := 0; i < tasks; i++ {
go func() {
defer wg.Done()
_ = cpuBound(n)
}()
}
wg.Wait()
fmt.Printf("GOMAXPROCS=%d, %d 个 CPU 密集任务耗时: %v\n",
procs, tasks, time.Since(start))
}
}运行这段代码你会发现,当 GOMAXPROCS 从 1 增加到核心数时,耗时明显下降;但如果超过核心数,提升就不明显了,甚至可能因为切换开销而变慢。
七、小结
本篇是 Go 并发编程的基础,我们讲解了以下核心概念:
并发 vs 并行:并发是程序结构设计,关注多任务的管理;并行是执行方式,关注同一时刻真正同时执行。Go 通过 goroutine 让我们用并发的方式组织代码,运行时自动在多核上并行执行。
进程、线程、协程:进程是资源分配单位,线程是 CPU 调度单位,协程是用户态轻量级线程。goroutine 是 Go 对协程的实现,采用 M:N 调度模型。
与 Java 线程对比:Java 线程(传统上)是 1:1 映射到操作系统线程的,重而少;goroutine 是 M:N 调度的,轻而多,可以轻松创建数十万个。
Go 的并发哲学:不要通过共享内存来通信,而要通过通信来共享内存。优先用 channel,但该用锁时也要果断用锁。
GMP 模型:G 是 goroutine(任务),M 是操作系统线程(工人),P 是逻辑处理器(工位)。P 的数量由
GOMAXPROCS决定,work stealing 机制保证负载均衡,抢占式调度避免单个 goroutine 霸占 CPU。goroutine 的优势:初始栈 2KB(可动态增长)、用户态调度、创建销毁开销极小,可以轻松创建几十万个。
GOMAXPROCS:控制 P 的数量,默认等于 CPU 核心数,大多数情况下保持默认即可,容器环境需注意自动适配。
理解了这些基础概念,我们就为学习后续章节的 goroutine 操作、channel 通信、并发模式打下了坚实的基础。下一篇我们将动手实践,学习如何启动和管理 goroutine。