并发与并行
并发(Concurrency)是指程序的结构设计——能够处理多个任务在逻辑上同时推进的能力,关注的是任务的分解与调度。并行(Parallelism)是指程序的执行方式——多个任务在物理上同时运行,关注的是利用多核 CPU 实现真正的同时执行。并发是关于”处理”很多事情,并行是关于”做”很多事情。
并发 vs 并行可视化
并发:同一个 CPU 核心在不同时间段交替执行多个任务(交替执行)
并行:多个 CPU 核心在同一时刻真正同时执行多个任务(同时执行)
进程、线程与协程
进程(Process)
进程是操作系统资源分配的基本单位,拥有独立的内存空间(虚拟地址空间)、文件描述符、环境变量等。进程间通信(IPC)需要通过管道、共享内存、消息队列等机制,开销较大。
进程的特点:
- 隔离性强:每个进程有独立的地址空间,一个进程崩溃不会影响其他进程
- 资源开销大:创建进程需要分配独立的内存空间,上下文切换代价高
- 通信成本高:进程间通信需要内核介入
// 使用 os/exec 启动新进程
package main
import (
"fmt"
"os/exec"
"runtime"
)
func main() {
fmt.Println("当前进程 PID:", os.Getpid())
fmt.Println("CPU 核心数:", runtime.NumCPU())
cmd := exec.Command("ls", "-la")
output, err := cmd.Output()
if err != nil {
fmt.Println("执行失败:", err)
return
}
fmt.Println(string(output))
}线程(Thread)
线程是 CPU 调度的基本单位,是进程内的执行单元。同一进程内的线程共享内存空间,但每个线程有独立的栈和寄存器。线程切换由操作系统内核完成,涉及用户态与内核态的切换。
线程的特点:
- 共享内存:同一进程的线程共享堆内存、文件描述符等资源
- 轻量于进程:创建和切换开销小于进程,但仍涉及内核态切换
- 同步复杂:共享内存需要加锁保护,容易产生竞态条件
// Go 中可以使用 runtime.LockOSThread 将 goroutine 绑定到 OS 线程
package main
import (
"fmt"
"runtime"
)
func main() {
fmt.Println("初始 GOMAXPROCS:", runtime.GOMAXPROCS(0))
fmt.Println("OS 线程数:", runtime.NumGoroutine())
// 将当前 goroutine 绑定到 OS 线程(适用于需要线程本地存储的场景)
runtime.LockOSThread()
defer runtime.UnlockOSThread()
fmt.Println("已绑定到 OS 线程")
}协程(Coroutine / Goroutine)
协程是用户态的轻量级线程,由运行时(而非操作系统)负责调度。协程的创建、切换和销毁成本极低,上下文切换不需要内核介入。Go 的 Goroutine 是协程的一种实现。
协程与线程的关键区别:
| 特性 | OS 线程 | Go Goroutine |
|---|---|---|
| 栈大小 | 固定(通常 1-8MB) | 动态伸缩(初始 2KB,最大可达 GB) |
| 创建成本 | 约 1MB 内存 + 内核调用 | 约 2KB 内存 |
| 切换成本 | 内核态切换(~μs 级) | 用户态切换(~ns 级) |
| 调度者 | 操作系统内核 | Go 运行时调度器 |
| 数量上限 | 通常数千个 | 轻松百万级 |
Goroutine 的栈动态伸缩
Go Goroutine 的栈初始只有 2KB,但会根据需要动态增长(最大可达 1GB)。这意味着你可以安全地创建数十万个 Goroutine 而不必担心内存耗尽,这与 OS 线程需要预分配大栈空间的机制截然不同。
并发 vs 并行
一张图理解本质区别
并发(Concurrency) 并行(Parallelism)
单核 CPU — 交替执行 多核 CPU — 同时执行
Task A: ████░░████████░░░░ Core 1: ████████████████████ (Task A)
Task B: ░░░░██████████████░░ Core 2: ████████████████████ (Task B)
时间 → 时间 →
两个任务交替推进 两个任务真正同时执行
逻辑上"同时" 物理上"同时"Rob Pike 的经典论述
“Concurrency is about dealing with lots of things at once. Parallelism is about doing lots of things at once.”
—— Rob Pike(Go 语言创始人之一)
// 并发但不并行的示例(GOMAXPROCS=1)
package main
import (
"fmt"
"runtime"
"time"
)
func main() {
// 限制只使用一个 OS 线程,确保并发但不并行
runtime.GOMAXPROCS(1)
go func() {
for i := 0; i < 5; i++ {
fmt.Println("Goroutine A:", i)
time.Sleep(100 * time.Millisecond)
}
}()
go func() {
for i := 0; i < 5; i++ {
fmt.Println("Goroutine B:", i)
time.Sleep(100 * time.Millisecond)
}
}()
time.Sleep(1 * time.Second)
fmt.Println("完成")
}
// 输出(交替执行):
// Goroutine A: 0
// Goroutine B: 0
// Goroutine A: 1
// Goroutine B: 1
// ...并发 ≠ 并行
- 并发是程序设计的概念——如何组织代码来处理多个任务
- 并行是程序执行的概念——多个任务是否真正同时运行
- 并发程序在单核 CPU 上也能运行(交替执行),但并行必须依赖多核硬件
- Go 的设计哲学是:通过并发模型,让程序自动利用并行能力
Go 的并发模型:CSP
CSP 模型概述
CSP 是由 Tony Hoare 在 1978 年提出的一种并发编程模型。其核心思想是:不要通过共享内存来通信,而应该通过通信来共享内存。Go 语言的并发设计深受 CSP 模型影响,通过 Goroutine(顺序进程)和 Channel(通信通道)来实现 CSP。
两种并发编程哲学的对比
共享内存模型(传统) CSP 模型(Go)
┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐
│ Thread A│←──→│ Memory │←──→│Thread B│ │Goroutine│──Channel──→│Goroutine│
└─────────┘ └─────────┘ └─────────┘ └─────────┘ └─────────┘
↑ ↑ ↑ ↑
└──── 需要加锁保护共享数据 ────┘ 通过 Channel 传递数据// ❌ 共享内存 + 锁(传统方式)
var counter int
var mu sync.Mutex
func increment() {
mu.Lock()
counter++
mu.Unlock()
}
// ✅ 通过 Channel 通信(Go 推荐方式)
func increment(ch chan int) {
ch <- 1 // 发送信号
}
func main() {
ch := make(chan int)
go increment(ch)
value := <-ch // 接收信号
fmt.Println("收到:", value)
}Go 的并发格言
“Do not communicate by sharing memory; instead, share memory by communicating.” 不要通过共享内存来通信,而应该通过通信来共享内存。 ——Go Proverbs
CSP 模型的核心要素
- 顺序进程(Sequential Process):在 Go 中对应 Goroutine,每个 Goroutine 是一个独立的执行单元
- 通道(Channel):Goroutine 之间的通信机制,传递数据和同步信号
- 选择(Select):从多个通信操作中选择一个执行
Goroutine vs 操作系统线程
详细对比
| 维度 | OS 线程 | Goroutine |
|---|---|---|
| 创建时间 | ~100μs | ~0.3μs(快 300 倍) |
| 初始栈 | 1-8 MB(固定) | 2 KB(动态增长) |
| 切换方式 | 内核态切换 | 用户态切换 |
| 切换开销 | ~1-10μs | ~100-200ns(快 50-100 倍) |
| 内存占用 | 线程数 × 栈大小 | 仅实际使用量 |
| 调度器 | OS 内核调度 | Go runtime 调度器 |
| I/O 模型 | 阻塞或需异步 | 非阻塞,自动让出 CPU |
| 典型数量 | 数百~数千 | 数十万~百万 |
| 身份标识 | 线程 ID | 无直接标识(可用 runtime.Stack) |
Goroutine 的优势
// 100 万个 Goroutine 轻松运行
package main
import (
"fmt"
"sync"
"time"
)
func main() {
start := time.Now()
var wg sync.WaitGroup
for i := 0; i < 1_000_000; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
// 每个 goroutine 做一点工作
_ = id
}(i)
}
wg.Wait()
fmt.Printf("创建了 1,000,000 个 Goroutine,耗时: %v\n", time.Since(start))
// 输出:创建了 1,000,000 个 Goroutine,耗时: 约 1-3 秒
}Goroutine 的注意事项
虽然 Goroutine 轻量,但不意味着可以无限制创建:
- 每个 Goroutine 至少需要 2KB 栈空间
- 过多的 Goroutine 会导致调度器压力增大,上下文切换变慢
- 应该使用 Worker Pool 等模式控制 Goroutine 数量
- 注意 Goroutine 泄漏——未正确退出的 Goroutine 会持续占用资源
GMP 调度模型
GMP 是 Go 运行时调度器的核心模型,由三个关键组件构成:
- G(Goroutine):用户态的协程,包含栈、指令指针等信息
- M(Machine):操作系统的线程,是实际的执行单元
- P(Processor):逻辑处理器,持有本地运行队列,M 必须关联 P 才能执行 G
GMP 模型架构
┌──────────────────────────────────────┐
│ Go Runtime Scheduler │
├──────────────────────────────────────┤
│ │
┌───────┐ │ ┌──────────┐ ┌──────────┐ │
│ G1 │──→ │ │ P0 │←──→ │ P1 │ │
│ G2 │ │ │ ┌──┬──┐ │ │ ┌──┬──┐ │ │
│ G3 │ │ │ │G4│G5│ │ │ │G7│G8│ │ │
└───────┘ │ │ └──┴──┘ │ │ └──┴──┘ │ │
Global Queue │ └────┬─────┘ └────┬─────┘ │
│ │ M0 │ M1 │
│ ┌────┴─────┐ ┌────┴─────┐ │
│ │ OS 线程 │ │ OS 线程 │ │
│ └──────────┘ └──────────┘ │
│ │
│ ┌──────────────────────┐ │
│ │ Global Run Queue │ │
│ │ G9, G10, G11, ... │ │
│ └──────────────────────┘ │
└──────────────────────────────────────┘三个核心组件
G — Goroutine
// G 的内部结构(简化)
type g struct {
stack stack // 栈信息(动态伸缩)
sched gobuf // 调度信息(保存/恢复上下文)
goid int64 // Goroutine ID
status uint32 // 状态:runnable/running/waiting等
preempt bool // 是否被抢占
m *m // 当前绑定的 M
}M — Machine(OS 线程)
// M 的内部结构(简化)
type m struct {
g0 *g // 调度用的 goroutine
curg *g // 当前运行的 goroutine
p uintptr // 关联的 P
nextp *p // 唤醒时绑定的 P
spinning bool // 是否在寻找可运行的 G
}P — Processor(逻辑处理器)
// P 的内部结构(简化)
type p struct {
id int32
status uint32
runqhead uint32 // 本地队列头
runqtail uint32 // 本地队列尾
runq [256]*g // 本地运行队列(最多 256 个 G)
runnext *g // 下一个要运行的 G
mcache *mcache // 内存分配缓存
}调度过程
GMP 调度的核心流程
- 创建 G:新创建的 G 优先放入当前 P 的本地队列
- 本地队列满:将本地队列的前半部分移到全局队列
- 本地队列空:M 从其他 P 的本地队列”偷”一半 G(Work Stealing),或从全局队列获取
- G 执行:M 从关联的 P 的本地队列取出 G 执行
- G 阻塞:当 G 执行系统调用或 Channel 操作阻塞时,M 释放 P,P 转去服务其他 M
Work Stealing(工作窃取)机制
P0 的本地队列:[G1, G2, G3] → P0 的 M 已忙
P1 的本地队列:[](空) → P1 的 M 处于空闲
P1 的 M 从 P0 窃取一半的 G:
P0 的本地队列:[G1]
P1 的本地队列:[G2, G3] → P1 的 M 开始执行 G2GOMAXPROCS 的设置
runtime.GOMAXPROCS(n) 设置 P 的数量,默认等于 CPU 核心数。这决定了 Go 程序能同时利用多少个 CPU 核心:
GOMAXPROCS=1:所有 Goroutine 在一个线程上并发执行(不并行)GOMAXPROCS=4:最多同时使用 4 个线程并行执行 Goroutine- Go 1.5+ 默认值就是 CPU 核心数,通常无需手动调整
package main
import (
"fmt"
"runtime"
"time"
)
func main() {
// 查看和设置 GOMAXPROCS
fmt.Println("默认 GOMAXPROCS:", runtime.GOMAXPROCS(0))
fmt.Println("CPU 核心数:", runtime.NumCPU())
// 设置为 4(通常不需要,默认就是 CPU 核心数)
old := runtime.GOMAXPROCS(4)
fmt.Println("修改前的值:", old)
fmt.Println("当前 GOMAXPROCS:", runtime.GOMAXPROCS(0))
}调度时机
Goroutine 在以下情况会触发调度(让出 CPU):
- 函数调用:
runtime.morestack检查栈空间并触发调度 - Channel 操作:发送/接收导致阻塞时
- 系统调用:网络 I/O、文件 I/O 等
runtime.Gosched():显式让出 CPU- 垃圾回收:GC STW 阶段会暂停所有 Goroutine
time.Sleep():主动休眠- 锁竞争:获取锁失败时
练习题
练习 1:并发 vs 并行实验
编写一个程序,分别设置 GOMAXPROCS=1 和 GOMAXPROCS=runtime.NumCPU(),创建两个 Goroutine 各自进行 100 万次计数。对比两种设置下程序运行的总时间,并解释差异。
代码:
package main
import (
"fmt"
"runtime"
"sync"
"time"
)
func count(id string, n int, wg *sync.WaitGroup) {
defer wg.Done()
for i := 0; i < n; i++ {
_ = i
}
fmt.Printf("%s 完成\n", id)
}
func runTest(procs int) {
runtime.GOMAXPROCS(procs)
var wg sync.WaitGroup
start := time.Now()
wg.Add(2)
go count("Worker-A", 10_000_000, &wg)
go count("Worker-B", 10_000_000, &wg)
wg.Wait()
fmt.Printf("GOMAXPROCS=%d, 耗时: %v\n\n", procs, time.Since(start))
}
func main() {
fmt.Println("CPU 核心数:", runtime.NumCPU())
fmt.Println("---")
// 并发但不并行
runTest(1)
// 并发且并行
runTest(runtime.NumCPU())
}预期输出(在多核机器上):
CPU 核心数: 8
---
Worker-A 完成
Worker-B 完成
GOMAXPROCS=1, 耗时: 约 20ms ← 两个任务交替执行
Worker-A 完成
Worker-B 完成
GOMAXPROCS=8, 耗时: 约 10ms ← 两个任务真正并行分析:GOMAXPROCS=1 时,两个 Goroutine 在同一个 OS 线程上交替执行,总时间约等于两个任务时间之和。GOMAXPROCS=8 时,两个 Goroutine 可以同时在不同核心上执行,总时间约等于单个任务的执行时间。
练习 2:Goroutine 数量测试
编写程序测量创建不同数量 Goroutine(1000、10000、100000、1000000)的时间和内存消耗,观察 Goroutine 的轻量特性。
代码:
package main
import (
"fmt"
"runtime"
"sync"
"time"
)
func run(id int, wg *sync.WaitGroup) {
defer wg.Done()
// 模拟一些工作
_ = id
}
func testGoroutines(count int) {
runtime.GC()
var m1, m2 runtime.MemStats
runtime.ReadMemStats(&m1)
var wg sync.WaitGroup
start := time.Now()
for i := 0; i < count; i++ {
wg.Add(1)
go run(i, &wg)
}
wg.Wait()
elapsed := time.Since(start)
runtime.ReadMemStats(&m2)
fmt.Printf("Goroutine 数量: %10d | 耗时: %10v | 内存增量: %10 KB\n",
count, elapsed, (m2.Sys-m1.Sys)/1024)
}
func main() {
fmt.Println("Goroutine 轻量性测试")
fmt.Println("=========================================")
fmt.Printf("CPU 核心数: %d\n\n", runtime.NumCPU())
counts := []int{1_000, 10_000, 100_000, 1_000_000}
for _, count := range counts {
testGoroutines(count)
}
}典型输出(会因机器而异):
Goroutine 轻量性测试
=========================================
CPU 核心数: 8
Goroutine 数量: 1000 | 耗时: 1.2ms | 内存增量: 28 KB
Goroutine 数量: 10000 | 耗时: 8.5ms | 内存增量: 245 KB
Goroutine 数量: 100000 | 耗时: 65.3ms | 内存增量: 2,100 KB
Goroutine 数量: 1000000 | 耗时: 720.0ms | 内存增量: 20,500 KB结论:即使创建 100 万个 Goroutine,内存增量也仅在 20MB 左右(每个约 20KB),而同等数量的 OS 线程(每线程 8MB 栈)将需要约 8TB 内存。
练习 3:CSP 模型实践
使用 Channel 实现”不要通过共享内存来通信”的理念:创建 3 个 Goroutine 作为生产者,每个向同一个 Channel 发送数据;1 个 Goroutine 作为消费者,从 Channel 接收并处理所有数据。要求使用 sync.WaitGroup 确保所有生产者完成后关闭 Channel。
代码:
package main
import (
"fmt"
"sync"
)
func producer(id int, ch chan<- int, wg *sync.WaitGroup) {
defer wg.Done()
for i := 1; i <= 5; i++ {
value := id*100 + i
ch <- value // 通过 Channel 通信,而非共享变量
fmt.Printf("生产者 %d 发送: %d\n", id, value)
}
}
func consumer(ch <-chan int, done chan<- struct{}) {
for value := range ch {
fmt.Printf(" 消费者收到: %d\n", value)
}
fmt.Println("所有生产者已完成,消费者退出")
close(done)
}
func main() {
ch := make(chan int)
done := make(chan struct{})
var wg sync.WaitGroup
// 启动消费者
go consumer(ch, done)
// 启动 3 个生产者
for id := 1; id <= 3; id++ {
wg.Add(1)
go producer(id, ch, &wg)
}
// 所有生产者完成后关闭 channel
go func() {
wg.Wait()
close(ch)
}()
// 等待消费者完成
<-done
}输出(每次运行顺序可能不同):
生产者 1 发送: 101
消费者收到: 101
生产者 2 发送: 201
消费者收到: 201
生产者 3 发送: 301
消费者收到: 301
生产者 1 发送: 102
消费者收到: 102
...
所有生产者已完成,消费者退出关键点:数据通过 Channel 在 Goroutine 之间传递,没有共享的可变状态,因此不需要加锁。
