Go 入门到精通-19-Goroutine 入门

目录

  • [🟡 Go 入门到精通:Goroutine 入门 ------ 轻量级并发的秘密](#🟡 Go 入门到精通:Goroutine 入门 —— 轻量级并发的秘密)
    • [1. 开篇:Go 并发的灵魂](#1. 开篇:Go 并发的灵魂)
    • [2. goroutine 是什么?](#2. goroutine 是什么?)
      • [2.1 一句话定义](#2.1 一句话定义)
      • [2.2 核心特征](#2.2 核心特征)
    • [3. go 关键字:一行代码启动并发](#3. go 关键字:一行代码启动并发)
      • [3.1 基本语法](#3.1 基本语法)
      • [3.2 闭包陷阱](#3.2 闭包陷阱)
    • [4. 主 goroutine 退出即结束](#4. 主 goroutine 退出即结束)
    • [5. sync.WaitGroup:等待协程完成](#5. sync.WaitGroup:等待协程完成)
      • [5.1 基本用法](#5.1 基本用法)
      • [5.2 WaitGroup 使用原则](#5.2 WaitGroup 使用原则)
      • [5.3 WaitGroup 最佳实践模式](#5.3 WaitGroup 最佳实践模式)
    • [6. goroutine vs 线程:全方位对比](#6. goroutine vs 线程:全方位对比)
      • [6.1 对比表](#6.1 对比表)
      • [6.2 规模演示](#6.2 规模演示)
    • [7. GMP 调度模型深度解析](#7. GMP 调度模型深度解析)
      • [7.1 GMP 三元素](#7.1 GMP 三元素)
      • [7.2 GMP 调度流程](#7.2 GMP 调度流程)
      • [7.3 工作窃取(Work Stealing)图示](#7.3 工作窃取(Work Stealing)图示)
      • [7.4 Goroutine 生命周期](#7.4 Goroutine 生命周期)
    • [8. GOMAXPROCS:并发度的控制旋钮](#8. GOMAXPROCS:并发度的控制旋钮)
      • [8.1 基本概念](#8.1 基本概念)
      • [8.2 GOMAXPROCS 的影响](#8.2 GOMAXPROCS 的影响)
      • [8.3 并发 vs 并行](#8.3 并发 vs 并行)
    • [9. runtime 包:窥探运行时](#9. runtime 包:窥探运行时)
      • [9.1 runtime 常用函数速查](#9.1 runtime 常用函数速查)
    • [10. goroutine 不是银弹:陷阱与反思](#10. goroutine 不是银弹:陷阱与反思)
      • [10.1 过度创建 goroutine](#10.1 过度创建 goroutine)
      • [10.2 goroutine 泄漏](#10.2 goroutine 泄漏)
      • [10.3 Goroutine 使用清单](#10.3 Goroutine 使用清单)
    • [11. 实战:并发爬虫框架](#11. 实战:并发爬虫框架)
    • [12. 小结与互动](#12. 小结与互动)
      • 核心要点回顾
      • [🧠 GMP 结构记忆](#🧠 GMP 结构记忆)
      • [📝 思考题](#📝 思考题)
      • [💬 互动](#💬 互动)

🟡 Go 入门到精通:Goroutine 入门 ------ 轻量级并发的秘密

📅 更新于 2026年7月 | ✍️ 原创文章,转载请注明出处


1. 开篇:Go 并发的灵魂

如果说 Go 语言有什么"杀手级特性",那 goroutine 必然排在第一位。在 Go 中启动一个并发任务只需要一个 go 关键字:

go 复制代码
go doSomething() // 搞定!

而在传统线程模型中,这意味着几十 KB 的栈空间分配、内核线程创建、复杂的线程池管理......

这篇文章将带你从零开始理解 goroutine------从它的轻量本质到 GMP 调度模型,从基本用法到高级陷阱,为后面的 channel 学习打下坚实基础。


2. goroutine 是什么?

2.1 一句话定义

goroutine 是 Go 运行时管理的轻量级用户态线程(协程),初始栈大小仅 2KB,由 Go 调度器(GMP模型)在操作系统线程上复用调度。

2.2 核心特征

go 复制代码
// goroutine 的四个核心特征:

// 1️⃣ 轻量:初始栈仅 2KB(线程通常是 1-8MB)
// 2️⃣ 动态栈:栈空间按需伸缩(最大可达 1GB)
// 3️⃣ 用户态调度:不由 OS 内核调度,切换成本极低(~200ns vs 线程 ~1-10µs)
// 4️⃣ 与 Go 运行时深度集成:GC、channel、select 等原生支持
特性 goroutine OS 线程
初始栈大小 ~2KB ~1-8 MB
栈增长方式 动态伸缩 固定大小
创建开销 数微秒 数百微秒 ~ 毫秒
切换开销 ~200 ns ~1-10 µs
调度方式 Go 运行时(用户态) OS 内核
数量上限 数十万 ~ 百万 数千
标识 无内置 ID 有 TID

3. go 关键字:一行代码启动并发

3.1 基本语法

go 复制代码
package main

import (
    "fmt"
    "time"
)

func sayHello() {
    fmt.Println("Hello from goroutine!")
}

func main() {
    // 启动一个 goroutine
    go sayHello()

    // 匿名函数 + goroutine
    go func() {
        fmt.Println("Hello from anonymous goroutine!")
    }()

    // 带参数的 goroutine
    go func(name string) {
        fmt.Printf("Hello, %s!\n", name)
    }("Go 2026")

    // ⚠️ 必须等待:否则 main 退出,goroutine 来不及执行
    time.Sleep(100 * time.Millisecond)
}

3.2 闭包陷阱

go 复制代码
func main() {
    // ❌ 经典错误:闭包捕获了循环变量
    for i := 0; i < 5; i++ {
        go func() {
            fmt.Println(i) // 可能全打印 5!
        }()
    }
    time.Sleep(time.Second)
    // 输出:5 5 5 5 5(大概率)

    fmt.Println("---")

    // ✅ 正确方式1:传参复制
    for i := 0; i < 5; i++ {
        go func(n int) {
            fmt.Println(n)
        }(i)
    }
    time.Sleep(time.Second)

    // ✅ 正确方式2:局部变量复制(Go 1.22+ 循环变量语义已改进)
    for i := 0; i < 5; i++ {
        i := i // 显式复制
        go func() {
            fmt.Println(i)
        }()
    }
    time.Sleep(time.Second)
}

4. 主 goroutine 退出即结束

这是初学者最容易犯的错误之一:

go 复制代码
func main() {
    // ❌ 可能什么都看不到
    go func() {
        fmt.Println("这条消息可能永远不会被打印")
    }()
    // main 函数返回 → 进程退出 → 所有 goroutine 被强制终止
}

Go 程序的规则

  1. main 函数本身运行在 main goroutine 中
  2. main goroutine 退出 → 整个程序立即退出
  3. 不会等待其他 goroutine 完成
go 复制代码
// 图解:
// main goroutine ──────────────────────▶ 退出 💀
//                  │
// go func() ───────┼── "正在执行..." ──▶ 被强制终止 ☠️
//                  │
// time ────────────▶

5. sync.WaitGroup:等待协程完成

sync.WaitGroup 是等待一组 goroutine 完成的标准工具,可以看作一个线程安全的计数器

5.1 基本用法

go 复制代码
package main

import (
    "fmt"
    "sync"
    "time"
)

func worker(id int, wg *sync.WaitGroup) {
    defer wg.Done() // ⭐ 确保计数器减一(即使 panic)

    fmt.Printf("Worker %d 开始\n", id)
    time.Sleep(time.Duration(id) * 100 * time.Millisecond)
    fmt.Printf("Worker %d 完成\n", id)
}

func main() {
    var wg sync.WaitGroup

    for i := 1; i <= 5; i++ {
        wg.Add(1) // 计数器 +1
        go worker(i, &wg)
    }

    wg.Wait() // 阻塞直到计数器归零
    fmt.Println("所有 Worker 完成!")
}

5.2 WaitGroup 使用原则

go 复制代码
// ✅ 三条铁律:
// 1. Add 必须在 goroutine 启动前调用
// 2. Done 必须在 goroutine 内部调用(通常用 defer)
// 3. WaitGroup 必须通过指针传递(不能值拷贝)

// ❌ 错误示例1:Add 在 goroutine 内部
var wg sync.WaitGroup
for i := 0; i < 5; i++ {
    go func() {
        wg.Add(1)  // ❌ 可能在 Wait 之后调用
        defer wg.Done()
        // ...
    }()
}
wg.Wait()

// ❌ 错误示例2:值传递
func wrongWorker(wg sync.WaitGroup) { // ❌ 值拷贝!
    defer wg.Done()
}
// 应该传指针:func worker(wg *sync.WaitGroup)

5.3 WaitGroup 最佳实践模式

go 复制代码
// 模式1:循环启动
func launchWorkers(n int) {
    var wg sync.WaitGroup
    wg.Add(n) // 一次性设置总数
    for i := 0; i < n; i++ {
        go func(id int) {
            defer wg.Done()
            doWork(id)
        }(i)
    }
    wg.Wait()
}

// 模式2:逐个添加
func launchDynamic() {
    var wg sync.WaitGroup
    for _, task := range tasks {
        wg.Add(1) // 逐个添加
        go func(t Task) {
            defer wg.Done()
            process(t)
        }(task)
    }
    wg.Wait()
}

// 模式3:封装为工具函数
func ConcurrentExec(tasks []func()) {
    var wg sync.WaitGroup
    wg.Add(len(tasks))
    for _, task := range tasks {
        task := task // 复制
        go func() {
            defer wg.Done()
            task()
        }()
    }
    wg.Wait()
}

6. goroutine vs 线程:全方位对比

6.1 对比表

维度 goroutine OS 线程 (Java Thread)
内存开销 初始 2KB,动态伸缩 固定 1-8MB
创建速度 ~2-3 µs ~50-100 µs
切换开销 ~200 ns(用户态) ~1-10 µs(内核态)
调度器 Go GMP 调度器 OS 内核调度器
上下文切换 用户态切换,无需陷入内核 内核态切换,保存/恢复大量寄存器
数量规模 轻松百万级 通常几千(受内存限制)
抢占方式 协作式 + 信号抢占(Go 1.14+) 时间片抢占
标识 无 goroutine ID(故意隐藏) 有 Thread ID
局部存储 无(推荐 context 传递) ThreadLocal

6.2 规模演示

go 复制代码
func main() {
    // 启动 10 万个 goroutine(内存仅增加约 200MB)
    var wg sync.WaitGroup
    wg.Add(100_000)

    for i := 0; i < 100_000; i++ {
        go func(id int) {
            defer wg.Done()
            time.Sleep(time.Second)
        }(i)
    }

    fmt.Println("10万个 goroutine 已启动!")
    wg.Wait()
    fmt.Println("全部完成")
}

如果用 OS 线程实现同样的功能:100,000 × 1MB = ~100GB 内存,基本不可行。


7. GMP 调度模型深度解析

7.1 GMP 三元素

Go 的并发调度模型称为 GMP 模型

复制代码
  ┌──────────────────────────────────────────┐
  │               GMP 调度模型                │
  │                                          │
  │   G ── G ── G ── G ── G  (Goroutine)    │
  │   │         │                            │
  │   P ─────── P ─────── P   (Processor)    │
  │   │         │         │                  │
  │   M ─────── M ─────── M   (Machine)      │
  │   │         │         │                  │
  │  [CPU]    [CPU]     [CPU]                │
  └──────────────────────────────────────────┘
元素 全称 含义 数量
G Goroutine 用户态协程,承载任务 成千上万
M Machine OS 线程,真正执行代码 动态变化
P Processor 逻辑处理器,持有本地运行队列 GOMAXPROCS(默认 CPU 核数)

7.2 GMP 调度流程

go 复制代码
// GMP 工作流程(简化版):

// 1. 每个 P 维护一个本地 goroutine 队列(runq)
// 2. P 与 M 绑定,M 从 P 的本地队列取 G 执行
// 3. 当 M 因系统调用阻塞时,P 会与 M 解绑,寻找新的 M
// 4. 当 M 的 G 阻塞在 channel/网络 I/O 时,G 被挂起,M 继续执行其他 G
// 5. 当 P 的本地队列为空时,从全局队列或"偷"其他 P 的 G

// 核心设计原则:
// - M 是计算资源,P 是调度资源,G 是任务
// - M:P 通常 1:1,但 P 可以在 M 之间迁移
// - 工作窃取(Work Stealing):空闲 P 从忙碌 P 偷 G

7.3 工作窃取(Work Stealing)图示

复制代码
     P1 (忙碌)              P2 (空闲)
    ┌─────────┐            ┌─────────┐
    │ G1 G2 G3│            │ (空)     │
    │ G4 G5 G6│            │          │
    │ G7 G8 G9│ ── steal──▶│ G7 G8 G9 │
    └─────────┘            └─────────┘

当 P2 的本地队列为空时,它会从 P1 "偷"一半的 goroutine。这种设计确保了负载均衡

7.4 Goroutine 生命周期

复制代码
  ┌──────┐  go func()   ┌──────────┐  执行完毕  ┌──────┐
  │ 创建  │ ──────────▶ │  可运行   │ ────────▶ │ 退出  │
  └──────┘              └──────────┘            └──────┘
                             │
                    ┌────────┼────────┐
                    ▼        ▼        ▼
              ┌─────────┐ ┌────────┐ ┌──────────┐
              │channel  │ │ 网络I/O│ │ 系统调用  │
              │等待     │ │ 等待   │ │ 阻塞     │
              └─────────┘ └────────┘ └──────────┘
                    │        │        │
                    └────────┼────────┘
                             ▼
                       ┌──────────┐
                       │  可运行   │ (等待重新调度)
                       └──────────┘

8. GOMAXPROCS:并发度的控制旋钮

8.1 基本概念

GOMAXPROCS 决定了同时执行 Go 代码的 OS 线程数量上限,即 P 的数量。

go 复制代码
package main

import (
    "fmt"
    "runtime"
)

func main() {
    // 查看当前值
    fmt.Println("GOMAXPROCS:", runtime.GOMAXPROCS(0)) // 默认 = CPU 核数

    // 设置新值(返回旧值)
    old := runtime.GOMAXPROCS(4)
    fmt.Println("旧值:", old)

    // 也可以通过环境变量设置:
    // GOMAXPROCS=4 go run main.go
}

8.2 GOMAXPROCS 的影响

GOMAXPROCS 值 效果
1 单核执行,无真正的并行,但 goroutine 仍可并发
N(N < CPU核数) 限制并行度,为其他应用留出 CPU
CPU核数(默认) 充分利用多核
> CPU核数 无额外收益,增加调度开销

8.3 并发 vs 并行

复制代码
并发(单核,GOMAXPROCS=1):
G1: ──▶──▶──
G2:    ──▶──▶──
G3:       ──▶──▶──
   单个 CPU 交替执行

并行(多核,GOMAXPROCS=4):
CPU1: G1 ────────▶
CPU2: G2 ────────▶
CPU3: G3 ────────▶
CPU4: G4 ────────▶
   同时执行

💡 Go 的哲学:Don't communicate by sharing memory; share memory by communicating.(不通过共享内存来通信,而通过通信来共享内存)------ 这将在下一篇 Channel 详解中展开。


9. runtime 包:窥探运行时

go 复制代码
package main

import (
    "fmt"
    "runtime"
    "time"
)

func main() {
    // NumGoroutine:当前 goroutine 数量
    fmt.Println("goroutine 数量:", runtime.NumGoroutine()) // 至少 1(main)

    // NumCPU:逻辑 CPU 数量
    fmt.Println("CPU 核心数:", runtime.NumCPU())

    // GOMAXPROCS:当前 P 的数量
    fmt.Println("GOMAXPROCS:", runtime.GOMAXPROCS(0))

    // Gosched:主动让出 CPU
    go func() {
        for i := 0; i < 5; i++ {
            fmt.Println("goroutine A:", i)
            runtime.Gosched() // 让出执行权
        }
    }()

    go func() {
        for i := 0; i < 5; i++ {
            fmt.Println("goroutine B:", i)
            runtime.Gosched()
        }
    }()

    time.Sleep(time.Second)

    // Goexit:终止当前 goroutine(但不影响其他)
    go func() {
        defer fmt.Println("defer 仍会执行")
        runtime.Goexit() // 终止 goroutine(defer 会执行)
        fmt.Println("这行不会执行")
    }()

    time.Sleep(100 * time.Millisecond)
    fmt.Println("main 结束,goroutine 数量:", runtime.NumGoroutine())

    // LockOSThread:绑定当前 goroutine 到 OS 线程
    // 适用于需要线程局部存储的 CGO 调用
    // runtime.LockOSThread()
    // defer runtime.UnlockOSThread()
}

9.1 runtime 常用函数速查

函数 用途
runtime.NumGoroutine() 当前 goroutine 数量(调试用)
runtime.NumCPU() 逻辑 CPU 核心数
runtime.GOMAXPROCS(n) 设置/查询 P 的数量
runtime.Gosched() 主动让出 CPU 时间片
runtime.Goexit() 终止当前 goroutine
runtime.LockOSThread() 绑定 goroutine 到 OS 线程
runtime.GC() 手动触发 GC(慎用)
runtime.ReadMemStats() 读取内存统计

10. goroutine 不是银弹:陷阱与反思

10.1 过度创建 goroutine

go 复制代码
// ❌ 坏习惯:每个请求创建一个无界数量的 goroutine
func handleRequest(w http.ResponseWriter, r *http.Request) {
    items := fetchAllItems() // 可能有 10 万条
    for _, item := range items {
        go processItem(item) // 💣 瞬间创建 10 万个 goroutine!
    }
}

// ✅ 正确做法:使用工作池(worker pool)或 semaphore 控制并发度
func handleRequestProperly(items []Item) {
    const maxConcurrency = 100
    sem := make(chan struct{}, maxConcurrency)
    var wg sync.WaitGroup

    for _, item := range items {
        wg.Add(1)
        sem <- struct{}{} // 获取令牌
        go func(it Item) {
            defer wg.Done()
            defer func() { <-sem }() // 释放令牌
            processItem(it)
        }(item)
    }
    wg.Wait()
}

10.2 goroutine 泄漏

go 复制代码
// ❌ goroutine 泄漏:channel 永远没有接收者
func leak() {
    ch := make(chan int)
    go func() {
        ch <- 42 // 永远阻塞!goroutine 泄漏
    }()
    // 忘记从 ch 读取
}

// ❌ 另一个泄漏场景:无限循环的 goroutine
func leak2() {
    go func() {
        for {
            // 做点什么...但没有退出条件
            time.Sleep(time.Second)
        }
    }()
    // 这个 goroutine 永远不会退出
}

// ✅ 正确做法:提供退出机制
func noLeak(ctx context.Context) {
    go func() {
        for {
            select {
            case <-ctx.Done():
                return // 正确退出
            default:
                // 做点什么
                time.Sleep(time.Second)
            }
        }
    }()
}

10.3 Goroutine 使用清单

✅ 应该使用 goroutine ❌ 不应使用 goroutine
I/O 密集型任务(网络、文件、数据库) CPU 密集型计算任务(超过 CPU 核数无收益)
独立的并行任务(互不依赖) 强依赖顺序的任务
需要异步执行的操作 简单的 1+1(杀鸡不用牛刀)
高并发服务(每个连接一个 goroutine) 无需并发的场景
定时/周期性任务 为了炫耀技术

11. 实战:并发爬虫框架

go 复制代码
package main

import (
    "fmt"
    "sync"
    "time"
)

// Crawler 并发爬虫
type Crawler struct {
    maxWorkers int
}

func NewCrawler(maxWorkers int) *Crawler {
    return &Crawler{maxWorkers: maxWorkers}
}

// Crawl 并发爬取 URL 列表
func (c *Crawler) Crawl(urls []string) map[string]string {
    results := make(map[string]string)
    var mu sync.Mutex
    var wg sync.WaitGroup

    // semaphore 控制并发数
    sem := make(chan struct{}, c.maxWorkers)

    for _, url := range urls {
        wg.Add(1)
        sem <- struct{}{}
        go func(u string) {
            defer wg.Done()
            defer func() { <-sem }()

            content := fetchURL(u) // 模拟抓取

            mu.Lock()
            results[u] = content
            mu.Unlock()
        }(url)
    }

    wg.Wait()
    return results
}

func fetchURL(url string) string {
    time.Sleep(200 * time.Millisecond) // 模拟网络延迟
    return fmt.Sprintf("Content of %s", url)
}

func main() {
    urls := make([]string, 20)
    for i := 0; i < 20; i++ {
        urls[i] = fmt.Sprintf("http://example.com/page/%d", i)
    }

    crawler := NewCrawler(5) // 最多 5 个并发
    start := time.Now()
    results := crawler.Crawl(urls)
    elapsed := time.Since(start)

    fmt.Printf("爬取了 %d 个 URL,耗时: %v\n", len(results), elapsed)
    // 20 个 URL,5 并发 → 大约 4 轮 × 200ms = ~800ms
}

12. 小结与互动

核心要点回顾

知识点 记忆要点
goroutine 概念 轻量级用户态协程,2KB 初始栈,GMP 调度
go 关键字 一行启动并发,但需要 WaitGroup 等待
WaitGroup Add/Done/Wait,必须传指针
GMP 模型 G=协程 M=线程 P=逻辑处理器
GOMAXPROCS 控制 P 的数量,默认 CPU 核数
Gosched 主动让出,Goexit 终止当前 goroutine
goroutine 泄漏 永远阻塞无退出 → 内存泄漏
并发控制 semaphore/worker pool 限制并发度

🧠 GMP 结构记忆

复制代码
G → 要做什么(任务)
M → 谁来做(干活的人,OS线程)
P → 在哪做(工作台,有工具和队列)

M 必须绑定 P 才能执行 G
P 的数量 = GOMAXPROCS

📝 思考题

  1. 如果 GOMAXPROCS=1,程序还会并发吗?这与"并行"有何区别?
  2. 如何检测和排查 goroutine 泄漏?(提示:runtime.NumGoroutine() + pprof)
  3. 什么是"工作窃取"(Work Stealing)?为什么它比全局队列更高效?

💬 互动

你在项目中最多同时运行过多少个 goroutine?有没有遇到过 goroutine 泄漏的坑?欢迎在评论区分享你的并发经验!


👨‍💻 作者 :布朗克168

📚 系列Go 入门到精通 2026

上一篇:文件操作 | 下一篇:Channel 详解


如果你觉得这篇文章对你有帮助,别忘了点赞 👍、收藏 ⭐ 和关注!

相关推荐
霸道流氓气质3 小时前
SpringBoot+Vue通过ModbusTCP协议实现PLC 设备连接、重连实时控制
vue.js·spring boot·后端
SimonKing3 小时前
阿里要求全员卸载 Claude Code:事件始末与深层逻辑
java·后端·程序员
武子康3 小时前
FDE 到底是什么:为什么 AI 时代重新需要前线部署工程师(4 个标准 + 8 类风险 + 10 个问题)
人工智能·后端·openai
AOwhisky3 小时前
Python 学习笔记(第十一期)——运维自动化(上·后篇):进程级监控与子进程管理——psutil进阶
运维·开发语言·python·学习·云原生·运维开发
xianjixiance_3 小时前
鸿蒙原生开发手记:徒步迹 - 轨迹记录:暂停/继续/停止
后端
猫猫不是喵喵.3 小时前
SpringBoot自动装配原理
java·spring boot·后端
人工干智能3 小时前
Python 的链式调用:一连串的“点”调用
开发语言·python
用户713874229003 小时前
Claude Code Skills 深度解析:参数传递与上下文预注入
后端
薛定猫AI3 小时前
【技术干货】大模型能力评测实战:Python构建可复现的模型选型流水线
人工智能·后端
用户713874229003 小时前
Claude Agent Skills 的四种设计模式;从渐进式披露到最小权限
后端