4.3 学习 Go 协程:goroutine
说到 Go 语言,许多人对它的第一印象就是从语言层面原生支持并发,这使得开发者可以非常方便地编写高性能且易于理解的程序。
在 Python 等主流编程语言中,并发编程的门槛较高。你需要学习多进程、多线程的概念,还要熟悉各种支持并发的库,比如 asyncio、aiohttp 等,同时要了解它们之间的区别和优缺点,能够根据场景选择合适的并发模式。
而 Golang 作为一门现代化的编程语言,这些复杂的问题已经被大大简化。在 Golang 中,开发者无需关心进程池/线程池的创建,也无需考虑多线程和多进程的具体应用场景。它原生提供了非常优秀的 goroutine(协程)机制,能够自动高效地处理并发任务,你只需简单地调用即可。
一、Goroutine 是什么?
Goroutine 是 Go 语言并发的执行单元。你可以简单地将其视为一个轻量级的线程。
在 Go 语言中,每一个并发的执行单元都称为 Goroutine (协程)。
想象一下您的程序中有两个独立的功能函数,一个负责复杂的计算任务,另一个负责将结果输出。在一个传统的、线性的程序中,这两个函数必须依次执行——先完成计算,再进行输出。
而当您使用 Goroutine 时,这两个函数可以同时开始、独立运行。Go 语言的运行时(Runtime)会自动调度它们,让您能够轻松地实现并发执行。
1. Goroutine vs 操作系统线程
| 特性 | Goroutine (协程) | Thread (操作系统线程) |
|---|---|---|
| 内存占用 | 极小,初始栈空间仅 几KB (通常 2KB),可动态伸缩。 | 较大,初始栈空间通常为 1MB 或更大。 |
| 创建开销 | 极低,只需要很少的 CPU 时间。 | 较高,涉及到操作系统内核的调度。 |
| 调度 | 由 Go 语言的 运行时(Runtime) 负责调度(M:N 调度模型)。 | 由操作系统内核负责调度。 |
| 数量 | 可轻松创建数十万个 Goroutine。 | 数量有限,创建过多会耗尽系统资源。 |
总结: Goroutine 相比传统线程更加轻量、高效,使得 Go 语言能以极低的成本实现大规模并发。
2. 并发 (Concurrency) 与并行 (Parallelism)
- 并发 (Concurrency): 指的是处理多个任务的能力。宏观上看起来是同时在做,但微观上可能在单核 CPU 上通过时间片切换轮流执行。Go 语言的设计目标是实现并发。
- 并行 (Parallelism): 指的是同时做多个任务的能力。这需要多个 CPU 核心同时运行多个任务。
Go Runtime: Go 语言的运行时会自动将 Goroutine 高效地映射到少量的操作系统线程上,通过调度器实现并发,如果有多核 CPU 也会自动实现并行。
二、如何启动 Goroutine
启动 Goroutine 非常简单,只需在一个函数调用前加上 go 关键字。
一个 goroutine 本身就是一个函数,当你直接调用时,它就是一个普通函数,如果你在调用前加一个关键字 go ,那你就开启了一个 goroutine。
// 执行一个函数
func()
// 开启一个协程执行这个函数
go func()1. 主协程与子协程
一个 Go 程序的入口通常是 main 函数,程序启动后,main 函数最先运行,我们称之为 main Goroutine (主协程)。
在 main 中或者其下调用的代码中才可以使用 go + func() 的方法来启动子协程。
main 的地位相当于主线程,当 main 函数执行完成后,这个线程也就终结了,其下运行着的所有协程也不管代码是不是还在跑,也得乖乖退出。
示例:
package main
import "fmt"
func mytest() {
fmt.Println("hello, go")
}
func main() {
// 启动一个协程
go mytest()
fmt.Println("hello, world")
}这段代码运行完,通常只会输出 hello, world,而不会输出 hello, go。因为协程的创建需要时间,当 hello, world 打印后,main 函数结束,程序退出,协程还没来得及执行。
为了让大家看到效果,我们可以临时使用 time.Sleep 来阻塞 main 函数(虽然这在实际开发中不推荐,但适合演示):
package main
import (
"fmt"
"time"
)
func mytest() {
fmt.Println("hello, go")
}
func main() {
go mytest()
fmt.Println("hello, world")
time.Sleep(time.Second) // 等待 1 秒
}输出如下:
hello, world
hello, go2. 多个协程并发执行的效果
为了让你看到并发的效果,这里举个最简单的例子:
package main
import (
"fmt"
"time"
)
func mygo(name string) {
for i := 0; i < 5; i++ {
fmt.Printf("In goroutine %s\n", name)
// 为了避免第一个协程执行过快,观察不到并发的效果,加个休眠
time.Sleep(10 * time.Millisecond)
}
}
func main() {
go mygo("协程1号") // 第一个协程
go mygo("协程2号") // 第二个协程
// 同样需要等待子协程执行完成
time.Sleep(time.Second)
}输出如下(顺序可能不同),可以观察到两个协程交替执行:
In goroutine 协程2号
In goroutine 协程1号
In goroutine 协程1号
In goroutine 协程2号
In goroutine 协程2号
In goroutine 协程1号
...3. 匿名函数与参数传递
Goroutine 也可以通过匿名函数启动,通常用于需要传递参数或访问闭包变量的场景。
package main
import (
"fmt"
"time"
)
func main() {
for i := 0; i < 5; i++ {
// ✅ 正确做法:将 i 作为参数传递给匿名函数,保证 Goroutine 捕获到当前 i 的值
go func(j int) {
fmt.Printf("Goroutine %d: 正在执行...\n", j)
}(i) // 立即调用匿名函数并传入 i 的值
}
time.Sleep(100 * time.Millisecond)
fmt.Println("所有 Goroutine 启动完毕。")
}重要提示: 在实际项目中,绝对不能依赖
time.Sleep来等待 Goroutine 完成。更优雅且推荐的方式是使用sync.WaitGroup或 Channel 来进行同步。
三、Goroutine 的生命周期
一个 Goroutine 的生命周期从 go 语句开始,并在以下情况之一结束:
- 函数自然返回: Goroutine 内部执行的函数正常执行完毕,返回。
- 发生未捕获的
panic: 如果 Goroutine 发生panic且没有在自身的defer中被recover捕获,则该 Goroutine 终止,并导致整个程序崩溃。 - 主 Goroutine 结束: 当
main函数返回时,程序会强制终止所有正在运行的 Goroutine。
Goroutine 的 Panic 处理示例
package main
import (
"fmt"
"time"
)
func safeworker(id int) {
// 使用 defer 和 recover 捕获 panic
defer func() {
if r := recover(); r != nil {
fmt.Printf("Goroutine %d: 捕获到 panic: %v\n", id, r)
}
}()
fmt.Printf("Goroutine %d: 开始工作\n", id)
if id == 2 {
panic("模拟错误") // 故意触发 panic
}
time.Sleep(500 * time.Millisecond)
fmt.Printf("Goroutine %d: 正常完成\n", id)
}
func main() {
// 启动安全的 goroutine(有 panic 处理)
for i := 1; i <= 3; i++ {
go safeworker(i)
}
time.Sleep(1 * time.Second)
fmt.Println("所有安全的 Goroutine 处理完成")
}四、Go 调度器原理(GMP 模型)
Go 语言的调度器采用 GMP 模型,这是 Go 能够实现高性能并发的核心:
- G (Goroutine): 协程,用户态的轻量级线程,包含栈、指令指针等信息。
- M (Machine): 操作系统线程,真正执行计算的资源,由操作系统调度。
- P (Processor): 处理器(逻辑处理器),调度的上下文,维护了一个 Goroutine 队列。M 必须持有 P 才能执行 G。
Go 运行时会维护 GOMAXPROCS 个 P,通常默认为 CPU 核心数。
- 复用线程:避免频繁创建、销毁线程,而是复用线程。
- 利用多核:通过 M 和 P 的绑定,让多个线程同时工作。
- 抢占式调度:防止某个 Goroutine 长时间占用 CPU,导致其他 Goroutine 饿死。
- 全局队列与本地队列:P 维护本地队列,还有一个全局队列,平衡负载。
五、常见陷阱与最佳实践
1. 循环变量陷阱
在 for 循环中启动 Goroutine 时,如果直接使用循环变量,可能会导致所有 Goroutine 读取到同一个值(循环结束后的值)。
// ❌ 错误示例
for i := 0; i < 5; i++ {
go func() {
fmt.Println(i) // 可能全部打印 5
}()
}
// ✅ 正确示例 1:参数传递
for i := 0; i < 5; i++ {
go func(val int) {
fmt.Println(val)
}(i)
}
// ✅ 正确示例 2:局部变量拷贝
for i := 0; i < 5; i++ {
i := i // 影子变量
go func() {
fmt.Println(i)
}()
}2. Goroutine 泄漏
启动了 Goroutine 但没有正确的退出机制,会导致 Goroutine 永远在后台运行,占用内存和 CPU。
常见原因:
- 从 channel 接收数据,但 channel 永远没有数据且没关闭。
- 向无缓冲 channel 发送数据,但没有接收者。
- 死循环且没有退出条件。
解决: 使用 context 包来控制 Goroutine 的退出,或确保 channel 正确关闭。
六、Goroutine 的应用:素数分解
以下是一个使用协程解决计算密集型任务效率问题的例子:计算多个整数的素数分解。
package main
import (
"fmt"
"sync"
"time"
)
// factorize 对一个整数进行素数分解
func factorize(num int) []int {
factors := []int{}
for i := 2; i*i <= num; i++ {
time.Sleep(100 * time.Microsecond) // 模拟耗时任务
for num%i == 0 {
factors = append(factors, i)
num /= i
}
}
if num > 1 {
factors = append(factors, num)
}
return factors
}
func main() {
numbers := make([]int, 100) // 减少数量以便演示
for i := range numbers {
numbers[i] = i + 1
}
var wg sync.WaitGroup
start := time.Now()
// 创建一个 channel 用于接收素数分解结果
resultCh := make(chan []int, len(numbers))
for _, num := range numbers {
wg.Add(1)
go func(n int) {
defer wg.Done()
factors := factorize(n)
resultCh <- factors
}(num)
}
// 所有的 goroutine 完成后关闭 channel
go func() {
wg.Wait()
close(resultCh)
}()
// 读取结果
count := 0
for range resultCh {
count++
}
elapsed := time.Since(start)
fmt.Printf("Processed %d numbers. Total time with goroutines: %v\n", count, elapsed)
}七、总结
Goroutine 是 Go 语言并发编程的核心,掌握其正确使用方法对于编写高性能 Go 程序至关重要。
- 简单易用:一个
go关键字即可启动。 - 轻量高效:内存占用小,切换成本低。
- 需要同步:Goroutine 是异步执行的,主程序(
main)需要等待它们完成,通常使用sync.WaitGroup或 Channel。 - 注意安全:并发环境下要注意共享内存的安全问题(Data Race),并小心 Goroutine 泄漏。
本篇只介绍了协程的简单使用,真正的并发程序还是要结合 信道 (channel) 来实现。关于信道的内容,将在下一篇文章中介绍。