第 20 课 - Goroutine 基础
学习目标
完成本课学习后,你将能够:
- 理解 Goroutine 的概念及其在 Go 并发中的作用。
- 掌握使用
go关键字启动 Goroutine 的方法。 - 明确区分 Goroutine 与传统线程/函数调用的区别。
- 初步了解 Goroutine 的调度机制(M:N 模型)。
- 识别并避免常见的 Goroutine 泄漏场景。
核心概念
1. 什么是 Goroutine?
想象一个车间(程序)原本只有你一个工人(主 Goroutine)。现在你有多项任务要同时进行,比如一边听歌一边写代码。你可以再雇佣几个“打工仔”(新的 Goroutine)来帮你分担任务。
- Goroutine 是由 Go 运行时管理的轻量级线程,可以理解为一个并发执行的函数调用。
- 与操作系统线程相比,创建 Goroutine 的开销极小(初始栈空间仅几KB),因此你可以轻松创建成千上万个。
2. 如何启动 Goroutine?
只需在函数调用前加上 go 关键字即可。
go functionName() // 启动一个新 Goroutine 执行 functionName
3. 调度机制 (GMP 模型简介)
Go 使用 M:N 调度模型,意味着 M 个 Goroutine 会被调度到 N 个操作系统线程上执行。
- G (Goroutine): 代表一个并发任务。
- M (Machine): 代表一个操作系统线程。
- P (Processor): 代表逻辑处理器,它持有本地 Goroutine 队列。 Go 调度器负责将 G 分配到 M 上,并在 P 的协调下高效执行,实现了真正的并行与高效的并发切换。
4. Goroutine vs. 函数调用
- 普通函数调用:同步、阻塞,调用者必须等待被调用函数执行完毕。
go启动的 Goroutine:异步、非阻塞,启动后立刻返回,新 Goroutine 与当前 Goroutine 并发执行。
代码示例
示例1:基础启动与观察
package main
import (
"fmt"
"time"
)
// 普通函数,模拟一个耗时任务
func printNumbers(name string) {
for i := 1; i <= 3; i++ {
fmt.Printf("%s: %d\n", name, i)
time.Sleep(100 * time.Millisecond) // 暂停一小会,模拟工作
}
}
func main() {
// 启动一个新的 Goroutine 执行 printNumbers
go printNumbers("Goroutine-One")
// 在主 Goroutine 中执行同样的函数
printNumbers("Main-Thread")
fmt.Println("Main function ends here.")
}
运行结果 (顺序可能每次不同):
Main-Thread: 1
Goroutine-One: 1
Main-Thread: 2
Goroutine-One: 2
Main-Thread: 3
Goroutine-One: 3
Main function ends here.
代码解析:
- 主 Goroutine 和新的 Goroutine 在并发地执行
printNumbers。 - 输出交错出现,证明了并发执行。
- 注意:程序在主 Goroutine 执行完后立即退出,有时可能来不及看到新 Goroutine 的所有输出(但本例中有
time.Sleep,所以通常能看到)。
示例2:没有等待的问题
package main
import "fmt"
func sayHello() {
fmt.Println("Hello from Goroutine!")
}
func main() {
go sayHello()
fmt.Println("Hello from Main!")
// 程序立刻结束,可能在新 Goroutine 执行前就退出了
}
可能的输出 (不完整):
Hello from Main!
问题分析:主 Goroutine 没有等待新 Goroutine 完成就退出了,导致新 Goroutine 的任务可能被取消。我们需要一种方式来协调等待,这是下一课 Channel 或后续课程 sync.WaitGroup 的用武之地。
实践练习
练习1:启动你的第一个 Goroutine
编写一个程序,定义一个函数 countDown(n int),该函数从 n 倒数到 1 并打印。在 main 函数中启动一个 Goroutine 来执行 countDown(5)。
预期输出(顺序可能交错,但内容应包含):
5
4
3
2
1
练习2:并发执行两个任务
创建两个函数:taskA() 和 taskB()。taskA 打印字母 “A” 5次,每次间隔200毫秒;taskB 打印字母 “B” 5次,每次间隔200毫秒。使用 Goroutine 并发启动它们,并观察输出顺序。
预期输出示例(顺序交错):
A
B
A
B
A
B
A
B
A
B
练习3:识别并修复代码错误
以下代码期望同时启动3个 Goroutine,每个打印一个唯一的消息,但可能无法看到所有输出。请分析原因并思考如何修复(提示:需要等待所有 Goroutine 完成)。
package main
import (
"fmt"
"time"
)
func printMessage(msg string) {
time.Sleep(time.Second)
fmt.Println(msg)
}
func main() {
go printMessage("Message 1")
go printMessage("Message 2")
go printMessage("Message 3")
fmt.Println("All goroutines launched.")
}
常见错误
-
忘记等待 Goroutine 完成:这是最常见的错误。主 Goroutine 退出时,所有其他 Goroutine 都会被强制终止。
// 错误示例 func main() { go doImportantWork() // 工作可能还没完成 fmt.Println("Exiting...") // 程序直接退出 }修正方案:使用
sync.WaitGroup或 Channel 等机制进行同步(将在后续课程学习)。 -
无限制地创建 Goroutine 导致资源耗尽 (Goroutine 泄漏):如果在循环中不断创建 Goroutine,且这些 Goroutine 因某种原因无法自行结束(如在阻塞读一个永远不会有数据的 Channel),它们会一直占用内存,最终导致程序崩溃。
// 危险示例:可能无限创建 Goroutine for { go func() { // 假设这个任务会一直阻塞 <-make(chan struct{}) }() }修正方案:确保每个启动的 Goroutine 都有明确的退出条件(如使用
context或doneChannel),并控制并发数量。 -
误解“并发”不等于“并行”:Goroutine 提供的是并发(多个任务交替执行),只有当 CPU 核心数足够时,它们才会并行(多个任务同时执行)。不要假设所有 Goroutine 都在物理上同时运行。
小结
- Goroutine 是 Go 语言实现并发的核心,是轻量级的用户态线程。
- 使用
go关键字启动一个新的 Goroutine,函数调用立即返回,原函数与新 Goroutine 并发执行。 - Go 运行时使用 GMP 调度模型高效管理成千上万的 Goroutine。
- 最关键的一点:必须在程序结束前确保所有必要的 Goroutine 已经完成工作。否则,程序可能会提前退出,导致任务中断。这是并发编程中同步问题的起点,我们将在下一课学习如何使用 Channel 来解决它。