vault backup: 2026-06-07 12:14:39
This commit is contained in:
@@ -1,112 +1,194 @@
|
||||
---
|
||||
tags:
|
||||
- Go
|
||||
- golang
|
||||
- go进阶语法
|
||||
- Goroutine
|
||||
tags: [go, golang, Goroutine, 并发, CSP]
|
||||
create time: 2026-06-07 14:35
|
||||
---
|
||||
|
||||
# Goroutine
|
||||
`goroutine`是Go语言对于协程的支持,可以把它理解为Go语言的协程。这是一个Go语言并发编程的终极杀器,它让我们的并发编程变得简单。
|
||||
|
||||
Go语言的并发只会用到`goroutine`,并不需要我们去考虑用多进程或者是多线程。有过C++或者Java经验的同学可能知道,线程本身是有一定大小的,一般OS线程栈大小为2MB,且线程在创建和上下文切换的时候是需要消耗资源的,会带来性能损耗,所以在我们用到多线程技术的时候,我们往往会通过池化技术,即创建线程池来管理一定数量的线程。
|
||||
在Go语言中,一个`goroutine`栈在其生命周期开始时占用空间很小(一般2KB),并且栈大小可以按需增大和缩小,`goroutine`的栈大小限制可以达到1GB,但是一般不会用到这么大。所以在Go语言中一次创建成千上万,甚至十万左右的`goroutine`理论上也是可以的。
|
||||
在Go语言中,我们用多`goroutine`来完成并发,在某个任务需要并发执行的时候,只需要把这个任务包装成一个函数,开启一个`goroutine`去执行这个函数就可以了。并不需要我们来维护一个类似于线程池的东西,也不需要我们去关心协程是怎么切换和调度的,因为这些都已经有Go语言内置的调度器帮我们做了,并且效率还非常高。
|
||||
## 概述
|
||||
|
||||
## Goroutine使用
|
||||
`goroutine`使用起来非常方便,通常我们会将需要并发的任务封装成一个函数,然后再该函数前加上`go`关键字就行了,这样就开启了一个`goroutine`。
|
||||
```go
|
||||
func()
|
||||
go func() // 会并发执行这个函数
|
||||
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
|
||||
```
|
||||
|
||||
## 主协程
|
||||
和其它语言一样,Go程序的入口也是`main`函数。在程序开始执行的时候,Go程序会为`main`函数创建一个默认的`goroutine`,我们称之为主协程,我们后来人为的创建的一些`goroutine`,都是在这个主`goroutine`的基础上进行的。
|
||||
下面请看个例子:
|
||||
> [!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 myGroutine() {
|
||||
fmt.Println("myGroutine")
|
||||
func myTask() {
|
||||
fmt.Println("子协程输出")
|
||||
}
|
||||
|
||||
func main() {
|
||||
go myGroutine()
|
||||
fmt.Println("end!!!")
|
||||
go myTask()
|
||||
fmt.Println("end!!!")
|
||||
// ❌ 这里如果没有等待,myTask 很可能来不及执行
|
||||
}
|
||||
// 可能只输出: end!!!
|
||||
```
|
||||
运行结果:
|
||||
```
|
||||
end!!!
|
||||
myGroutine
|
||||
```
|
||||
很奇怪,明明是多协程任务,为什么只打印了主协程里的"end!!!",而没有打印我们开启的协程里的输出"myGroutine",按理不是应该都打印出来吗?
|
||||
这是因为:当`main`函数返回的时候该`goroutine`就结束了,当主协程退出的时候,其他剩余的`goroutine`不管是否运行完,都会跟着结束。所以,这里主协程打印完"end!!!"之后就退出了,`myGroutine`协程可能还没运行到`fmt.Println("myGroutine")`语句也跟着退出了。
|
||||
接下来我们让主`goroutine`执行完`fmt.Println("end!!!")`之后不立刻退出,而是等待2s,看一下运行结果:
|
||||
|
||||
上面的代码大概率只打印 `"end!!!"`,因为主协程执行完就退出了,子协程还没来得及运行。**正确做法是使用同步原语等待**:
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
func myGroutine() {
|
||||
fmt.Println("myGroutine")
|
||||
}
|
||||
|
||||
func main() {
|
||||
go myGroutine()
|
||||
fmt.Println("end!!!")
|
||||
time.Sleep(2*time.Second)
|
||||
}
|
||||
```
|
||||
运行结果:
|
||||
```
|
||||
end!!!
|
||||
myGroutine
|
||||
```
|
||||
此时打印出了我们想要的结果,这里我们通过让主协程睡眠2s来等待子协程执行完了之后再退出,后面我们会学习到更好的方法,这里就不再过多阐述。
|
||||
|
||||
## 多协程调用
|
||||
在Go语言中,我们可以通过`go`关键字来开启多个协程,每个协程可以并发执行,互不干扰。
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
"fmt"
|
||||
"sync"
|
||||
)
|
||||
|
||||
func myGoroutine(name string, wg *sync.WaitGroup) {
|
||||
defer wg.Done()
|
||||
|
||||
for i := 0; i < 5; i++ {
|
||||
fmt.Printf("myGroutine %s\n", name)
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
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)
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(2)
|
||||
|
||||
go myGoroutine("goroutine1", &wg)
|
||||
go myGoroutine("goroutine2", &wg)
|
||||
go myTask("goroutine1", &wg)
|
||||
go myTask("goroutine2", &wg)
|
||||
|
||||
wg.Wait()
|
||||
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) // 结果不确定!
|
||||
}
|
||||
```
|
||||
myGroutine goroutine1
|
||||
myGroutine goroutine2
|
||||
|
||||
检测数据竞争:Go 提供了内置工具——编译时加上 `-race` 标志即可启用 race detector:
|
||||
|
||||
```bash
|
||||
go run -race main.go
|
||||
go test -race ./...
|
||||
```
|
||||
从结果中可以看到,两个协程并发执行,互不干扰。注意在上述例子中,我们使用了`sync.WaitGroup`来等待所有协程执行完毕之后再退出。关于`sync.WaitGroup`的详细介绍,可以参考[sync.WaitGroup](https://pkg.go.dev/sync#WaitGroup)。后续也会在`sync`章节详细介绍。
|
||||
|
||||
解决方式见 [[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语言进阶/并发概述]]
|
||||
|
||||
Reference in New Issue
Block a user