导航菜单

并发与并行

并发与并行(Concurrency vs Parallelism)

并发(Concurrency)是指程序的结构设计——能够处理多个任务在逻辑上同时推进的能力,关注的是任务的分解与调度。并行(Parallelism)是指程序的执行方式——多个任务在物理上同时运行,关注的是利用多核 CPU 实现真正的同时执行。并发是关于”处理”很多事情,并行是关于”做”很多事情。

并发 vs 并行可视化

并发(单核)一个 CPU 核心交替执行多个任务
CPU
任务 A
任务 B
任务 A
任务 B
任务 A
时间 →

并发:同一个 CPU 核心在不同时间段交替执行多个任务(交替执行)

并行(多核)多个 CPU 核心同时执行多个任务
CPU 1
CPU 2
任务 A
任务 B
时间 →

并行:多个 CPU 核心在同一时刻真正同时执行多个任务(同时执行)

进程、线程与协程

进程(Process)

进程(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)

线程(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)

协程(Coroutine)

协程是用户态的轻量级线程,由运行时(而非操作系统)负责调度。协程的创建、切换和销毁成本极低,上下文切换不需要内核介入。Go 的 Goroutine 是协程的一种实现。

协程与线程的关键区别:

特性OS 线程Go Goroutine
栈大小固定(通常 1-8MB)动态伸缩(初始 2KB,最大可达 GB)
创建成本约 1MB 内存 + 内核调用约 2KB 内存
切换成本内核态切换(~μs 级)用户态切换(~ns 级)
调度者操作系统内核Go 运行时调度器
数量上限通常数千个轻松百万级

并发 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
// ...

Go 的并发模型:CSP

CSP 模型概述

CSP(Communicating Sequential Processes)

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)
}

CSP 模型的核心要素

  1. 顺序进程(Sequential Process):在 Go 中对应 Goroutine,每个 Goroutine 是一个独立的执行单元
  2. 通道(Channel):Goroutine 之间的通信机制,传递数据和同步信号
  3. 选择(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 秒
}

GMP 调度模型

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  // 内存分配缓存
}

调度过程

Work Stealing(工作窃取)机制

P0 的本地队列:[G1, G2, G3]  →  P0 的 M 已忙
P1 的本地队列:[](空)        →  P1 的 M 处于空闲

P1 的 M 从 P0 窃取一半的 G:
P0 的本地队列:[G1]
P1 的本地队列:[G2, G3]  →  P1 的 M 开始执行 G2
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):

  1. 函数调用runtime.morestack 检查栈空间并触发调度
  2. Channel 操作:发送/接收导致阻塞时
  3. 系统调用:网络 I/O、文件 I/O 等
  4. runtime.Gosched():显式让出 CPU
  5. 垃圾回收:GC STW 阶段会暂停所有 Goroutine
  6. time.Sleep():主动休眠
  7. 锁竞争:获取锁失败时

练习题

练习 1:并发 vs 并行实验

编写一个程序,分别设置 GOMAXPROCS=1GOMAXPROCS=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 之间传递,没有共享的可变状态,因此不需要加锁。

搜索