用带缓冲的chan struct{}实现信号量可限制同时打开文件写入的goroutine数量,缓冲容量即最大并发数,需全局复用、先获令牌再打开文件、defer归还并结合context防阻塞。

用 semaphore 控制同时打开的文件写入 goroutine 数量
Go 没有内置信号量,但用 sync.Mutex + sync.WaitGroup 或更简洁的 chan struct{} 就能实现轻量级计数型限流。核心思路是:每次写入前从带缓冲的 channel 获取一个“令牌”,写完后归还。缓冲容量即最大并发数。
常见错误是把 channel 声明在函数内(每次调用新建),导致限流失效;或忘记用 defer 归还令牌,造成 goroutine 泄漏。
- 限流 channel 必须是全局或长期存活的变量,例如定义为包级变量或传入结构体字段
- 使用
make(chan struct{}, N),N 即最大并发写入数(如3表示最多 3 个文件同时写) - 写操作前执行
<-sem(阻塞直到有空位),写完立刻sem <- struct{}{} - 不要用
close(sem)—— channel 关闭后不能再发送,会导致 panic
避免 os.OpenFile 被限流误伤其他 IO
限流目标只是「写入动作」,不是「打开文件」。如果在获取令牌前就调用 os.OpenFile,可能提前占用文件句柄、触发磁盘 IO,且这些操作不受控——尤其当文件路径错误或权限不足时,OpenFile 失败但令牌已被占,后续 goroutine 会卡死。
正确顺序是:先拿令牌 → 再打开文件 → 写入 → 关闭 → 归还令牌。这样失败只消耗一次尝试,不阻塞队列。
立即学习“go语言免费学习笔记(深入)”;
-
os.OpenFile和f.Write必须放在<-sem之后 - 用
defer f.Close()确保关闭,但注意:若f是 nil(OpenFile失败),Close()会 panic,需加非空判断 - 不要把
os.Create放在限流外——它等价于OpenFile+ flag,同样触发底层 IO
写入大文件时注意 io.Copy 和缓冲区的关系
用 io.Copy 写大文件很常见,但它内部会分配默认 32KB 缓冲区,并反复调用 Write。如果并发数设得太小(比如 1),而单个文件极大,整个程序会像单线程一样慢;设得太大(比如 50),又可能打满磁盘带宽或触发系统 open files 限制。
这不是限流逻辑的问题,而是资源匹配问题。建议根据实际磁盘类型(SSD/HDD)、文件平均大小、系统 ulimit -n 值来调优。
- SSD 上可设
5–10并发;HDD 建议2–4 - 用
runtime.GOMAXPROCS(1)无法限制 IO 并发,Go 的 goroutine 调度不控制系统调用阻塞 - 若需细粒度控制每次
Write大小,可改用bufio.NewWriterSize(f, size),但限流仍作用于整个写入流程
用 context.Context 支持超时与取消
仅靠 channel 令牌无法中断等待中的 goroutine。比如限流设为 1,但某个写入卡住(网络文件系统挂起、磁盘故障),后续所有请求会在 <-sem 处无限阻塞。
必须把限流和 context 结合:用 select 等待令牌或 context done,任一满足即退出。
- 不要直接
<-sem,改用select { case <-sem: ... case <-ctx.Done(): return ctx.Err() } - 写入过程中也应定期检查
ctx.Err(),尤其在循环写多个 chunk 时 - 归还令牌的动作必须在 defer 或 finally-style 分支中完成,否则 cancel 后令牌丢失,限流器永久卡死
真正容易被忽略的是:限流 channel 的生命周期管理。它不能随一次批量写入任务创建销毁,也不能在 HTTP handler 里每次 new 一个——必须复用。另外,错误路径里的令牌归还,比成功路径更容易遗漏。


















