Goroutine:Go 的并发魔法
如果你问我 Go 语言最酷的特性是什么,我会毫不犹豫地回答:goroutine。
在当今这个多核 CPU 普及的时代,并发编程已经不是什么新鲜事了。但传统的并发模型——不管是 Java 的线程、Python 的多进程,还是 Node.js 的事件循环——都有各自的痛点:要么太重、要么太复杂、要么性能不够。
Go 语言另辟蹊径,提出了 goroutine 这个概念。它是一种轻量级的线程,由 Go 运行时管理,而不是操作系统。你可以轻松启动成千上万个 goroutine,而不用担心系统资源耗尽。
今天我们就来揭开 goroutine 的神秘面纱,看看它到底是怎么工作的,以及如何正确使用它。
什么是并发?为什么需要它?
在深入 goroutine 之前,我们先聊聊并发编程的基本概念。
并发 vs 并行
很多人把这两个概念搞混了,其实它们是不同的:
- 并发(Concurrency):多个任务在同一时间段内交替执行。单核 CPU 也能实现并发。
- 并行(Parallelism):多个任务在同一时刻同时执行。需要多核 CPU。
打个比方:
- 并发:一个厨师同时做三道菜,先切 A 菜的菜,然后去炒 B 菜,再回来炒 A 菜。三道菜在同一时间段内都在"进行中",但同一时刻厨师只做一件事。
- 并行:三个厨师同时做三道菜,每道菜都有一个专门的厨师在做。三道菜在同一时刻都在"被制作"。
Go 的 goroutine 支持并发,在多核 CPU 上也能实现并行。
为什么需要并发?
- 提高性能:充分利用多核 CPU,同时处理多个任务
- 提升响应性:不让耗时操作阻塞主流程
- 简化模型:把复杂的异步操作拆分成多个独立的逻辑单元
举个例子:一个 Web 服务器需要同时处理多个用户的请求。如果没有并发,服务器只能一个接一个地处理请求,后面的用户就得排队等待。有了并发,服务器可以同时处理多个请求,用户体验就好多了。
第一个 Goroutine
让我们从一个最简单的例子开始:
package main
import (
"fmt"
"time"
)
func sayHello() {
fmt.Println("Hello from goroutine!")
}
func main() {
// 启动一个 goroutine
go sayHello()
// 主函数继续执行
fmt.Println("Hello from main!")
// 等待一下,让 goroutine 有机会执行
time.Sleep(100 * time.Millisecond)
}
就这么简单!在函数调用前加上 go 关键字,这个函数就会在一个新的 goroutine 中异步执行。
运行结果可能是:
Hello from main!
Hello from goroutine!
或者:
Hello from goroutine!
Hello from main!
顺序是不确定的,因为两个 goroutine 是并发执行的。
⚠️ 注意:我们在最后加了 time.Sleep(100 * time.Millisecond)。如果不加这个,程序可能在 goroutine 还没执行完就退出了,你就看不到 “Hello from goroutine!” 的输出。
Goroutine 的生命周期
启动 goroutine
启动 goroutine 有两种方式:
方式一:调用函数
go functionName(args)
方式二:匿名函数
go func() {
// 做一些事情
}()
主 goroutine 退出
重要:当 main() 函数返回时,所有的 goroutine 都会被强制终止,不管它们是否执行完毕。
package main
import (
"fmt"
"time"
)
func worker(id int) {
for i := 0; i < 5; i++ {
fmt.Printf("Worker %d: working... %d\n", id, i)
time.Sleep(100 * time.Millisecond)
}
}
func main() {
go worker(1)
go worker(2)
fmt.Println("Main: I'm done!")
// 没有等待,直接退出
}
运行结果:
Main: I'm done!
Worker 根本没有机会执行!因为 main 函数一返回,整个程序就结束了。
如何等待 goroutine?
这是我们遇到的第一个实际问题。目前我们用的是 time.Sleep(),但这显然不是个好办法——你很难准确估计要等多久。
后面我们会学习更好的方式:
- Channel:通过通道同步
- WaitGroup:专门的同步工具
- Context:带超时的等待
现在先用 time.Sleep() 凑合一下,后面会详细介绍这些工具。
Goroutine 的轻量级特性
Goroutine 和操作系统线程最大的区别在于轻量级。
栈大小对比
- 操作系统线程:初始栈大小通常是 1-8 MB(Linux 默认 8 MB)
- Goroutine:初始栈大小只有 2 KB!
这意味着你可以在同样的内存中启动多得多的 goroutine。
栈的动态增长
Goroutine 的栈不是固定的,它会根据需要动态增长和缩小。这得益于 Go 运行时的栈分段(segmented stacks)技术。
package main
import (
"fmt"
"runtime"
)
func recursive(depth int) {
if depth > 10000 {
return
}
recursive(depth + 1)
}
func main() {
fmt.Println("当前 goroutine 数量:", runtime.NumGoroutine())
for i := 0; i < 1000; i++ {
go recursive(0)
}
// 等待 goroutine 启动
runtime.Gosched()
fmt.Println("启动后 goroutine 数量:", runtime.NumGoroutine())
}
这段代码启动了 1000 个 goroutine,每个都执行深度为 10000 的递归。如果是操作系统线程,早就因为栈溢出崩溃了。但 goroutine 可以轻松应对。
启动成本
启动一个 goroutine 的成本非常低:
- 内存:2 KB 初始栈
- CPU:几百条指令
- 时间:微秒级
相比之下,启动一个操作系统线程的成本要高得多:
- 内存:1-8 MB 栈
- CPU:数千条指令
- 时间:毫秒级
这就是为什么你可以轻松启动成千上万个 goroutine,而启动同样数量的线程会让系统崩溃。
实战:多任务下载器
让我们用 goroutine 写一个实用的程序——并发下载多个文件:
package main
import (
"fmt"
"math/rand"
"time"
)
// simulateDownload 模拟下载文件
func simulateDownload(filename string, duration time.Duration) {
fmt.Printf("[%s] 开始下载...\n", filename)
time.Sleep(duration)
fmt.Printf("[%s] 下载完成!耗时: %v\n", filename, duration)
}
func main() {
// 模拟要下载的文件
files := []struct {
name string
duration time.Duration
}{
{"file1.zip", time.Duration(rand.Intn(3)+1) * time.Second},
{"file2.zip", time.Duration(rand.Intn(3)+1) * time.Second},
{"file3.zip", time.Duration(rand.Intn(3)+1) * time.Second},
{"file4.zip", time.Duration(rand.Intn(3)+1) * time.Second},
}
start := time.Now()
// 启动所有下载任务
for _, file := range files {
go simulateDownload(file.name, file.duration)
}
// 等待所有下载完成(粗略的方式)
time.Sleep(4 * time.Second)
elapsed := time.Since(start)
fmt.Printf("\n总耗时: %v\n", elapsed)
}
运行结果类似:
[file1.zip] 开始下载...
[file2.zip] 开始下载...
[file3.zip] 开始下载...
[file4.zip] 开始下载...
[file2.zip] 下载完成!耗时: 1s
[file1.zip] 下载完成!耗时: 2s
[file4.zip] 下载完成!耗时: 2s
[file3.zip] 下载完成!耗时: 3s
总耗时: 4.001s
看到了吗?虽然每个文件下载需要 1-3 秒,但 4 个文件同时下载,总耗时只有约 4 秒(最长的下载时间 + 一些开销),而不是串行下载的 1+2+2+3=8 秒。
这就是并发的力量!
Goroutine 调度器
Goroutine 的执行是由 Go 运行时的调度器管理的。了解调度器的工作原理,有助于你写出更高效的并发代码。
M:N 调度模型
Go 使用的是 M:N 调度模型:
- M(Machine):操作系统线程
- N(Goroutine):用户态的 goroutine
Go 调度器会把 N 个 goroutine 调度到 M 个操作系统线程上执行。默认情况下,M 的数量等于 CPU 核心数。
package main
import (
"fmt"
"runtime"
)
func main() {
// 查看当前可用的 CPU 核心数
fmt.Println("CPU 核心数:", runtime.NumCPU())
// 查看当前使用的线程数
fmt.Println("GOMAXPROCS:", runtime.GOMAXPROCS(0))
// 可以手动设置使用的线程数
// runtime.GOMAXPROCS(4) // 使用 4 个线程
}
抢占式调度
在 Go 1.14 之前,goroutine 使用的是协作式调度——goroutine 必须主动让出 CPU(比如调用 time.Sleep()、I/O 操作、channel 操作等),否则它会一直占用线程。
从 Go 1.14 开始,Go 引入了抢占式调度——调度器可以强制中断一个运行中的 goroutine,即使它没有主动让出 CPU。这解决了"一个 goroutine 死循环占用整个线程"的问题。
runtime.Gosched()
你可以手动让当前 goroutine 让出 CPU,给其他 goroutine 执行的机会:
package main
import (
"fmt"
"runtime"
)
func worker(id int) {
for i := 0; i < 3; i++ {
fmt.Printf("Worker %d: iteration %d\n", id, i)
runtime.Gosched() // 主动让出 CPU
}
}
func main() {
go worker(1)
go worker(2)
// 主 goroutine 也让出 CPU
for i := 0; i < 3; i++ {
fmt.Printf("Main: iteration %d\n", i)
runtime.Gosched()
}
}
输出结果会交替显示各个 goroutine 的执行情况。
Goroutine 泄漏
Goroutine 泄漏是 Go 程序中常见的 bug。它指的是一个 goroutine 因为某种原因永远无法退出,一直占用资源。
常见的泄漏原因
1. 等待永远不会发生的 channel 操作
func leaky() {
ch := make(chan int)
go func() {
// 这个 goroutine 永远不会收到数据
// 因为它等待的 channel 永远不会被发送
value := <-ch
fmt.Println(value)
}()
// 忘记发送数据就返回了
// ch <- 42
}
2. 无限循环
func leaky() {
go func() {
for {
// 没有退出条件的死循环
time.Sleep(time.Second)
}
}()
}
3. 阻塞在 I/O 操作
func leaky() {
go func() {
conn, _ := net.Dial("tcp", "example.com:80")
// 如果连接一直不关闭,这个 goroutine 就泄漏了
defer conn.Close()
buf := make([]byte, 1024)
for {
_, err := conn.Read(buf)
if err != nil {
return
}
}
}()
}
如何避免泄漏?
- 使用 context:给 goroutine 设置超时或取消机制
- 使用 select:不要无限期等待
- 确保有退出条件:每个 goroutine 都应该有明确的退出路径
- 监控 goroutine 数量:使用
runtime.NumGoroutine()检测泄漏
package main
import (
"context"
"fmt"
"runtime"
"time"
)
func worker(ctx context.Context) {
for {
select {
case <-ctx.Done():
fmt.Println("Worker: 收到取消信号,退出")
return
default:
fmt.Println("Worker: 工作中...")
time.Sleep(500 * time.Millisecond)
}
}
}
func main() {
fmt.Println("开始时 goroutine 数量:", runtime.NumGoroutine())
// 创建一个 2 秒后取消的 context
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
go worker(ctx)
time.Sleep(3 * time.Second)
fmt.Println("结束时 goroutine 数量:", runtime.NumGoroutine())
}
Goroutine 和闭包
在循环中启动 goroutine 时,要特别小心闭包的问题:
// ❌ 错误示例
for i := 0; i < 5; i++ {
go func() {
fmt.Println(i) // 所有 goroutine 共享同一个 i
}()
}
time.Sleep(100 * time.Millisecond)
// 输出:5 5 5 5 5(或者其他的 5)
所有 goroutine 都捕获了同一个变量 i。当它们执行时,循环已经结束了,i 的值已经是 5。
// ✅ 正确方式一:通过参数传递
for i := 0; i < 5; i++ {
go func(n int) {
fmt.Println(n) // 每个 goroutine 有自己的 n
}(i)
}
// ✅ 正确方式二:在循环内创建局部变量
for i := 0; i < 5; i++ {
i := i // 创建新的局部变量
go func() {
fmt.Println(i)
}()
}
time.Sleep(100 * time.Millisecond)
// 输出:0 1 2 3 4(顺序不确定)
性能考量
什么时候该用 goroutine?
✅ 适合的场景:
- I/O 密集型操作(网络请求、文件读写、数据库查询)
- 独立的计算任务
- 事件处理和消息传递
- 后台任务和定时任务
❌ 不适合的场景:
- CPU 密集型的纯计算(应该用 worker pool)
- 需要严格顺序执行的任务
- 启动成本大于任务本身的轻量操作
Goroutine 池
如果你需要处理大量任务,但又不想启动太多 goroutine,可以使用 goroutine 池:
package main
import (
"fmt"
"sync"
"time"
)
func worker(id int, jobs <-chan int, wg *sync.WaitGroup) {
defer wg.Done()
for job := range jobs {
fmt.Printf("Worker %d: processing job %d\n", id, job)
time.Sleep(100 * time.Millisecond)
}
}
func main() {
const numWorkers = 3
const numJobs = 10
jobs := make(chan int, numJobs)
var wg sync.WaitGroup
// 启动 worker 池
for i := 1; i <= numWorkers; i++ {
wg.Add(1)
go worker(i, jobs, &wg)
}
// 发送任务
for j := 1; j <= numJobs; j++ {
jobs <- j
}
close(jobs)
// 等待所有任务完成
wg.Wait()
fmt.Println("所有任务完成!")
}
这个模式限制了同时运行的 goroutine 数量,避免资源耗尽。
小结
今天我们深入学习了 Go 语言的 goroutine:
- 什么是 goroutine:Go 运行时的轻量级线程,初始栈只有 2 KB
- 启动方式:
go func()或go functionName() - 生命周期:main 函数退出时所有 goroutine 强制终止
- 轻量级特性:启动成本低,可以启动成千上万个
- 调度器:M:N 模型,Go 1.14 后支持抢占式调度
- Goroutine 泄漏:常见原因和避免方法
- 闭包陷阱:循环中的变量捕获问题
- 性能考量:适合 I/O 密集型任务,不适合纯计算
- Goroutine 池:控制并发数量的模式
Goroutine 是 Go 并发编程的基础,但它只是一个开始。要写出正确的并发程序,你还需要学习 channel(通道)——这是 goroutine 之间通信的桥梁。
下一篇文章,我们就来学习 channel!
练习时间
- 并行计算:写一个程序,用多个 goroutine 并行计算 1 到 1000000 的平方和
- 并发下载:修改多任务下载器,让它能够处理任意数量的文件
- Goroutine 泄漏检测:写一个程序,启动一些可能泄漏的 goroutine,用
runtime.NumGoroutine()检测 - 生产者-消费者:用 goroutine 实现一个简单的生产者-消费者模型(暂时用 sleep 同步)
- 性能测试:对比串行和并发执行 100 个任务的耗时差异
下一篇预告
下一篇文章,我们将学习 Channel(通道)——goroutine 之间通信的桥梁。Channel 是 Go 并发编程的核心,它会让你真正理解 Go 的并发哲学:“不要通过共享内存来通信,而要通过通信来共享内存。”
我们下篇见!👋
参考资料:
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。