文章目录
- [🚀 16 - Go 协程(goroutine):从基础到实战](#🚀 16 - Go 协程(goroutine):从基础到实战)
- [什么是 goroutine?](#什么是 goroutine?)
-
- [🚀 第一个 goroutine](#🚀 第一个 goroutine)
- [goroutine 执行机制](#goroutine 执行机制)
-
- [🔥 关键模型:GMP 模型](#🔥 关键模型:GMP 模型)
- [🧠 调度流程(简化版)](#🧠 调度流程(简化版))
- [🚀 为什么 goroutine 很轻?](#🚀 为什么 goroutine 很轻?)
- [goroutine + channel(核心组合)](#goroutine + channel(核心组合))
-
- [📦 channel 基础](#📦 channel 基础)
- [示例:goroutine 通信](#示例:goroutine 通信)
- [🔁 带缓冲 channel](#🔁 带缓冲 channel)
- [goroutine 实战场景](#goroutine 实战场景)
-
- [🧪 并发任务处理](#🧪 并发任务处理)
- [🧪 使用 WaitGroup 控制并发](#🧪 使用 WaitGroup 控制并发)
- [🧪 并发安全(Mutex)](#🧪 并发安全(Mutex))
- [goroutine 常见坑(必会)](#goroutine 常见坑(必会))
-
- [❌ 主 goroutine 提前退出](#❌ 主 goroutine 提前退出)
- [❌ 闭包变量问题(经典面试题)](#❌ 闭包变量问题(经典面试题))
- [❌ goroutine 泄漏](#❌ goroutine 泄漏)
- [goroutine 调度细节(进阶)](#goroutine 调度细节(进阶))
-
- [⏱ 抢占式调度(Go 1.14+)](#⏱ 抢占式调度(Go 1.14+))
- [🔄 调度时机](#🔄 调度时机)
- 性能优化建议
-
- [🚀 控制 goroutine 数量](#🚀 控制 goroutine 数量)
- [🚀 使用 sync.Pool 复用对象](#🚀 使用 sync.Pool 复用对象)
- [🚀 合理使用 channel](#🚀 合理使用 channel)
- 总结
-
- [🎯 goroutine 核心要点](#🎯 goroutine 核心要点)
- [🎯 并发三件套](#🎯 并发三件套)
- [🎯 一句话总结](#🎯 一句话总结)
- [📌 面试高频问题](#📌 面试高频问题)
🚀 16 - Go 协程(goroutine):从基础到实战
Go 的并发之所以强大,不是因为它快,而是因为它"简单且优雅"。
在 Go 语言中,并发编程的核心就是 goroutine。它让你用极低的成本实现高并发,是 Go 被称为"云原生语言"的关键原因之一。
什么是 goroutine?
goroutine 是 Go 语言中的轻量级线程(用户态线程)
👉 特点:
- 占用内存极小(初始 ~2KB)
- 创建成本极低
- 由 Go runtime 调度(而不是操作系统)
- 可以轻松创建成千上万个
🚀 第一个 goroutine
go
package main
import (
"fmt"
"time"
)
// 定义一个普通函数
func hello() {
fmt.Println("Hello, world!") // 打印一句话
}
func main() {
go hello()
// 使用 go 关键字启动一个 goroutine(协程)
// 此时 hello() 会在一个新的协程中异步执行
// main 函数不会等待它执行完
time.Sleep(time.Second)
// 让主 goroutine 休眠 1 秒
// 作用:防止 main 提前退出
// 如果没有这行代码,程序可能在 hello() 执行前就结束了
}
👉 注意:
👉 1. goroutine 是异步执行的
go hello()不会阻塞- main 会继续往下执行
👉 2. main 退出 = 所有 goroutine 结束
- 这是很多新手最容易踩的坑
goroutine 执行机制
🔥 关键模型:GMP 模型
Go 的调度核心是:
| 名称 | 含义 |
|---|---|
| G | Goroutine |
| M | 线程(Machine) |
| P | Processor(调度器) |
👉 关系:
G(任务) → P(队列) → M(执行)
🧠 调度流程(简化版)
- goroutine(G)加入队列
- P 负责调度 G
- M(线程)执行 G
- 遇到阻塞 → 切换其他 G
🚀 为什么 goroutine 很轻?
相比传统线程:
| 对比项 | 线程 | goroutine |
|---|---|---|
| 创建成本 | 高 | 极低 |
| 内存 | MB级 | KB级 |
| 调度 | OS | Go runtime |
| 切换 | 慢 | 快 |
goroutine + channel(核心组合)
Go 并发哲学:
不要通过共享内存来通信,而要通过通信来共享内存
📦 channel 基础
go
ch := make(chan int)
示例:goroutine 通信
go
package main
import "fmt"
// 定义一个 worker 函数,接收一个 int 类型的 channel
func worker(ch chan int) {
ch <- 100
// 向 channel 发送数据 100
// 如果没有接收者,这里会阻塞(很关键)
}
func main() {
ch := make(chan int)
// 创建一个无缓冲 channel(同步 channel)
// 特点:发送和接收必须同时准备好,否则会阻塞
go worker(ch)
// 启动 goroutine 执行 worker
// worker 会尝试向 channel 发送数据
v := <-ch
// 从 channel 接收数据
// 如果没有数据,这里会阻塞,直到有数据写入
fmt.Println(v)
// 输出接收到的值:100
}
🔁 带缓冲 channel
go
package main
import "fmt"
// 定义 worker 函数,参数是一个 int 类型的 channel
func worker(ch chan int) {
ch <- 100
// 向 channel 发送数据 100
// 因为是带缓冲 channel,所以只要 buffer 没满就不会阻塞
}
func main() {
ch := make(chan int, 2)
// 创建一个带缓冲的 channel,容量为 2
// 表示最多可以暂存 2 个 int
ch <- 1
// 第一次发送:放入 buffer[0]
ch <- 2
// 第二次发送:放入 buffer[1]
// 此时 buffer 已满(2/2)
fmt.Println(<-ch)
// 从 channel 取出一个值(1)
// buffer 腾出一个位置
fmt.Println(<-ch)
// 再取出一个值(2)
// 此时 buffer 为空
go worker(ch)
// 启动 goroutine 执行 worker
// 因为 buffer 已经空出空间,所以可以正常写入 100
fmt.Println(<-ch)
// 从 channel 取出一个值(100)
fmt.Println("main function")
// 主函数继续执行,不会等待 worker
}
输出:
bath
1
2
100
main function
实际运行逻辑是:
创建 buffer = 2 的 channel
写入 1、2(buffer 满)
读取 1、2(buffer 清空)
启动 goroutine 写入 100
main 继续执行,读取 100
👉 特点:
- 不会立即阻塞
- 类似队列
goroutine 实战场景
🧪 并发任务处理
go
package main
import (
"fmt"
"time"
)
// 定义一个任务函数,模拟耗时操作
func task(id int) {
fmt.Println("start", id)
// 打印任务开始
time.Sleep(time.Second)
// 模拟耗时 1 秒的业务逻辑(比如 IO / 网络 / DB)
fmt.Println("end", id)
// 打印任务结束
}
func main() {
for i := 0; i < 10; i++ {
go task(i)
// 启动 10 个 goroutine 并发执行 task
// 每个 goroutine 处理一个 id
}
time.Sleep(2 * time.Second)
// 主 goroutine 休眠 2 秒
// 作用:防止 main 函数提前退出
// 否则子 goroutine 还没执行完程序就结束了
}
输出:
bash
start 9
start 6
start 4
start 5
start 8
start 0
start 1
start 2
start 7
start 3
end 9
end 6
end 5
end 4
end 0
end 8
end 3
end 1
end 2
end 7
👉 输出是"交错的"
👉 重点:
goroutine 调度是抢占式 + 不可控顺序
- 10 个 goroutine 同时进入调度队列
- Go runtime 自动调度执行
- 执行顺序 完全不确定
🧪 使用 WaitGroup 控制并发
go
package main
import (
"fmt"
"sync"
)
// 定义一个任务函数,接收 id 和 WaitGroup 指针
func task(id int, wg *sync.WaitGroup) {
defer wg.Done()
// defer 保证函数结束时一定调用 Done()
// 表示该 goroutine 执行完成,计数器 -1
fmt.Println("task:", id)
// 模拟任务执行
}
func main() {
var wg sync.WaitGroup
// 创建 WaitGroup,用于控制 goroutine 同步
for i := 0; i < 10; i++ {
wg.Add(1)
// 每启动一个 goroutine,计数器 +1
// 表示"还有一个任务未完成"
go task(i, &wg)
// 启动 goroutine 执行任务
// 注意:传指针,否则会拷贝 wg(错误写法)
}
wg.Wait()
// 阻塞主 goroutine
// 直到 wg 计数器变为 0(所有任务完成)
}
输出:顺序不一的
bash
task: 9
task: 0
task: 1
task: 2
task: 3
task: 4
task: 5
task: 6
task: 7
task: 8
👉 推荐:生产环境必须用 WaitGroup,而不是 sleep
WaitGroup 是 Go 中用于"等待一组 goroutine 完成"的标准同步工具,本质是计数器控制并发生命周期。
🧪 并发安全(Mutex)
go
package main
import (
"fmt"
"sync"
)
// 全局变量:共享资源(多个 goroutine 会同时访问)
var count int
// 定义互斥锁,用于保护共享变量 count
var mu sync.Mutex
// 定义任务函数,接收 WaitGroup 指针
func add(wg *sync.WaitGroup) {
defer wg.Done()
// goroutine 执行完成后通知 WaitGroup -1
mu.Lock()
// 加锁:同一时刻只允许一个 goroutine 进入临界区
count++
// 临界区:对共享变量进行修改(非原子操作)
mu.Unlock()
// 解锁:允许其他 goroutine 进入临界区
}
func main() {
var wg sync.WaitGroup
// 用于等待所有 goroutine 执行完成
for i := 0; i < 1000; i++ {
wg.Add(1)
// 每启动一个 goroutine,计数 +1
go add(&wg)
// 启动 goroutine 执行加法操作
}
wg.Wait()
// 阻塞主 goroutine,等待所有任务完成
fmt.Println(count)
// 输出最终结果:1000
}
👉 Go 设计哲学:
不要通过共享内存通信,而要通过通信共享内存
Mutex 的作用是保证共享资源在并发访问时的"互斥性",从而避免数据竞争,保证程序结果正确。
goroutine 常见坑(必会)
❌ 主 goroutine 提前退出
go
go func() {
fmt.Println("hello")
}()
👉 可能不会执行!
✔ 解决:
WaitGroup- channel
- 阻塞 main
❌ 闭包变量问题(经典面试题)
go
for i := 0; i < 3; i++ {
go func() {
fmt.Println(i)
}()
}
👉 可能输出:
3 3 3
✔ 正确写法:
go
for i := 0; i < 3; i++ {
go func(i int) {
fmt.Println(i)
}(i)
}
❌ goroutine 泄漏
go
func worker(ch chan int) {
<-ch // 永远等不到
}
👉 没有关闭 channel → goroutine 永久阻塞
正确写法:
go
func worker(ch chan int) {
for v := range ch {
fmt.Println(v)
}
}
主函数:
go
ch := make(chan int)
go worker(ch)
ch <- 1
ch <- 2
close(ch) // 👈 关键:关闭 channel
goroutine 调度细节(进阶)
⏱ 抢占式调度(Go 1.14+)
以前:
- 协程不会主动让出 CPU
现在:
- Go runtime 会强制抢占
👉 优势:
- 防止某个 goroutine 长时间占用 CPU
🔄 调度时机
goroutine 切换发生在:
- channel 阻塞
- IO 阻塞
- 系统调用
- runtime 主动调度
性能优化建议
🚀 控制 goroutine 数量
❌ 错误:
go
for {
go task()
}
✔ 正确(使用 worker pool):
go
jobs := make(chan int, 100)
for w := 0; w < 5; w++ {
go worker(jobs)
}
🚀 使用 sync.Pool 复用对象
减少 GC 压力(高并发场景)
🚀 合理使用 channel
- 不要滥用
- 简单场景用锁更高效
总结
🎯 goroutine 核心要点
go关键字开启协程- 本质是用户态线程
- 由 GMP 模型调度
- 与 channel 配合使用最优雅
🎯 并发三件套
- goroutine
- channel
- sync(WaitGroup / Mutex)
🎯 一句话总结
goroutine 让并发变简单,但并发本身并不简单。
📌 面试高频问题
- goroutine 和线程区别?
答:轻量级线程,由 Go runtime 管理。 - GMP 模型是什么?
答:Go 运行时调度模型,包含 G(goroutine)、M(线程)和 P(处理器)。 - channel 是怎么实现的?
答:基于管道通信,底层实现依赖于 goroutine。 - 如何避免 goroutine 泄漏?
答:确保所有 goroutine 执行完毕,或使用 context 控制。 - select 的作用?
答:多路复用,用于等待多个 channel 操作。