上篇限制了同时执行和排队的数量。队列有了上限,另一个问题就无法回避:调用方准备退出时,多个提交者仍可能正在等待空槽。若一个 goroutine 关闭输入 channel,另一个 goroutine 恰好发送,关闭过程本身便可能 panic。Close、cancel、等待退出必须各有责任,本篇在同一个累计工程的原 worker 路径上补关闭协议。完整工程与实验包、SHA-256和解包复跑说明保存本章实际代码及正反证据。

能关闭 channel 的不应是任何一个提交者

Go 1.27.1 的语言规范 Send statements 明确:向已关闭的 channel 发送会触发运行时 panic。testdata/send-after-close.go 是单独构建的故意错误程序;本次直接执行二进制得到 panic: send on closed channel,退出 2。这不是生产执行器发生的故障,而是要排除的错误设计。只在发送处先检查一个 closed 布尔值也不够:从检查通过到真正发送之间,另一 goroutine 仍可把 channel 关闭。

原批处理只有一个生产者,由它关闭输入;这能回答先前章节,却不能回答并发 Submit 与 Close。现在第 20 篇的 worker 循环移入 taskExecutor,批量 CLI 也调用同一个执行器,没有复制一份互不相干的 worker pool。它持有输入 channel、结果 channel、取消函数,以及由 Mutex 保护的 closing 和 submitters。提交者先在锁内检查是否已进入关闭状态,并登记自己为在途提交者,才尝试向队列发送;无论成功、被 context 取消,还是因队列满被拒绝,离开前都会减去自己的登记数。

Close 首次在锁内设置 closing,关闭 stopAccepting 通知在途提交者停止等待。若登记数已为 0,Close 自己关闭 inputs;否则最后一个退出的提交者关闭它。两条路径均处在同一把锁下,不会同时关闭两次。其余调用 Close 的 goroutine 不再操作输入 channel,只等待 submissionsDone。这里不用拿着 Mutex 等待空队列:等待者要退出并减掉登记数,仍须能拿到这把锁。

1
2
3
4
5
6
7
8
9
Submit:锁内登记 ─→ 可取消发送 ─→ 锁内注销
│ │
Close:锁内标记关闭 ─→ 唤醒等待 ─→ 登记数归零后唯一关闭 inputs
│
worker 排空已接收项
│
全部 worker 退出,收集者结束
│
Wait 返回

关闭请求不是所有提交的共同截止点。一个在 Close 开始之前已登记的 Submit,可能与 stopAccepting 的关闭竞争:若输入发送与停止通知同时可选,它仍可能成功,并在 Close 返回之前给调用者一个 nil。只有 Close 返回,才能断言没有在途提交者、后续 Submit 会得到 ErrExecutorClosed。此时队列中早已接受的项目仍可能继续被 worker 领取;关闭输入只表示不会再追加项目,并非队列立刻为空。Close 返回本身也不代表 worker 已经完成。

优雅关闭与取消关闭分别解释哪些任务

Close 不调用 cancel。调用者仍要执行 Wait,直到 worker 组结束、输出 channel 关闭、收集者把结果按输入位置保存,才读本次批次的结果。这个顺序使“已接收但尚在队列里的任务”能在优雅关闭时走完原来的统计与 TaskStore 状态提交;不会把 Close 偷换成“扔掉积压”。调用 Wait 之前如果从未 Close 或 Shutdown,等待可能一直不结束,Wait 不是隐式关闭按钮。

Shutdown 选择另一种含义:先对执行器 context 发出取消请求,再执行 Close 和 Wait。context.CancelFunc 的固定版文档明确:调用 cancel 不等待工作停止。收到取消以后,空队列的 worker 可退出,阻塞的提交可返回,排队记录可以取消;正在执行的分析器却要自己检查 context 或走到返回点。生产执行器在分析器返回后再检查取消,RUNNING 的记录先置取消请求,交由锁内完成提交点决定终态;worker 全部退出后 CancelOpen 清理尚未到终态的记录。重复调用 Shutdown 不会重复关闭 channel 或将终态翻回 RUNNING。

