Go并发学习:从阶乘案例看Channel的必要性
在Go语言并发编程中,Goroutine是实现高效并发的核心,但多个Goroutine之间的通信与同步,往往是新手容易踩坑的地方。本文将以“计算1-200各数阶乘并存入map”为案例,结合学习过程中遇到的无锁、无休眠等问题,逐步分析全局变量加锁同步的局限性,最终理解Channel出现的必要性,记录自己的学习心得与思考。
一、案例需求与初始思路
本次学习的核心需求:使用Goroutine计算1-200每个数的阶乘,将结果存入map中,最终打印所有结果。
初始思路很直接,参考PPT中的引导:
- 编写阶乘计算函数,负责计算单个数字的阶乘,并将结果存入map;
- 启动多个Goroutine(对应1-200的每个数),并发执行阶乘计算与结果存储;
- 将map设为全局变量,方便所有Goroutine访问和修改。
按照这个思路,我先写出了基础代码框架,但在实际运行中,出现了各种问题,也正是这些问题,让我逐步理解了Go并发同步的核心逻辑。
二、问题拆解:三种异常场景分析
结合PPT内容和自己的调试过程,我将问题分为“无锁场景”“无休眠场景”“加锁加休眠仍有隐患场景”三类,逐一分析问题本质和现象。
场景1:无锁状态——并发安全问题(concurrent map writes)
首先,我去掉了代码中的互斥锁(lock.Lock()和lock.Unlock()),仅保留全局map和Goroutine,代码核心片段如下:
var myMap = make(map[int]int, 10)
// 无锁版本test函数
func test(n int) {
res := 1
for i := 1; i <= n; i++ {
res *= i
}
myMap[n] = res // 直接写入全局map,无任何锁保护
}
func main() {
for i := 1; i <= 20; i++ {
go test(i) // 启动多个Goroutine
}
// 未加休眠,未加锁打印
for i, v := range myMap {
fmt.Printf("map[%d]=%d\n", i, v)
}
}
直接运行上述代码,报错并非concurrent map writes,而是fatal error: concurrent map iteration and map write,实际运行报错如下:
/app/go-atguigu/goroutine-demo02 # go run main.go
map[1]=1
map[2]=2
map[8]=40320
fatal error: concurrent map iteration and map write
goroutine 1 [running]:
internal/runtime/maps.fatal({0x4ccdcf?, 0x38ce22c48040?})
/usr/local/go/src/runtime/panic.go:1181 +0x18
internal/runtime/maps.(*Iter).Next(0x0?)
/usr/local/go/src/internal/runtime/maps/table.go:819 +0x86
main.main()
/app/go-atguigu/goroutine-demo02/main.go:33 +0x119
exit status 2
只有给主线程添加休眠代码(//time.Sleep(time.Second * 5)),让主线程等待所有Goroutine先执行写操作,避免“读+写”并发,报错才会变为concurrent map writes,具体报错如下:
/app/go-atguigu/goroutine-demo02 # go run main.go
fatal error: concurrent map writes
goroutine 19 [running]:
internal/runtime/maps.fatal({0x4c8de5?, 0x0?})
/usr/local/go/src/runtime/panic.go:1181 +0x18
main.test(...)
/app/go-atguigu/goroutine-demo02/main.go:25
created by main.main in goroutine 1
/app/go-atguigu/goroutine-demo02/main.go:30 +0x4b
exit status 2
这里需要重点纠正一个细节:上述无锁代码直接运行时,报错并非PPT中提到的concurrent map writes,而是concurrent map iteration and map write——原因是主线程在未加休眠的情况下,一边执行map的读操作(循环打印),一边有多个Goroutine执行map的写操作,属于“读+写”并发冲突;只有给主线程添加休眠代码,让主线程暂停执行读操作,等待所有Goroutine先执行写操作,此时多个Goroutine同时写map,才会报concurrent map writes错误,这两种报错本质都是map的并发安全问题,也是PPT中重点提到的“资源争夺问题”。如图1

问题本质:Go中的map是非并发安全的数据结构(这也是Go并发编程的基础知识点),多个Goroutine同时对map执行写操作时,会出现资源竞争——多个Goroutine同时抢占map的内存资源,导致数据写入混乱、程序崩溃。正如PPT所讲,判断是否存在资源竞争的方法很简单,在编译程序时添加-race参数,就能清晰看到竞争点。
补充说明:不仅写操作会有竞争,即使是“读+写”同时进行,也会出现资源竞争。这是因为全局变量被所有Goroutine共享,没有任何限制的情况下,多个Goroutine的读写操作会相互干扰,这也是并发编程中“共享内存”模式的固有问题。
场景2:无休眠状态——主线程提前退出,结果丢失
解决无锁问题后,我添加了互斥锁,确保map的读写安全,但去掉了主线程的休眠代码(//time.Sleep(time.Second * 5)),代码核心片段如下:
package main
import (
"fmt"
//"time"
"sync"
)
// 需求:现在要计算 1-200 的各个数的阶乘,并且把各个数的阶乘放入到map中。
// 最后显示出来。要求使用goroutine完成
// 思路
// 1. 编写一个函数,来计算各个数的阶乘,并放入到 map中.
// 2. 我们启动的协程多个,统计的将结果放入到 map中
// 3. map 应该做出一个全局的.
var (
myMap = make(map[int]int, 10)
//声明一个全局的互斥锁
//lock 是一个全局的互斥锁,
//sync 是包: synchornized 同步
//Mutex : 是互斥
lock sync.Mutex
)
// test 函数就是计算 n!, 让将这个结果放入到 myMap
func test(n int) {
res := 1
for i := 1; i <= n; i++ {
res *= i
}
//这里我们将 res 放入到myMap
//加锁
lock.Lock()
myMap[n] = res //concurrent map writes?
//解锁
lock.Unlock()
}
func main() {
// 我们这里开启多个协程完成这个任务[200个]
for i := 1; i <= 20; i++ {
go test(i)
}
//休眠10秒钟【第二个问题 】
//time.Sleep(time.Second * 5)
//这里我们输出结果,变量这个结果
lock.Lock()
for i, v := range myMap {
fmt.Printf("map[%d]=%d\n", i, v)
}
lock.Unlock()
}
运行后,控制台要么打印空结果,要么只打印少量结果,大部分阶乘结果丢失。如图2

/app/go-atguigu/goroutine-demo02 # go run main.go
/app/go-atguigu/goroutine-demo02 # go run main.go
map[1]=1
map[3]=6
map[7]=5040
map[2]=2
/app/go-atguigu/goroutine-demo02 # go run main.go
/app/go-atguigu/goroutine-demo02 # go run main.go
map[10]=3628800
map[15]=1307674368000
map[2]=2
map[13]=6227020800
map[14]=87178291200
map[6]=720
map[12]=479001600
map[5]=120
map[11]=39916800
map[1]=1
map[3]=6
map[4]=24
map[8]=40320
map[7]=5040
map[9]=362880
/app/go-atguigu/goroutine-demo02 #
问题本质:Goroutine是轻量级的执行单元,由Go runtime调度,而主线程(main函数对应的Goroutine)不会等待其他Goroutine执行完毕,会直接继续执行后续代码,执行完打印操作后就退出程序。一旦主线程退出,所有未执行完毕的Goroutine会被强制销毁,导致它们的计算结果无法写入map,最终出现结果丢失的情况。
这就是PPT中提到的“第二个问题”:主线程和Goroutine的执行节奏不一致,主线程没有等待其他Goroutine完成任务,导致结果无法正常获取。
场景3:加锁加休眠——看似可行,仍有隐患且不灵活
结合前两个问题,我同时添加了互斥锁和主线程休眠代码,也就是用户提供的“最终代码”,看似解决了所有问题,但深入调试后发现,这种方式依然存在隐患,且不够灵活。
首先,关于“打印时为什么也要加锁”,PPT中给出了关键解释:虽然我们主观估算10秒足够所有Goroutine执行完毕,但主线程并不知道其他Goroutine的执行状态。即使设置了休眠时间,底层调度仍可能出现“Goroutine未执行完,主线程就开始打印”的情况,此时打印操作(读map)和Goroutine的写操作会产生资源竞争,因此打印时也需要加锁保护。
其次,这种方式的核心隐患的是:休眠时间无法精准控制。
- 如果休眠时间设置过长(比如10秒),会造成不必要的等待,浪费系统资源,降低程序效率;
- 如果休眠时间设置过短(比如1秒),部分Goroutine可能还未执行完毕,依然会出现结果丢失的问题;
- 实际开发中,Goroutine的执行时间受任务复杂度、系统负载等因素影响,无法提前精准估算,硬编码休眠时间,会导致程序的稳定性和可扩展性极差。
除此之外,全局变量加锁同步的方式,还存在一个核心问题:多个Goroutine通过共享全局map通信,所有读写操作都需要通过锁来控制,不仅增加了代码复杂度,还容易出现死锁(比如忘记解锁)、锁粒度不合理等问题,不利于后续维护和扩展。正如PPT所讲,这种方式“不完美”,无法满足灵活、安全的并发通信需求。
三、总结:为什么Channel是必要的?
通过上面三个场景的分析,结合PPT中的引导,我深刻理解了Channel出现的必要性——它正是为了解决“全局变量加锁同步”的诸多弊端,实现Goroutine之间更安全、更灵活的通信与同步。
结合本次阶乘案例,总结Channel的必要性如下:
- 解决资源竞争问题,无需手动加锁
Channel是Go语言中用于Goroutine之间通信的“管道”,遵循“通过通信来共享内存,而不是通过共享内存来通信”的设计哲学。它本身是并发安全的,多个Goroutine通过Channel发送和接收数据时,无需手动添加互斥锁,就能避免资源竞争问题。
在阶乘案例中,如果使用Channel替代全局map,Goroutine计算出阶乘后,将结果发送到Channel中,主线程从Channel中接收结果并打印,就能彻底避免map的并发读写问题,代码更简洁、更安全。 - 精准同步Goroutine,无需手动休眠
Channel具有“阻塞特性”:无缓冲Channel的发送和接收操作会相互阻塞,直到双方都准备好;有缓冲Channel在缓冲区满时会阻塞发送,缓冲区空时会阻塞接收。利用这种特性,主线程可以精准等待所有Goroutine执行完毕,无需手动设置休眠时间。
比如,我们可以启动200个Goroutine,每个Goroutine计算完阶乘后,向Channel发送一个“完成信号”,主线程接收200个信号后,再执行打印操作,这样就能确保所有Goroutine都已执行完毕,既不会浪费时间,也不会丢失结果。 - 降低代码复杂度,提升可维护性
全局变量加锁同步的方式,需要手动管理锁的加锁和解锁,容易出现遗漏解锁、死锁等问题,且全局变量的共享会导致代码耦合度升高。而Channel将“数据传递”和“同步控制”融为一体,Goroutine之间无需共享全局资源,通过Channel传递数据和信号,代码逻辑更清晰,维护成本更低。 - 适配更复杂的并发场景
随着并发场景的复杂化(比如多生产者、多消费者模式),全局变量加锁的方式会越来越难以控制,而Channel支持单向通道、select多路复用等特性,能够轻松应对复杂的并发通信需求。例如,我们可以通过有缓冲Channel平衡生产者(计算阶乘的Goroutine)和消费者(打印结果的主线程)的速度,避免一方等待另一方的情况。
四、学习感悟
通过本次阶乘案例的学习,我深刻体会到:Goroutine让Go的并发变得简单,但真正的难点在于Goroutine之间的通信与同步。全局变量加锁同步是一种基础的解决方案,但它的局限性很明显,而Channel作为Go并发编程的核心原语,完美解决了这些问题。
Go语言的设计哲学告诉我们:“不要通过共享内存来通信,而要通过通信来共享内存”。Channel正是这种哲学的体现,它让并发编程变得更安全、更优雅、更可维护。本次学习不仅解决了阶乘案例中的具体问题,更让我理解了Go并发的核心逻辑,为后续学习更复杂的并发场景(如Worker Pool、select多路复用)打下了基础。
后续,我将继续深入学习Channel的使用(无缓冲、有缓冲、单向通道等),结合更多案例,熟练运用Channel实现Goroutine之间的通信与同步,真正掌握Go并发编程的精髓。