上篇让生产者和消费者退出时都有明确的等待者,但原来只有一个消费者、容量为 1 的输入 channel。给它加大缓冲并不能自动限制同时运行的工作:执行数由消费者数量决定,排队数由 channel 容量决定,这两个上限回答的是不同问题。本章给同一个任务工程的原队列加入固定 worker 数和可选的排队容量,用可控闸门检查峰值、满载和取消。完整工程及实验包、SHA-256与解包指令可复核,不需要另建一份演示工程。

容量 1 到底约束了什么

设两个 worker、输入 channel 容量 1。两个 worker 已分别取出一项并开始统计时,队列还可以保留一项。此时系统最多有三个已交给执行器、尚未结束的任务:两个正在执行,一个等待领取;不是“只有一个任务能进入系统”。接收端取走 channel 中的一项,那个等待槽位才重新可用。不能只用 len(inputs) 算所有占用,因为 worker 领取后元素已经不在 channel 中。

工程的 processTasksContextWithLimits 收到 workerCount、queueCapacity,分别初始化 workerCount 个消费者和固定容量的 inputs。原单生产者只负责提交并关闭输入;所有消费者只接收,任何消费者都不关闭输入。workers.Add(workerCount + 1) 在启动前登记了生产者与 worker;独立 goroutine 等这组成员全部结束后才关闭输出。调用方先排空无缓冲的 outputs,再根据生产者错误或 context 返回。若反过来先等待成员退出,而 worker 又卡在发送结果,就会造出自锁。旧 API 和 CLI 默认仍使用 1 个 worker、容量 1,已有测试不会因本章悄悄变成并发多消费者。

1
2
3
完整 batch 切片 → 单生产者 → [输入 channel:最多 Q 项] → W 个 worker → 输出/状态提交
↓
调用方排空输出、等待全部退出

本版 Go 1.27.1 的语言规范 Channel types、Send statements 决定缓冲 channel 满时发送会阻塞,以及发送与关闭的条件;内存模型的 Channel communication 说明匹配发送和接收之间的同步边。不会由这些规则推出“哪一个 worker 一定先取到任务”或每个任务的运行耗时。这里用位置编号写入预分配结果切片,保障返回按提交顺序排列;没有把完成顺序冒充提交顺序。TestBoundedPoolPeakAndQueueReuse 的两个 hold 任务分别占据两名 worker,闸门不放行时调用方不返回;放行后六项全部完成,测试记录 workers=2 queue=1 peak=2 completed=6 active-after-wait=0。峰值来自本机受控输入,不是对任意运行时调度的实测上界证明;在代码结构上同一时刻只能有两个调用分析器的 worker。

满队列:等待还是拒绝

队列不能悄悄无限扩容。submitTask 在旧 sendTask 的可取消等待之外提供一个明确的策略开关:等待模式在队列满时阻塞,直到可以发送或 context 取消;立即拒绝模式在 channel 当前无法发送时返回可用 errors.Is 判断的 ErrQueueFull。TestBoundedPoolRejectsFullQueue 先把容量为 1 的 channel 装满,第二次提交得到 task queue full;取出原项后,第三次可以进入腾出的同一个槽。随后把 12 项送进两 worker、容量 1 的同一执行路径,受控分析器在释放或取消前不结束,观察到整批过载返回同一错误。这比只造一个满 channel 更进一步,验证生产者将该错误传播给执行器调用方,并请求停止、等待原先已经启动的工作退出。

当前 CLI 的 --workers 2 --queue-capacity 1 只启用等待模式;内部拒绝模式尚未暴露为外部协议。立即拒绝是一次提交尝试的返回值,不是对“已有任务已经执行成功”的解释。生产者遇到拒绝后会请求本批次取消、排空输出并等待自己拥有的 worker 后返回错误;TaskStore 对尚未完成的本批次记录做取消收尾。此阶段不能把这套批处理语义称为一个已经解决并发提交、重复关闭、重试、已接受任务解释的服务。下一篇 21 才定义停止接收和关闭竞争的协议。

CLI 两个数都限定在 1–128:--workers 0 在创建任务前退出 2,stderr 为 workers and queue-capacity must be between 1 and 128;三项批量输入在两 worker、容量 1 下输出三个原位置结果。数字 128 是本工程为参数设的范围,不是 Go 对 channel 的限制。实际 --batch 会先 strings.Split 出全部文本,newTaskStore 预建全部记录,结果也按输入数量分配;channel 容量与 worker 数虽固定,整体内存仍随批次长度增长。若读者需要端到端内存预算,应改造输入流、结果收集和存储,不可给现有 CLI 贴上“有界输入”标签。

取消请求仍不等于工作退出

多 worker 让取消时序更容易误读。若分析器不理会 context,只看 cancel() 已调用就判断槽位释放,会把仍在统计的工作计入空闲。TestBoundedPoolCancelWaitsForSlotsAndRecords 先让两个分析器同时进入闸门,调用 cancel() 后确认调用方仍未返回、active=2;释放闸门后才观察 active=0 terminal=3,两个运行中与一个未完成记录才有终态。worker 从分析器返回后检查已取消的 context,并对还在 RUNNING 的记录提出取消请求;Complete 在同一把状态锁下把它提交为 CANCELED,不把本次闸门解除后的统计结果误写成成功。未领到的记录在所有 worker 退出后由 CancelOpen 收尾。若取消与完成提交竞争,可能得到成功;判断以取消标记和结果在锁内的提交先后为准,不能只凭 cancel() 的墙上时间倒推记录提交顺序。

输入 channel 的容量并不是可抢占计算的 CPU 配额。不合作分析器在闸门未放行时继续占着 worker,即便输入排队数是 0,服务仍不能许诺“取消即释放资源”。context.Context 的 Done/Err 给的是通知/分类;完成确认来自 worker 与生产者实际退出后关闭的输出和等待组。go test -count=50 重跑了容量/取消以及前章退出和状态测试;完整两包测试退出 0,本机 CGO_ENABLED=1、gcc 13.3.0 的两包 race 也退出 0、未报告 race。最后一句只适用于执行过的路径,不是“全路径并发安全”认证。所有命令、环境、原始 stdout/stderr、退出码在 writing-plans/go-language/evidence/20/;此处引述的数字来自实际运行,不是吞吐量基准测试。

练习:若将 --queue-capacity 1 改为 100,而 worker 仍为 2,是否可保证第 101 个任务立即被拒绝且服务最多保存 102 项输入?答案:都不能。CLI 采用等待提交而非拒绝;两个 worker 是否已经取走任务、队列当前是否空闲还取决于执行进度。更重要的是整个 --batch 在开始提交前已经完整保存在切片,结果和任务记录也按批量预分配,所以总内存仍可超过 102 项。若改为立即拒绝,判断点也只是提交当时的 channel 状态,不能凭总序号推断这次操作必定失败。

本章只涉及进程内批处理:还没有持续服务的关闭协议,HTTP 请求应答不等于业务完成,真实数据库提交和外部副作用均 NOT_RUN。Go 工具链固定为 1.27.1;源码 tag go1.27.1、镜像查询的完整 Git SHA 862c888e612ac346c7c4d99c9392bdfd265f33b0 沿用基线,官方 Gitiles 交叉核验仍 NOT_RUN。本章不依据 runtime 内部调度策略解释哪个 worker 优先。其他平台、发布也没有运行。

参考资料