5.5 KiB
tags, create time
| tags | create time | |||||
|---|---|---|---|---|---|---|
|
2026-06-07 14:35 |
Goroutine
概述
Goroutine 是 Go 语言对协程的原生支持,以极低的启动成本(约 2KB 栈)和灵活的栈扩容机制,让开发者可以轻易创建成千上万的并发任务。本文讲解 goroutine 的基本用法、生命周期管理以及最佳实践。
正文
为什么需要 Goroutine?
[!question] 💭 思考 假设有 1000 个独立的 HTTP 请求要同时发出,用传统线程模型你会怎么做?
在 C++/Java 等传统语言中,线程栈大小通常为 2MB,且创建/销毁涉及系统调用,开销较大。因此这些语言通常使用线程池来复用线程。
Go 的 Goroutine 则完全不同:
| 对比项 | OS 线程 | Go Goroutine |
|---|---|---|
| 初始栈大小 | ~2MB | ~2KB |
| 最大栈大小 | 受限于系统内存 | 可达 1GB(动态扩容) |
| 创建方式 | 系统调用 | 用户态调度 |
| 典型并发量 | 数百 | 数十万 |
flowchart TD
A["main goroutine"] -->|"go func()"| B["goroutine 1"]
A -->|"go func()"| C["goroutine 2"]
A -->|"go func()"| D["goroutine n..."]
subgraph Runtime["Go Runtime GMP 调度器"]
G["Goroutine 队列"]
M["M: OS Thread"]
P["P: Processor"]
G --> M
P --> M
end
B --> Runtime
C --> Runtime
D --> Runtime
[!info] ℹ️ 补充 Goroutine 由 Go 运行时通过 GMP 调度模型管理——G(goroutine)、M(OS thread)、P(processor)。你只需
go关键字即可交给运行时调度,无需关心底层细节。详见 hzh/GolangStar/Go语言原理/gmp调度原理。
基本用法
开启一个 goroutine 只需要在函数调用前加 go 关键字:
func task(name string) {
fmt.Printf("%s running\n", name)
}
func main() {
go task("worker") // 异步执行,不阻塞主流程
fmt.Println("main continues immediately")
}
[!warning] ⚠️ 关键陷阱:主协程退出 = 全部终止 当
main函数返回时,程序立即结束——所有其他 goroutine 无论是否执行完都会被强制终止。这与 Java/C++ 中后台线程继续运行的行为不同。
package main
import "fmt"
func myTask() {
fmt.Println("子协程输出")
}
func main() {
go myTask()
fmt.Println("end!!!")
// ❌ 这里如果没有等待,myTask 很可能来不及执行
}
// 可能只输出: end!!!
上面的代码大概率只打印 "end!!!",因为主协程执行完就退出了,子协程还没来得及运行。正确做法是使用同步原语等待:
package main
import (
"fmt"
"sync"
)
func myTask(name string, wg *sync.WaitGroup) {
defer wg.Done() // 标记本 goroutine 完成
fmt.Printf("%s running\n", name)
}
func main() {
var wg sync.WaitGroup
wg.Add(2)
go myTask("goroutine1", &wg)
go myTask("goroutine2", &wg)
wg.Wait() // 阻塞直到计数器归零
fmt.Println("all done")
}
[!tip] 💡 最佳实践
- 始终使用
sync.WaitGroup/ channel / context 来管理 goroutine 生命周期,而非time.Sleepdefer wg.Done()放在 goroutine 入口第一行,确保即使 panic 也能正确递减计数器
竞态条件与数据竞争
[!question] 💭 思考 多个 goroutine 同时读写同一个变量会发生什么?就像两个人同时修改同一张纸上的数字。
当两个或多个 goroutine 同时访问同一块内存,且至少有一个是写操作时,就会发生数据竞争(Data Race)。这是 Go 中最常见的并发 bug 之一。
var counter int
func increment() {
counter++ // ❌ 危险!多个 goroutine 同时读写 counter
}
func main() {
for i := 0; i < 1000; i++ {
go increment()
}
time.Sleep(time.Second)
fmt.Println(counter) // 结果不确定!
}
检测数据竞争:Go 提供了内置工具——编译时加上 -race 标志即可启用 race detector:
go run -race main.go
go test -race ./...
解决方式见 hzh/GolangStar/Go语言进阶/Sync(Mutex / Atomic / Channel)。
Goroutine Leak
[!warning] ⚠️ 隐形杀手:Goroutine Leak 如果一个 goroutine 永远无法结束(比如等待永远不会到来的 channel 消息),它就会一直驻留在内存中——这就是 goroutine leak。它比内存泄漏更难发现,因为 GC 不会回收仍在活跃的 goroutine。
常见泄漏场景:
// 场景1:channel 未关闭,接收方永久阻塞
func reader(ch <-chan int) {
v := <-ch // 如果 ch 永远不会关闭或写入,这里永久阻塞
}
// 场景2:select 中没有 default,且所有 case 都无响应
func selectLoop() {
ch := make(chan int)
select {
case v := <-ch: // 无 goroutine 往 ch 写数据
fmt.Println(v)
} // 死锁
}
// 场景3:超时未设置
func fetchWithTimeout() {
result := make(chan string, 1)
go func() {
result <- doSlowWork() // 可能永远不返回
}()
// ❌ 没有超时机制,goroutine 可能永远挂起
<-result
}
[!tip] 💡 避免泄漏的 checklist
- 每个发送端应在合适时机调用
close(ch)- 使用
context.WithTimeout为所有外部调用设置超时- 在 select 中使用
default实现非阻塞检查- 使用
goleak库在测试中检测泄漏:go.uber.org/goleak