Go 并发控制:从 Channel 方向约束到实战模式

写并发代码,最怕的不是写不出来,而是写出来了、跑起来了、但藏着你看不见的 bug。


一、Channel 的方向约束

Go 的 channel 有三种类型签名:

go 复制代码
chan int      // 双向:能读能写能关
<-chan int    // 只读:只能接收
chan<- int    // 只写:只能发送和关闭

这不是运行时机制,是编译期契约。运行时它们指向同一个底层结构,性能零损耗。

为什么要自找麻烦加方向?

并发代码的 bug 往往不是"功能错了",而是"角色越界了"------消费者不该写入,生产者不该关别人的 channel。

go 复制代码
// 不加约束:三个月后你忘了职责,手滑写一行 ch <- x,编译器不拦你
go func(ch chan int) { ... }

// 加了约束:编译器直接报错
go func(ch <-chan int) { ... }

转换规则

go 复制代码
ch := make(chan int)     // 双向

var r <-chan int = ch    // ✅ 双向 → 只读(隐式收窄)
var w chan<- int = ch    // ✅ 双向 → 只写(隐式收窄)

var ch2 chan int = r     // ❌ 只读 → 双向(不可逆,编译错误)

我的建议:所有传入 goroutine 的 channel,都显式标注方向。不是炫技,是为了三个月后的自己。


二、WaitGroup 的正确姿势

sync.WaitGroup 管理的是 goroutine 的生命周期,不是任务的执行顺序。顺序靠 channel,生命周期靠 WaitGroup,两件事别混在一起。

唯一的铁律:wg.Add(1) 必须在 go func() 之前调用。

放在 goroutine 内部,主协程可能在 Add 执行前就跑到 Wait(),直接返回------这是 race condition。


三、实战一:生产者-消费者

设计思路

复制代码
Producer → [buffered channel] → Consumer × N

关键决策:

  • 谁关 channel? 生产者。只有它知道"没有更多数据了"
  • 怎么等消费者结束? WaitGroup
  • channel 要不要缓冲? 要。解耦生产和消费的速度差

完整实现

go 复制代码
package main

import (
	"fmt"
	"math/rand"
	"sync"
	"time"
)

// produce 返回只读 channel------调用方只能消费,不能往里塞东西
func produce(tasks []int) <-chan int {
	out := make(chan int, 8)
	go func() {
		defer close(out)
		for _, t := range tasks {
			out <- t
		}
	}()
	return out
}

// consume 从只读 channel 取任务,处理完自然退出
func consume(id int, jobs <-chan int, wg *sync.WaitGroup) {
	defer wg.Done()
	for job := range jobs {
		time.Sleep(time.Duration(rand.Intn(50)) * time.Millisecond)
		fmt.Printf("consumer-%d processed job %d\n", id, job)
	}
}

func main() {
	tasks := make([]int, 30)
	for i := range tasks {
		tasks[i] = i + 1
	}

	jobs := produce(tasks)

	var wg sync.WaitGroup
	for i := range 5 {
		wg.Add(1)
		go consume(i, jobs, &wg)
	}

	wg.Wait()
	fmt.Println("all done")
}

设计要点

设计点 原因
produce 返回 <-chan int 防止外部误写入或误关闭
close(out) 在生产者内部 唯一生产者负责关闭
5 个消费者共享一个 channel channel 并发安全,多消费者自动竞争
range jobs channel 关闭后自动退出,无需额外信号

四、实战二:固定 10 个 Worker 打印 100 个元素

题目

用固定 10 个 goroutine,无重复、无遗漏地打印 slice 中的 100 个元素。用容量为 10 的有缓冲 channel 作为任务队列。

核心思路

这是标准的 Worker Pool 模式:

复制代码
main 把 100 个任务塞进 channel
         ↓
  [buffered channel, cap=10]
         ↓
10 个 worker 竞争消费,干完一个取下一个

自始至终只有 10 个 goroutine,不是启动 100 个然后限流。

完整实现

go 复制代码
package main

import (
	"fmt"
	"sync"
)

func main() {
	const size = 100
	const workerNum = 10

	slice := make([]int, size)
	for i := range slice {
		slice[i] = i + 1
	}

	// 容量为 10 的有缓冲 channel 作为任务队列
	jobs := make(chan int, workerNum)

	// 启动固定 10 个 worker
	var wg sync.WaitGroup
	for i := range workerNum {
		wg.Add(1)
		go worker(i, jobs, &wg)
	}

	// 主协程分发任务
	for _, v := range slice {
		jobs <- v
	}
	close(jobs) // 所有任务分发完毕,关闭 channel

	// 等待所有 worker 退出
	wg.Wait()
	fmt.Println("all done")
}

