Files
cs-note/hzh/GolangStar/Go语言进阶/Goroutine.md
T

195 lines
5.5 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
tags: [go, golang, Goroutine, 并发, CSP]
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(动态扩容) |
| 创建方式 | 系统调用 | 用户态调度 |
| 典型并发量 | 数百 | 数十万 |
```mermaid
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` 关键字:
```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++ 中后台线程继续运行的行为不同。
```go
package main
import "fmt"
func myTask() {
fmt.Println("子协程输出")
}
func main() {
go myTask()
fmt.Println("end!!!")
// ❌ 这里如果没有等待,myTask 很可能来不及执行
}
// 可能只输出: end!!!
```
上面的代码大概率只打印 `"end!!!"`,因为主协程执行完就退出了,子协程还没来得及运行。**正确做法是使用同步原语等待**:
```go
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.Sleep`
> - `defer wg.Done()` 放在 goroutine 入口第一行,确保即使 panic 也能正确递减计数器
### 竞态条件与数据竞争
> [!question] 💭 思考
> 多个 goroutine 同时读写同一个变量会发生什么?就像两个人同时修改同一张纸上的数字。
当两个或多个 goroutine 同时访问同一块内存,且至少有一个是写操作时,就会发生**数据竞争(Data Race)**。这是 Go 中最常见的并发 bug 之一。
```go
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:
```bash
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。
常见泄漏场景:
```go
// 场景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`
## 关联笔记
- [[hzh/GolangStar/Go语言进阶/Channel]]
- [[hzh/GolangStar/Go语言进阶/Sync]]
- [[hzh/GolangStar/Go语言进阶/Context]]
- [[hzh/GolangStar/Go语言进阶/并发概述]]