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),适合高并发。
6.并发与并行
6.1并发 vs 并行 示意图
+-----------------------+ +-------------------------+
| 单核 CPU | | 多核 CPU |
| (1 个核心处理器核) | | (多个核心同时工作) |
+-----------------------+ +-------------------------+
| | | |
| ┌───────────────┐ | | ┌───────────────┐ |
| │ 核心 CPU 核 │ | | │ 核心 CPU 核 1 │ |
| │ │ | | ├───────────────┤ |
| │ 任务1 │ | | │ 任务1 (并行执行)│ |
| │任务2 (并发切换) │ ←──┐ | │任务2 (并行执行)│ |
| └───────────────┘ | | | └───────────────┘ |
| 任务1和任务2“交替”执行 | | | 多个任务“真正同时”执行 |
| | | | |
+-----------------------+ | +-------------------------+|并发(Concurrency) | 并行(Parallelism)任务切换看似同时进行 | 物理上真正同时执行
6.2Go 的 G-P-M 调度模型简化示意
+----------------------------+
| G (Goroutine) | // 轻量级任务
+----------------------------+│▼
+----------------------------+
| P (Processor) | // 逻辑处理器(虚拟CPU核)
| 数量 = GOMAXPROCS 的值 |
+----------------------------+│▼
+----------------------------+
| M (Machine/线程) | // 真实的 OS 线程
+----------------------------+│▼真正执行代码的 CPU 核心
你创建的很多 G (goroutine),由有限个 P(由 GOMAXPROCS 控制) 调度到 M(操作系统线程) 上执行。
当 GOMAXPROCS=1,只有一个 P,也就是说同一时刻只有一个 M 在执行 goroutine,表现为“并发”,但不是“并行”。
当 GOMAXPROCS>1,多个 P 和 M 允许 goroutine 并行运行在多个 CPU 核心上。
6.3举个例子
🍳 并发(Concurrency):
就像一个厨师(单核 CPU),轮流做多个菜(任务)。他可能:
先把锅烧热,然后去切菜,再回来翻炒,接着煮汤……
虽然这些菜好像都在“同时准备”,但其实是一个人来回切换工作状态完成的。
特点:
看起来同时,其实是轮流进行的本质上是 任务调度,而不是并行执行
👨🍳👨🍳 并行(Parallelism):
多个厨师(多核 CPU)在多个灶台上,真正同时做多个菜(任务):
一人炒菜,一人煮汤,一人蒸饭……
特点:
真正同时执行每个任务由不同核心独立运行
💡 套用到 Go 的场景里:
go func() 就像是安排一个菜让厨师做runtime.GOMAXPROCS(1) 就是告诉厨房:“我只配一个厨师”(不管你有几个灶台)Go 的调度器就像厨房总管,会安排炒什么菜、先做哪个、后做哪个
7. 缓冲信道和无缓冲信道
🌟 一张表格对比
特性 | 无缓冲通道 (make(chan T) ) | 带缓冲通道 (make(chan T, N) ) |
---|---|---|
是否有缓冲区 | ❌ 没有,容量为 0 | ✅ 有,容量为 N(N ≥ 1) |
是否阻塞发送方 | ✅ 会,直到接收方准备好 | ❌ 不会,只要缓冲区没满 |
是否阻塞接收方 | ✅ 会,直到发送方发送 | ❌ 不会,只要缓冲区有数据 |
典型应用 | 同步通信,控制执行时机 | 异步通信,解耦发送与接收 |
性能 | 慢,但同步、控制精准 | 快,但可能增加复杂性 |
死锁风险 | 高(若不成对出现) | 低(发送和接收可以脱钩) |
是否 FIFO | ✅ 是 | ✅ 是 |
✅ 示例对比:无缓冲通道
package mainimport "fmt"func main() {ch := make(chan int) // 无缓冲通道go func() {val := <-chfmt.Println("接收成功:", val)}()ch <- 10 // 阻塞直到接收方准备好fmt.Println("发送成功")
}
执行顺序:
接收成功: 10
发送成功
👉 注意:如果你把 发送(ch <- 10) 放在 go 启动接收协程 之前,就会死锁!
✅ 示例对比:带缓冲通道
package mainimport "fmt"func main() {ch := make(chan int, 1) // 带缓冲通道,容量为1ch <- 10 // ✅ 不阻塞fmt.Println("发送成功")go func() {val := <-chfmt.Println("接收成功:", val)}()
}
执行顺序:
发送成功
接收成功: 10
👉 注意:即使接收方还没准备好,发送也不会阻塞(只要缓冲区有空间)。
🧠 总结一句话:
无缓冲通道:必须“手递手”,发送和接收必须同步发生。
带缓冲通道:允许“放下再走”,先发送存储,之后再接收。
🤔我的理解【ASK ChatGPT】
我的理解
无论是先发送还是先接收,都不应该主程序阻塞后续的程序,否则接收不到,或者发送不了。
无论先发送还是接收,都应该在下一步前建立一个协程来进行类似监听的协程来阻塞协程这样才能保证发送接收同步进行吗?
无论是发送(val<-10),还是接收(<-val)都会阻塞当前的协程,我说的对吗
GPT回答
✅ 你的原话分析:
❓“无论是先发送还是先接收,都不应该主程序阻塞后续的程序,否则接收不到,或者发送不了。”
✅ 完全正确!
如果你在 main() 主线程中直接执行 val := <-ch 或 ch <- 10,主 goroutine 会阻塞,导致之后的 goroutine 根本不会被调度运行。
所以要让 发送/接收那一方跑在 goroutine 中,才能让另一方执行并完成通信。
❓“无论先发送还是接收,都应该在下一步前建立一个协程来进行类似监听的协程来阻塞协程这样才能保证发送接收同步进行吗?”
✅ 非常正确,表达也很清晰。
Go 的无缓冲通道通信是 两个 goroutine 的同步配合行为。
不论谁先运行,都必须有 另一个 goroutine 在等待,通信才能发生。
✅ 举个常用结构例子:
go func() {ch <- 10 // goroutine A 发送
}()go func() {val := <-ch // goroutine B 接收
}()
两个 goroutine 各自阻塞在 send/recv 点,一旦两者都就绪,通信立刻完成。
❓“无论是发送(val <- 10),还是接收(<-val)都会阻塞当前的协程,我说的对吗?”
✅ 完全正确!
操作 | 是否阻塞 | 说明 |
---|---|---|
ch <- 10 | ✅ 会阻塞 | 如果没有协程在接收,就阻塞 |
<-ch | ✅ 会阻塞 | 如果没有协程在发送,就阻塞 |
✅ 只要通道是无缓冲的,这两个操作都必须等对方准备好才能继续。
✅ 最核心的一句话总结:
无缓冲通道中的发送和接收,必须发生在两个独立的 goroutine 中,才能顺利配对并完成同步。
8. channel关闭(close)
8.1 channel 关闭(close)≠ 销毁
Go 里没有显式的“销毁”channel操作,只有 关闭(close) 操作。
close(ch) 表示:
禁止再向通道写入数据(写入会 panic)可以继续读取通道中剩余的缓冲数据读取到空了之后,再读取会得到零值(对应类型的默认值)并返回 ok=false
8.2 关闭 channel 后为什么“只能读取一次”?
通道关闭后,只能读取到剩余的数据,读取完就没有了。你可以多次从关闭的 channel 读取数据,直到读完所有剩余数据。但是如果你用 for range ch 循环,循环会在读完所有数据后自动结束。
重点:
for range ch 循环 只能执行一次,因为通道中的数据只会被消费一次,且通道关闭后不再接收新数据。
8.3 不关闭 channel,能读多次吗?
如果 不关闭 channel,就不能用 for range ch 这种方式循环读取,因为它会阻塞等待新的数据,直到 channel 关闭。但你可以用手动读取(val, ok := <-ch)不断读数据,只要有人往里面写,就能持续接收。多个接收者从同一个 channel 读取是“抢占式”的,数据会被分配给其中一个,不能保证所有接收者都能读到相同数据。
8.4 总结
状态 | 是否能写入 | 是否能读取多次 | 备注 |
---|---|---|---|
channel 已关闭 | 不允许(写会 panic) | 可以读取剩余数据,读完返回零值+ok=false | 只能消费剩余数据,不能写入 |
channel 未关闭 | 允许 | 可以多次读取(取决于写入情况) | 读取会阻塞等待数据 |
- 举个例子说明
package mainimport "fmt"func main() {ch := make(chan int, 3)ch <- 1ch <- 2ch <- 3close(ch) // 关闭通道for i := 0; i < 5; i++ {val, ok := <-chfmt.Println(val, ok)}
}
输出:
1 true
2 true
3 true
0 false // 通道关闭且无剩余数据,返回类型零值,ok=false
0 false
如果你想多次“重新读取”同一批数据,就需要把数据保存到 slice 或其他容器,而不是依赖 channel 本身,因为 channel 数据是一次性消费的。
9.匿名函数(内联函数)
官方文档的Goroutine池中提供了
package mainimport ("fmt""math/rand"
)type Job struct {// idId int// 需要计算的随机数RandNum int
}type Result struct {// 这里必须传对象实例job *Job// 求和sum int
}func main() {// 需要2个管道// 1.job管道jobChan := make(chan *Job, 128)// 2.结果管道resultChan := make(chan *Result, 128)// 3.创建工作池createPool(64, jobChan, resultChan)// 4.开个打印的协程go func(resultChan chan *Result) {// 遍历结果管道打印for result := range resultChan {fmt.Printf("job id:%v randnum:%v result:%d\n", result.job.Id,result.job.RandNum, result.sum)}}(resultChan) //①匿名函数var id int// 循环创建job,输入到管道for {id++// 生成随机数r_num := rand.Int()job := &Job{Id: id,RandNum: r_num,}jobChan <- job}
}// 创建工作池
// 参数1:开几个协程
func createPool(num int, jobChan chan *Job, resultChan chan *Result) {// 根据开协程个数,去跑运行for i := 0; i < num; i++ {go func(jobChan chan *Job, resultChan chan *Result) {// 执行运算// 遍历job管道所有数据,进行相加for job := range jobChan {// 随机数接过来r_num := job.RandNum// 随机数每一位相加// 定义返回值var sum intfor r_num != 0 {tmp := r_num % 10sum += tmpr_num /= 10}// 想要的结果是Resultr := &Result{job: job,sum: sum,}//运算结果扔到管道resultChan <- r}}(jobChan, resultChan)//②匿名函数}
}
对②匿名函数解释
(jobChan, resultChan)
是createPool(num int, jobChan chan *Job, resultChan chan *Result)
处对应的参数值,- 然后
(jobChan, resultChan)
又被当作参数传入了go func(jobChan chan *Job, resultChan chan *Result)
用匿名函数的好处是 不需要额外写一个函数名,逻辑上简洁一些。
10.select处理多种类型事件例子
常见用法举例
假设你有几个不同类型的事件通过不同通道传递,你可以写类似的代码:
package mainimport ("fmt""time"
)func eventA(ch chan string) {time.Sleep(time.Second * 1)ch <- "Event A occurred"
}func eventB(ch chan int) {time.Sleep(time.Second * 2)ch <- 42
}func eventC(ch chan bool) {time.Sleep(time.Second * 3)ch <- true
}func main() {chA := make(chan string)chB := make(chan int)chC := make(chan bool)go eventA(chA)go eventB(chB)go eventC(chC)for i := 0; i < 3; i++ {select {case msg := <-chA:fmt.Println("Received from chA:", msg)case val := <-chB:fmt.Println("Received from chB:", val)case flag := <-chC:fmt.Println("Received from chC:", flag)}}
}
解释
-
你启动3个 goroutine,它们各自延迟不同时间后往对应通道写入不同类型的事件数据。
-
主协程中用一个 select 监听这3个通道。
-
每当任意一个通道有数据,就进入对应 case 执行,打印接收到的事件。
-
循环3次保证3个事件都能被处理。
重点总结
-
select 可以监听任意多个通道。
-
每个 case 可以处理不同类型的数据。
-
只要某个通道准备好数据,select 就会立即触发对应的 case。
-
select 本身不是“处理多种类型数据”的结构,但结合不同通道的类型可以做到这一点。