Goroutine
第13课:Goroutine(协程)
想象一家餐厅:如果只有一个服务员(单线程),他必须为每桌客人点完菜、等上菜、结账后才能服务下一桌。而如果雇很多服务员(多线程),成本又很高。Go 的方案是——雇一群轻量级的兼职服务员(goroutine),他们共享同一套餐厅系统(调度器),谁有空谁就去服务,用极少的人力高效应对大量客人。
核心概念
- Goroutine 是 Go 运行时管理的轻量级并发执行单元,由
go关键字启动。 - Goroutine 不是操作系统线程,初始栈仅约 2KB(线程通常 1-8MB),可轻松创建数十万个。
- Go 运行时使用 M:N 调度模型(G-M-P 模型):将 M 个 goroutine 映射到 N 个 OS 线程上执行。
- Goroutine 之间通过 channel 通信(后续课程详讲),也可用
sync包同步。 - 主 goroutine 退出时,所有子 goroutine 会被强制终止,不会等待它们完成。
基本语法与用法
启动 Goroutine
在函数调用前加 go 关键字即可启动一个新的 goroutine:
GO
go 函数名(参数)
// 或
go func() {
// 匿名函数体
}()
用 sync.WaitGroup 等待 Goroutine
由于主 goroutine 不会自动等待子 goroutine,需要使用 sync.WaitGroup 同步:
GO
var wg sync.WaitGroup
wg.Add(1) // 计数器 +1
go func() {
defer wg.Done() // 完成时计数器 -1
// 执行任务
}()
wg.Wait() // 阻塞直到计数器归零
💡 Tip:
wg.Add() 必须在 go 语句之前调用,否则可能出现竞态条件——主 goroutine 还没来得及 Add 就已经 Wait 返回了。
💡 Tip:不要将
wg.Add() 放在 goroutine 内部,否则 wg.Wait() 可能在 Add 之前就执行了。
💡 Tip:始终用
defer wg.Done() 保证即使发生 panic,计数器也能正确递减。
示例
示例:启动多个 Goroutine(难度⭐)
GO
package main
import (
"fmt"
"sync"
)
func sayHello(id int, wg *sync.WaitGroup) {
defer wg.Done() // 完成时通知 WaitGroup
fmt.Printf("你好,我是 goroutine #%d\n", id)
}
func main() {
var wg sync.WaitGroup
for i := 1; i <= 5; i++ {
wg.Add(1)
go sayHello(i, &wg)
}
wg.Wait() // 等待所有 goroutine 完成
fmt.Println("所有 goroutine 已完成")
}
输出顺序不确定(并发执行):
TEXT
你好,我是 goroutine #3
你好,我是 goroutine #1
你好,我是 goroutine #5
你好,我是 goroutine #2
你好,我是 goroutine #4
所有 goroutine 已完成
示例:并发计算与结果汇总(难度⭐⭐)
GO
package main
import (
"fmt"
"sync"
)
// 并发计算平方值并存入共享切片
func main() {
numbers := []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}
results := make([]int, len(numbers))
var wg sync.WaitGroup
for i, num := range numbers {
wg.Add(1)
go func(index, val int) {
defer wg.Done()
results[index] = val * val // 各 goroutine 写不同索引,无需加锁
}(i, num) // 注意:将循环变量作为参数传入,避免闭包捕获问题
}
wg.Wait()
fmt.Println("原始数据:", numbers)
fmt.Println("平方结果:", results)
}
输出:
TEXT
原始数据: [1 2 3 4 5 6 7 8 9 10]
平方结果: [1 4 9 16 25 36 49 64 81 100]
💡 Tip:Go 1.22 起,
for 循环变量每次迭代都会创建新副本,不再需要显式传参。但为了兼容性和可读性,仍然推荐显式传递。
示例:并发任务池与 Goroutine 泄漏防范(难度⭐⭐⭐)
GO
package main
import (
"context"
"fmt"
"math/rand"
"sync"
"time"
)
// task 表示一个需要处理的任务
func task(ctx context.Context, id int) (string, error) {
// 模拟耗时操作
duration := time.Duration(rand.Intn(500)) * time.Millisecond
select {
case <-time.After(duration):
return fmt.Sprintf("任务 #%d 完成 (耗时 %v)", id, duration), nil
case <-ctx.Done():
return "", ctx.Err() // 上下文取消时立即返回,防止 goroutine 泄漏
}
}
func main() {
rand.Seed(time.Now().UnixNano())
// 设置总超时,防止 goroutine 泄漏
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel() // 确保资源释放
taskCount := 20
results := make(chan string, taskCount) // 带缓冲的 channel 收集结果
var wg sync.WaitGroup
// 启动 5 个 worker goroutine 组成任务池
workerCount := 5
for w := 1; w <= workerCount; w++ {
wg.Add(1)
go func(workerID int) {
defer wg.Done()
for i := workerID; i <= taskCount; i += workerCount {
res, err := task(ctx, i)
if err != nil {
fmt.Printf("Worker %d: 任务 #%d 被取消\n", workerID, i)
return
}
results <- res
}
}(w)
}
// 等待所有 worker 完成后关闭 channel
go func() {
wg.Wait()
close(results)
}()
// 收集结果
for res := range results {
fmt.Println(res)
}
fmt.Println("全部任务处理完毕")
}
关键点:
- 使用
context.WithTimeout控制整体超时,避免 goroutine 无限等待。 - 任务池模式(固定数量 worker)比每个任务一个 goroutine 更可控。
close(results)在所有 worker 完成后关闭 channel,range results会在 channel 关闭后自动退出。
实际应用场景
场景1:并发 HTTP 请求
GO
package main
import (
"fmt"
"io"
"net/http"
"sync"
"time"
)
func fetchURL(url string, wg *sync.WaitGroup) {
defer wg.Done()
start := time.Now()
resp, err := http.Get(url)
if err != nil {
fmt.Printf("[错误] %s: %v\n", url, err)
return
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
elapsed := time.Since(start)
fmt.Printf("[完成] %s — 状态: %d, 大小: %d 字节, 耗时: %v\n",
url, resp.StatusCode, len(body), elapsed)
}
func main() {
urls := []string{
"https://httpbin.org/get",
"https://httpbin.org/delay/1",
"https://httpbin.org/delay/2",
"https://httpbin.org/status/200",
}
var wg sync.WaitGroup
start := time.Now()
for _, url := range urls {
wg.Add(1)
go fetchURL(url, &wg)
}
wg.Wait()
fmt.Printf("\n全部完成,总耗时: %v\n", time.Since(start))
// 总耗时约等于最慢请求的时间,而非所有请求之和
}
场景2:日志异步写入
GO
package main
import (
"fmt"
"time"
)
type LogEntry struct {
Level string
Message string
}
// logWriter 在后台 goroutine 中异步消费日志
func logWriter(entries <-chan LogEntry, done chan<- struct{}) {
for entry := range entries {
// 模拟写入文件或远程服务
time.Sleep(50 * time.Millisecond)
fmt.Printf("[%s] %s\n", entry.Level, entry.Message)
}
done <- struct{}{} // 通知主 goroutine 已完成
}
func main() {
logCh := make(chan LogEntry, 100) // 带缓冲 channel 作为日志队列
done := make(chan struct{})
// 启动后台日志写入 goroutine
go logWriter(logCh, done)
// 主程序正常执行,异步记录日志
for i := 1; i <= 5; i++ {
logCh <- LogEntry{
Level: "INFO",
Message: fmt.Sprintf("处理请求 #%d", i),
}
fmt.Printf("已提交日志 #%d\n", i)
}
close(logCh) // 关闭 channel 通知写入器退出
<-done // 等待写入器完成
fmt.Println("程序退出")
}
❓ 常见问题
1. 为什么我的 goroutine 没有执行?
主 goroutine 退出时会强制终止所有子 goroutine。常见原因:
GO
// ❌ 错误:主 goroutine 直接退出,子 goroutine 没机会运行
func main() {
go fmt.Println("你好")
}
// ✅ 正确:使用 WaitGroup 等待
func main() {
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
fmt.Println("你好")
}()
wg.Wait()
}
2. Goroutine 什么时候会泄漏?
Goroutine 泄漏指 goroutine 永远无法退出,持续占用内存和调度资源。常见原因:
GO
// ❌ 泄漏:channel 永远没有接收者
func leaky() <-chan int {
ch := make(chan int)
go func() {
ch <- 42 // 永久阻塞,goroutine 无法退出
}()
return ch
}
// ✅ 安全:使用带缓冲的 channel 或 context 取消
func safe() <-chan int {
ch := make(chan int, 1) // 缓冲为 1,发送不会阻塞
go func() {
ch <- 42
}()
return ch
}
3. 如何避免闭包捕获循环变量的经典陷阱?
GO
// ❌ Go 1.21 及之前:所有 goroutine 可能打印相同的值
for i := 0; i < 5; i++ {
go func() {
fmt.Println(i) // 可能全部打印 5
}()
}
// ✅ 推荐写法:显式传参,兼容所有 Go 版本
for i := 0; i < 5; i++ {
go func(n int) {
fmt.Println(n) // 正确打印 0, 1, 2, 3, 4
}(i)
}
4. 能否获取当前运行的 goroutine 数量?
GO
import "runtime"
fmt.Println("当前 goroutine 数量:", runtime.NumGoroutine())
这在调试 goroutine 泄漏时非常有用。正常程序空闲时通常只有 1-2 个 goroutine,如果数字持续增长则可能存在泄漏。
📖 小节
| 概念 | 说明 |
|---|---|
go 关键字 |
启动一个 goroutine,语法为 go func() |
| 调度模型 | G-M-P 模型,Go 运行时负责调度,对用户透明 |
| 轻量级 | 初始栈约 2KB,可创建数十万个 |
| WaitGroup | 用于等待一组 goroutine 全部完成 |
| Context | 用于传递取消信号和超时控制 |
| 泄漏防范 | 确保每个 goroutine 都有退出路径 |
核心原则:
- 始终确保 goroutine 有明确的退出条件。
- 用
sync.WaitGroup或 channel 同步主 goroutine 与子 goroutine。 - 用
context控制 goroutine 的生命周期,防止泄漏。 - 注意闭包与循环变量的交互(推荐显式传参)。
📝 作业
练习1:并发质数判断
编写程序,使用 goroutine 并发判断一组数字是否为质数,最后汇总输出结果。
GO
// 提示:
// - 定义 isPrime(n int) bool 函数
// - 为每个数字启动一个 goroutine
// - 使用 WaitGroup 等待全部完成
// - 用互斥锁或 channel 收集结果
练习2:并发下载模拟器
模拟并发下载文件:启动 3 个 worker goroutine 从任务队列中取任务执行,每个下载随机耗时 100-1000ms,统计总耗时和各 worker 完成的任务数。
GO
// 提示:
// - 使用 channel 作为任务队列
// - 每个 worker 从 channel 中读取任务
// - 使用 context.WithTimeout 控制总超时
// - 用 sync.Map 或互斥锁统计每个 worker 的完成数
练习3:Goroutine 泄漏检测器
编写一个工具函数,接受一个任务函数作为参数,执行该函数并监控 goroutine 数量。如果任务完成后 goroutine 数量比执行前多,说明可能存在泄漏,输出警告。
GO
// 提示:
// - 使用 runtime.NumGoroutine() 获取 goroutine 数量
// - 任务执行前后各记录一次数量
// - 执行后等待一小段时间(如 100ms)再检查
// - 输出检测结果