这套 API 仍限定在本次进程内、预分配位置的批次。TaskStore 在 Submit 之前就为给定批次的所有位置建记录,所以“能 Lookup 到预置 QUEUED 占位记录”不能反推出该位置的 Submit 已被接受。接受以 Submit 返回 nil 为准,已接受项须在 Close/Wait 后有可解释的成功、失败或取消状态;提交时的 ErrQueueFull、context 错误、ErrExecutorClosed 属于未获接收的一侧。测试按位置核对成功提交数和终态数,没有拿 store.records 长度冒充接收数。同一位置重复 Submit 还不是合法业务 API;任务 ID 的分配与持久化要在后面的服务章节另外定义。

闸门能区分“请求停”与“确实停了”

TestExecutorConcurrentSubmitAndRepeatedClose 先让两个 worker 停在闸门,容量 1 的队列接收第三项,再同时放行 61 个 Submit 与 3 个 Close 调用。受控运行记录 close callers=3; accepted=3; rejected=61; post-close rejected; active-after-wait=0。本次接受的 3 项在闸门释放后均到达 SUCCEEDED,终态计数也是 3;拒绝者得到 ErrExecutorClosed,而非一次发送到已关闭 channel 的 panic。接受数 3 是这组输入和闸门下的本次观察,不许外推为任意调度下“并发 Close 总会接收 3 项”。

取消路径用另一个闸门:TestExecutorShutdownWaitsForWorkerAndCancelsAccepted 让一项 RUNNING、一项已排队。请求 Shutdown 后,在分析器仍被闸门挡住时调用方没有返回;放行后才看到两项已接受任务均为 CANCELED、结果字段未错误写入、终态数 2。第二次 Shutdown 和随后 Submit 分别验证幂等和 ErrExecutorClosed。原 CLI 的批量输出仍按位置排列,--workers 0 与 --timeout 0s 各按错误路径退出 2,标准输出为空;对不合作分析器的取消并不会因为设置了截止时间就强制释放 worker。

这一批实验的固定环境为 Go 1.27.1/linux-amd64、GOTOOLCHAIN=local、GOMAXPROCS=2、CGO_ENABLED=1、gcc 13.3.0。本章两组闸门和先前有界测试重复 100 次通过,两包完整测试退出 0,两包 go test -race 本次退出 0;无 race 报告只限真正执行的路径。writing-plans/go-language/evidence/21/ 保存每项真实命令、原始 stdout/stderr 和退出码,独立故意 panic 的结果另算,不能拿静态页面的 200 代替 Go 测试。

练习:在 Close 中先 close(inputs),随后再设 closing = true,只给 Submit 开头加一次“是否关闭”检查,能否避免发送到已关闭 channel?答案:不能。一个提交者可以在检查通过后被切走,Close 先关闭输入,提交者恢复执行时向已关闭 channel 发送;本版规范要求 panic。需要先阻止新登记,同时确保所有已登记发送者结束后才唯一关闭输入。即便改成上述协议,Close 与在途发送竞争时仍可能产生成功的提交,所以“调用 Close 的瞬间一律拒绝”也不是本工程承诺的契约。关闭是否已完成以 Close 返回为准,worker 是否已结束以 Wait/Shutdown 返回为准。

这还是进程内批处理执行器,不是已经上线的 HTTP 服务。后续 HTTP 应答必须和任务是否完成分别核验;真实 SQL 提交与任何外部副作用也不能混为一次动作。HTTP/SQL、其他平台、全站构建含本篇及发布均 NOT_RUN。Go 源码 release tag go1.27.1、镜像查询的完整 Git SHA 862c888e612ac346c7c4d99c9392bdfd265f33b0 沿用基线,官方 Gitiles 直接交叉核验仍 NOT_RUN;本章的关闭语义依据公开规范、固定 API 及本机测试,没有根据 runtime 实现猜测调度选择。

参考资料