func worker(id int, jobs <-chan int, wg *sync.WaitGroup) {
	defer wg.Done()
	for num := range jobs {
		fmt.Printf("worker-%d: num=%d\n", id, num)
	}
}

执行流程拆解

复制代码
时间线:

1. main 启动 10 个 worker,它们全部阻塞在 <-jobs 等待任务
2. main 开始往 jobs 塞数据
   - channel 缓冲区 10,加上 10 个 worker 在读,吞吐很快
   - 哪个 worker 先读到是随机的 → 无序打印
3. 100 个元素全部塞完,close(jobs)
4. 每个 worker 的 range 检测到 channel 关闭,退出循环
5. wg.Wait() 等 10 个 worker 全部 Done,结束

为什么这么写?

1. 为什么用 range jobs 而不是 for { select }

range 在 channel 关闭时自动退出,干净利落。如果用 for + select,你还得自己判断 ok 值,多写一堆代码没有任何好处。

2. 为什么 close(jobs) 放在分发循环之后?

这是"谁生产谁关闭"原则。主协程是生产者,它塞完所有数据后关闭。10 个 worker 是消费者,它们只读不关。

3. 为什么 channel 缓冲区设为 10?

和 worker 数量一致。意味着即使 10 个 worker 都在忙,主协程还能继续塞 10 个任务进缓冲区而不阻塞。这是一个合理的吞吐平衡点。设太大浪费内存,设太小主协程频繁阻塞。

4. 为什么不需要 context 超时?

100 个 fmt.Printf 不会超时。加 context 是防御性编程,在这个场景下是过度设计。如果你的 worker 里跑的是网络请求,那必须加。


五、两种模式对比

生产者-消费者 Worker Pool
goroutine 数量 固定 N 个 固定 N 个
本质区别 强调"生产"和"消费"的角色分离 强调"固定人手处理批量任务"
channel 语义 数据流管道 任务队列
典型场景 流式处理、管道串联 批量 I/O、并发请求
关闭时机 生产者写完关闭 分发者塞完关闭

其实 Worker Pool 就是生产者-消费者的一种具体形态。区别在于心智模型:你是在想"数据在流动",还是在想"工人在干活"。


六、写并发代码的纪律

  1. 谁生产谁关闭。 消费者永远不关 channel。

  2. Addgo 前面。 没有例外。

  3. channel 方向能标就标。 零成本,编译器帮你守门。

  4. defer 归还资源。 信号量、WaitGroup、锁------全用 defer。

  5. 固定并发用 Worker Pool,动态限流用信号量。 别搞混。

  6. 退出前等 goroutine 收尾。 每次写 return 之前想一想:还有谁在跑?


并发代码的质量不在于跑没跑通,在于你能不能对着每一行说清楚:谁在什么时候执行,凭什么保证。说不清楚,就是在赌运气。

相关推荐
带鱼吃猫2 小时前
预处理器:宏、运算符与条件编译
c语言·开发语言
拾陆楼2 小时前
PT: DMSA辅助调tree报告前后级余量脚本
后端·学习
今天的砖头有点烫手啊2 小时前
接口太慢?Spring Boot 缓存体系 @Cacheable 全链路拆解
spring boot·后端·缓存
小小龙学IT3 小时前
C++ std::vector 底层实现深度解析:内存布局、扩容策略、移动语义与迭代器失效
开发语言·c++·算法
码匠许师傅3 小时前
【C++ 面试真题】聊聊 volatile 和 mutable
开发语言·c++·面试
xcLeigh3 小时前
Go入门:短变量声明的陷阱与最佳实践
java·redis·golang·教程·变量
(Charon)3 小时前
【C++】线程安全队列(二):生产者消费者模型与 condition_variable 阻塞等待
c语言·开发语言·c++
liwulin05063 小时前
【PYTHON】使用Selenium + ChromeDriver以及XPATH语法
开发语言·python·selenium
JavaPub-rodert3 小时前
我又把自己的 Go 后台管理系统升级了一遍:文件管理、2GB 上传、私有文件预览、Docker 镜像全安排上了
开发语言·docker·golang·shiyuadmin