Go并发需控制边界、明确通信、及时回收,盲目go f()易致OOM或压垮下游;用make(chan struct{}, N)实现轻量并发控制,语义清晰、无锁、零分配。

Go 语言的并发不是靠“开很多 goroutine 就完事”,而是靠控制边界、明确通信、及时回收——不加限流的 go f() 在批量任务里大概率会把内存打爆或把下游打挂。
用 go 启动 goroutine 的真实风险
直接在循环里写 go process(task) 看似简单,但任务量稍大(比如 1000 个 HTTP 请求),就会瞬间创建 1000 个 goroutine。它们不是立刻执行完,而是排队等待调度、占用栈内存、争抢网络连接池或文件句柄。常见现象包括:
- 程序 OOM 被系统 kill
- 下游返回
429 Too Many Requests或连接超时 - goroutine 数飙到几万,
runtime.NumGoroutine()持续上涨不降
根本原因不是语法错,是没做资源节制。别把 go 当成“多线程开关”,它只是协程创建入口,节流必须手动加。
make(chan struct{}, N) 是最轻量的并发控制器
Go 没有内置信号量,但用带缓冲的空结构体 channel 做计数器,语义清晰、无锁、零分配。它只占一个指针大小,比 sync.Mutex 或原子操作更符合 Go 的通信哲学。
立即学习“go语言免费学习笔记(深入)”;
正确用法:
- 初始化:
sem := make(chan struct{}, 10)表示最多 10 个并发 - 获取许可:
sem (阻塞直到有空位) - 释放许可:
,且必须用 <code>defer包裹,否则 panic 或 return 早于释放会导致卡死 - 切忌在 goroutine 内
close(sem)——信号量要复用,关了就废了
错误示范:sem := make(chan int, 5) 却忘了初始化缓冲区,或漏写 defer,后续所有任务都会永久阻塞在 sem 上。
使用 uber-go/dig 在 Golang 中实现依赖注入——基于反射容器,提供 Provide/Invoke、dig.In/dig.Out 参数与结果对象,支持命名依赖等
结果收集必须带索引,别信执行顺序
goroutine 完成时间不确定,往共享 slice 里按循环变量 i 直接写,会因闭包捕获导致所有任务写同一个下标——这是高频竞态 bug。
安全做法:
- 定义结果结构:
type TaskResult struct { Index int; Data interface{}; Err error } - 开带缓冲 channel:
results := make(chan TaskResult, len(tasks)) - 启动时显式传参:
go func(idx int, t Task) { ... }(i, task) - 主 goroutine 循环接收
len(tasks)次,按result.Index填入最终切片
这样结果天然保序,且避免了对 sync.Mutex 或 sync/atomic 的依赖。
errgroup.Group 适合需要错误传播和上下文取消的场景
如果你的任务要支持超时、中途取消、任一失败就停其余,errgroup.Group(来自 golang.org/x/sync/errgroup)比裸写 channel 更稳:
- 初始化:
g, ctx := errgroup.WithContext(context.WithTimeout(context.Background(), 30*time.Second)) - 设限:
g.SetLimit(8)(Go 1.21+),旧版本需自己封装 semaphore - 启动:
g.Go(func() error { return processWithContext(ctx, task) }) - 等待:
if err := g.Wait(); err != nil拿到第一个非 nil 错误
关键提醒:它不会自动中断正在运行的 goroutine,你得在 processWithContext 里主动检查 ctx.Err(),比如用 http.NewRequestWithContext(ctx, ...),否则超时后 goroutine 还在跑。
真正难的不是“怎么并发”,而是判断瓶颈在哪——是 CPU?磁盘寻道?HTTP 连接池?还是目标服务的 QPS 限制?并发数调太高反而降低吞吐,这个阈值必须实测,不能拍脑袋定。

















