Skip to content

并发基础:进程、线程与协程

并发编程是 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. 调度流程

  1. 当我们用 go func() 创建一个新 goroutine 时,运行时会创建一个 G,并把它放入当前 P 的本地运行队列。
  2. M 绑定一个 P,从 P 的本地队列取出 G 执行。
  3. 如果本地队列为空,M 会尝试从全局队列偷 G,或者从其他 P 的队列偷 G(这叫 work stealing 工作窃取)。
  4. 当 G 发生系统调用阻塞时,M 会和 P 分离,P 会绑定到另一个 M(或创建新 M)继续执行其他 G,避免 P 闲置。
  5. 当 G 调度点(函数调用、channel 操作等)触发时,运行时会检查是否需要切换到其他 G。

3. 为什么需要 P

有人会问:为什么不直接让 M 持有 G 队列?引入 P 的好处是:

  • 限制并行度:P 的数量等于 GOMAXPROCS,直接控制了同时执行 Go 代码的操作系统线程数,避免线程过多导致频繁切换。
  • 本地队列减少锁竞争:每个 P 有自己的本地 G 队列(无锁),大部分时候 M 只操作本地队列,不需要全局锁。
  • 简化调度:P 作为调度的上下文,保存了调度所需的状态,使调度逻辑更清晰。

4. work stealing(工作窃取)

当一个 P 的本地队列空了,它不会傻等,而是去「偷」别的 P 的 G:

  1. 先看自己的本地队列——空。
  2. 看全局队列——如果有,取一半过来。
  3. 看其他 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=1busyTask 会霸占唯一的 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 并发编程的基础,我们讲解了以下核心概念:

  1. 并发 vs 并行:并发是程序结构设计,关注多任务的管理;并行是执行方式,关注同一时刻真正同时执行。Go 通过 goroutine 让我们用并发的方式组织代码,运行时自动在多核上并行执行。

  2. 进程、线程、协程:进程是资源分配单位,线程是 CPU 调度单位,协程是用户态轻量级线程。goroutine 是 Go 对协程的实现,采用 M:N 调度模型。

  3. 与 Java 线程对比:Java 线程(传统上)是 1:1 映射到操作系统线程的,重而少;goroutine 是 M:N 调度的,轻而多,可以轻松创建数十万个。

  4. Go 的并发哲学:不要通过共享内存来通信,而要通过通信来共享内存。优先用 channel,但该用锁时也要果断用锁。

  5. GMP 模型:G 是 goroutine(任务),M 是操作系统线程(工人),P 是逻辑处理器(工位)。P 的数量由 GOMAXPROCS 决定,work stealing 机制保证负载均衡,抢占式调度避免单个 goroutine 霸占 CPU。

  6. goroutine 的优势:初始栈 2KB(可动态增长)、用户态调度、创建销毁开销极小,可以轻松创建几十万个。

  7. GOMAXPROCS:控制 P 的数量,默认等于 CPU 核心数,大多数情况下保持默认即可,容器环境需注意自动适配。

理解了这些基础概念,我们就为学习后续章节的 goroutine 操作、channel 通信、并发模式打下了坚实的基础。下一篇我们将动手实践,学习如何启动和管理 goroutine。