GO协程(Goroutine)问题总结(待续)
在使用Go语言来编写代码时,遇到的一些问题总结一下
[参考文档]:https://www.topgoer.com/%E5%B9%B6%E5%8F%91%E7%BC%96%E7%A8%8B/goroutine.html
1. main()函数默认的Goroutine
场景再现:今天在看到这个教程的时候,在自己的电脑上跑了一下示例的代码。
发现了描述与我的执行结果不同的地方,官方文档描述如下:
这一次的执行结果只打印了main goroutine done!,并没有打印Hello Goroutine!。
但是我执行后的情况是,如图:
可以看到,我最终的执行结果是都输出了,而不是只输出了
main goroutine done!
Why?
原因是——虽然 main() 函数中调用了 go hello(),主 goroutine 在打印完 main goroutine done! 后就会退出,但:
在主 goroutine 退出前,如果新启动的 goroutine 有足够的时间运行完,Hello Goroutine! 就会输出。
上面这段代码启动了一个新 goroutine,但程序的执行是并发的,不是同步/阻塞的。
执行流程是:
go hello() 启动了一个新 goroutine;
fmt.Println(“main goroutine done!”) 被执行;
如果此时 main() 返回前,新 goroutine 还没来得及执行完,那它也会被强行终止;
但如果它已经执行完了,就能看到打印的内容。
这两句都成功输出,是因为你的电脑配置比较好,执行速度非常快,新启动的 goroutine 来得及在 main() 退出前完成打印。
正确做法:用 sync.WaitGroup 或 time.Sleep
2 . Go 协程(Goroutine)的两个关键点
协程不能保证执行的顺序,但是如果加了time.sleep的话,可以保障协程执行完毕
✅ Go 协程(Goroutine)的两个关键点:
①. 协程是并发的,不能保证执行顺序
go hello() 启动后,什么时候运行是由 Go 调度器决定的。
主协程和子协程是“谁抢到 CPU 谁先跑”,谁先打印是不确定的。
所以:
go hello()
fmt.Println("main done")
有可能先打印 main done,也可能先打印 Hello,取决于当时调度情况。
② 加 time.Sleep() 可以“间接保障”子协程执行完
加 time.Sleep() 相当于强行让主协程等一下,给子协程留时间执行完。
所以子协程通常会有时间执行完,看起来“像是被保障了执行”。
❗但注意:time.Sleep() ≠ 可靠同步
虽然 time.Sleep() 很简单,但它存在几个问题:
问题点 | 说明 |
---|---|
❌ 不精准 | 你不知道子协程到底需要多少时间,sleep 多了浪费,少了又执行不完 |
❌ 不可扩展 | 如果你有多个协程,就很难 sleep 到合适的时间 |
✅ 适合临时调试 | 用于演示或实验是可以的 |
✅ 正确做法:用 sync.WaitGroup
import ("fmt""sync"
)func hello(wg *sync.WaitGroup) {fmt.Println("Hello Goroutine!")wg.Done() // 协程结束,通知 WaitGroup
}func main() {var wg sync.WaitGroupwg.Add(1) // 告诉 WaitGroup 等待 1 个协程go hello(&wg) // 启动协程wg.Wait() // 等待所有协程结束fmt.Println("main goroutine done!")
}
这样就能准确地等待协程执行完再退出,不用靠 sleep。
总结一句话:
time.Sleep() 是简单粗暴的等待方式,可以在小程序中“凑合用”,但真正写程序,用 sync.WaitGroup 等同步机制更稳、更准、更专业。
3.defer
3.1 defer使用对比
func hello(i int) {defer wg.Done() // goroutine结束就登记-1fmt.Println("Hello Goroutine!", i)
}
与
func hello(i int){fmt.Println("Hello Goroutine!",i)wg.Done()
}
//在 正常情况下的效果是一样的:都会确保在 goroutine 执行完成后调用 wg.Done(),
//从而通知 WaitGroup,减少一个等待计数。
✅ defer wg.Done() 的优势:
defer 会在 函数返回前自动执行,即使函数中间发生了 panic(未被恢复),defer 也会运行(前提是没有让程序直接崩溃)。
这意味着:
func hello(i int) {defer wg.Done()// 如果这里出现错误,也能保证 Done 会执行fmt.Println("Hello Goroutine!", i)
}
更安全、稳健,防止遗漏。
❗ 手动调用 wg.Done() 有风险:
func hello(i int) {fmt.Println("Hello Goroutine!", i)wg.Done()
}
如果你写了更复杂的逻辑,中途 return 或 panic 了,wg.Done() 可能根本执行不到,就会导致 wg.Wait() 永远卡住。
✅ 总结:
写法 | 是否推荐 | 原因 |
---|---|---|
defer wg.Done() | ✅ 推荐 | 更安全,即使中途出错也能保证 Done 被调用 |
手动调用 wg.Done() | ⚠️ 慎用 | 必须确保函数末尾一定能执行到,否则容易漏调用 |
所以你的判断是对的:“两种写法是一样的”,功能上是对的;但为了避免未来的问题,推荐使用 defer 写法,更健壮、易维护。
3.2defer的作用、在Goroutine中的使用案例以及执行顺序
3.2.1 defer的作用
✅ defer 的作用
defer 用于延迟一个函数的执行,直到外围函数(即当前函数)返回之前才调用。
换句话说:
无论当前函数中发生了什么(正常结束或提前 return),defer 注册的语句都会在函数结束前自动执行。
📌 举个例子说明:
func demo() {fmt.Println("start")defer fmt.Println("this is defer")fmt.Println("end")
}
输出结果是:
start
end
this is defer
defer fmt.Println(“this is defer”) 被延迟执行到 demo() 函数退出前的最后一刻。
✅ defer 的常见用途
用途 | 示例 | 说明 |
---|---|---|
释放资源 | defer file.Close() | 防止文件忘记关闭 |
解锁 | defer mu.Unlock() | 防止死锁 |
记录日志/退出操作 | defer log.Println("退出") | 确保函数末尾执行 |
Done 通知 | defer wg.Done() | 保证 goroutine 退出时减少计数 |
3.2.2 在Goroutine中的使用案例
package mainimport ("fmt""sync"
)var wg sync.WaitGroupfunc hello(i int) {defer wg.Done() // 必须在协程结束时调用 wg.Done() 通知 WaitGroup 协程结束fmt.Println("Hello Goroutine!", i)
}
func main() {for i := 0; i < 10; i++ {wg.Add(1) // 告诉 WaitGroup 等待 1 个协程go hello(i) // 启动另外一个goroutine去执行hello函数}wg.Wait() // 等待所有的 goroutine 结束fmt.Println("main goroutine done!")
}
3.2.3 多个defer的执行顺序
defer 在 Go 中的执行顺序就是栈结构的“先进后出(LIFO)”原则:
🧠 defer 的执行顺序:后注册,先执行
也就是:
func demo() {defer fmt.Println("1")defer fmt.Println("2")defer fmt.Println("3")
}
🧾 输出结果:
3
2
1
📦 原理解释:
当你写多个 defer 时,Go 会把它们压入一个栈中,函数结束时就从栈顶往下依次执行这些 defer。
行号 | 执行内容 | 进入 defer 栈 |
---|---|---|
1 | defer fmt.Println("1") | 栈:[1] |
2 | defer fmt.Println("2") | 栈:[1, 2] |
3 | defer fmt.Println("3") | 栈:[1, 2, 3] |
return前执行 defer | 从栈顶开始弹出 | 输出顺序:3 → 2 → 1 |
✅ 常见用途
多个 defer 通常用于按顺序清理资源,比如:
func handleFile() {f1, _ := os.Open("file1.txt")defer f1.Close() // 最后关闭f2, _ := os.Open("file2.txt")defer f2.Close() // 比 f1 先关闭fmt.Println("doing something...")
}
结果是 file2 会先关闭,然后 file1 再关闭 —— 和你打开文件的顺序是相反的,这正是我们想要的行为(像“关门要倒着来
”一样)。
✅ 总结:
特性 | 说明 |
---|---|
顺序 | 多个 defer 是“后注册,先执行”(LIFO) |
应用场景 | 清理资源(文件、锁、连接等)、错误处理等 |
安全性 | 即使函数 return 或 panic ,也会执行 |
4.主协程和其他协程的关系,主协退出了,其他的协程还执行吗?
代码使用了官网提供的:
package mainimport ("fmt""time"
)func main() {// 合起来写go func() {i := 0for {i++fmt.Printf("new goroutine: i = %d\n", i)time.Sleep(time.Second)}}()i := 0for {i++fmt.Printf("main goroutine: i = %d\n", i)time.Sleep(time.Second)if i == 2 {break}}
}
执行结果:
main goroutine: i = 1
new goroutine: i = 1
main goroutine: i = 2
new goroutine: i = 2
new goroutine: i = 3Process finished with the exit code 0
证明了主协程结束,其他线程不会再执行
5. java/c/c++线程与go协程的对比(与OS线程)
特性 | Java / C 的线程(OS Thread) | Go 的 goroutine |
---|---|---|
线程类型 | 操作系统线程(内核线程) | 用户级线程(协程) |
线程模型 | 1:1 模型 | M:N 模型 |
调度者 | 操作系统 | Go 自带的调度器(runtime) |
映射关系 | 每个语言线程对应一个 OS 线程 | 多个 goroutine 映射到多个 OS 线程 |
栈内存初始大小 | 通常 1MB~2MB(固定) | 起始约 2KB(可动态伸缩) |
创建成本 | 高(需要系统调用) | 极低(用户态,几乎无开销) |
调度成本 | 高(内核态线程切换) | 低(用户态线程切换) |
并发数量限制 | 一般几千个 | 十万甚至百万级 |
适合场景 | 计算密集、高性能场景 | 高并发、大量 I/O 场景 |
常用语言API | std::thread , Thread | go myFunc() |
内存使用效率 | 相对较低 | 非常高 |
🔍 示例类比:
类比 | Java / C 的线程 | Go 的 goroutine |
---|---|---|
比喻 | 重型卡车:开销大但能干活 | 自行车大军:轻量且灵活 |
调度员 | 操作系统 | Go 自己的调度器 |
数量 | 几千个已很吃力 | 十万个都轻轻松松 |
✅ 图示说明
Java / C => 1:1 线程模型
┌──────────┐ ┌──────────┐
│ Thread A │───────▶│ OS 线程 A │
│ Thread B │───────▶│ OS 线程 B │
└──────────┘ └──────────┘Go => M:N 线程模型
┌──────────────┐
│ goroutine 1 │
│ goroutine 2 │
│ goroutine 3 │──┐
│ goroutine 4 │ │
│ goroutine 5 │ ├──▶ 被 Go runtime 调度
│ goroutine N │──┘ 分配到 OS 线程 A/B/C…
└──────────────┘
✅ 总结一句话:
Java 和 C 的线程就是系统线程(1:1),重量级。
Go 的 goroutine 是用户级线程,轻量可扩展(M:N),适合高并发。