Go 并发学习复盘
最近刷了不少 Go 并发练习题,五花八门的题最终分析起来,归根落地就围绕三件事:控制资源互斥访问、协调协程执行顺序、协程间的数据互通。本文结合实战将日常开发的并发场景归纳成四类,同时聊聊一句经典go哲学:不要用共享内存通信,要用通信共享内存。
一、四类并发场景
1. 有序轮流执行:环形 Channel
碰到多个 Goroutine 需要固定顺序循环执行,比如两个协程交替打印奇偶、三个协程循环输出 ABC,首选环形 Channel 方案。
思路很简单:每个协程绑定专属通道,当前协程业务逻辑结束后,向下一个协程的 Channel 发送空信号,以此驱动下一段代码执行。
当然如果只有两个协程的话,用一个无缓冲通道就行,一个协程阻塞等待通道可读,读到信号就运行;另一个协程执行完,等待通道可写,写入信号切换对方,写法更精简。
2. 生产者消费者:单通道多消费
典型的流水线模型,一端负责生产任务,多个协程并行处理任务。只需要创建一个公共 Channel,所有 worker 协程阻塞从同一个通道读取数据。
像文件读取→数据清洗→入库的链路、消息队列消费,全是这套思路的落地。
3. 多协程汇总等待:WaitGroup
需要批量开启一堆协程,且后续逻辑必须等待全部任务执行完毕才能继续,比如多协程分片下载文件,全部下载结束后再统一合并文件,这时 WaitGroup 是最优解。
不过这里我有个客观看法:WaitGroup 能力很单一,只负责等待,无法传递数据。如果协程之间还要交互数据,依旧需要加入channel 或者传递context实现数据共享。
4. 并发限流:缓冲 Channel 充当信号量
爬虫限制瞬时请求量、控制数据库连接池最大连接数,利用带缓冲的空结构体 Channel 做令牌桶。缓冲区容量就是最大并发数,开启协程前占用一个令牌,任务执行完毕释放令牌,轻松实现并发管控。
日常写代码快速抉择:要严格顺序同步优先 Channel;批量等待收尾用 WaitGroup;限制并发数量用缓冲 Chan;简单短变量读写,偶尔直接 Mutex 反而更轻便。
二、常见踩坑
1.循环中启动 Goroutine 捕获循环变量
循环变量地址不变,所有协程共用同一个变量,最终输出结果全部一致。解决办法就是启动协程时把循环值当做入参传入;
2.Channel 收发失衡引发死锁
无缓冲通道只发不收、只收不读是死锁高发区,写代码前提前梳理收发逻辑;
3.Channel 传递指针违背设计初衷
看似在用 Chan 通信,但是收发双方指向同一块堆内存,多个协程共用内存地址,直接退化回传统共享内存模式,白白浪费了 Chan 的设计优势,还要手动加锁防竞争。
三、重新理解“通过通信共享内存”
这句话是 Go 并发的灵魂,但我初学阶段一直很疑惑:hchan底层结构体明明自带 Mutex 锁,缓冲区也是所有协程共享的内存,从硬件实现层面,Channel 本身就是共享内存 + 锁实现的,那么通过通信共享内存和通过共享内存通信其实不就是一个含义吗,都需要开辟一个内存空间,实现数据共享?
琢磨很久才理清这句话的适用边界:哲学约束的是业务代码,不约束 Go Runtime 底层实现。
1.通信指什么?
通信就是ch <- data发送、<-ch接收这一组 Channel 交互动作。数据诞生之初只属于单个 Goroutine 私有,不会主动暴露成全局公共变量,依靠收发消息完成数据流转。
2. 共享内存指什么?
并不是多个协程同时共用同一块物理内存地址,而是数据经由 Channel 传递之后,接收方拿到数据、拥有使用权,从使用效果上实现了数据共享,这就是原文中 “共享内存” 的真正含义。
3. 两种编程思路本质区别
1)Go 哲学“通过通信共享内存”:数据全程私有,依靠 Channel 通信完成数据转交,锁被 Runtime 封装在 Channel 内部,业务代码不用直面锁的管理。
2)传统“通过共享内存通信”:主动开辟全局共享内存,为了协程通信交换数据,开发者手动加锁、解锁,所有同步风险由程序员承担;
四、总结
所以Go 从来没有从底层消灭锁和共享内存,只是把锁的管理从开发者身上转移到运行时。也不能神化 Channel 万能,毕竟channel是一个复杂的结构体,使用时内部的逻辑还是有的,在简单数值累加场景中,直接开辟内存空间和加锁性能反而更好。
没有万能的并发原语,Mutex、Chan、WaitGroup 三者互补,主要还是根据业务场景合理选用和权衡。