# 前言：让服务在故障中仍能交代清楚

写出一个返回 JSON 的 Go 接口并不难。难的是上线之后回答这些问题：用户已经离开，为什么数据库查询还在执行？队列长度看上去很短，为什么 goroutine 数一直增长？服务收到了退出信号，为什么仍有任务写了一半？上游没有报错，为什么新的请求迟迟拿不到连接？这些问题属于服务的运行边界，不能靠增加几个 `go` 关键字解决。

本书面向已经能够实现 HTTP 接口、读懂结构体与接口的后端开发者。读者可以带着 Java、网关、消息系统或 Agent 基础设施的经验进入，但不要把 Go 的 goroutine 直接换算成 Java 线程池，也不要把 context 当成可以自动终止任务的控制器。我们从每个资源由谁创建、谁继续使用、谁有权关闭开始，逐步建立可以验证的服务行为。

作者 Aias00 是 Apache ShenYu PMC 成员，长期关注 Java、Go 与 Agent 基础设施。本书采用这类工程工作的一个朴素判断：当一个结论不能落到代码、约束和故障验收上时，它暂时只能算设计意图。书中没有借用未公开生产系统为实验背书，也没有把本机的一次基准测试写成生产容量承诺。

贯穿全书的工程位于实验包根目录。它只有标准库依赖，包含一个 HTTP 服务、容量固定的后台工作池、复用连接的外呼器，以及可在真实 TCP 和子进程上执行的测试。请求可以等待工作，也可以提交服务负责的后台任务。我们故意制造队列饱和、取消、未读完的响应体、慢读取和退出超时，然后检查最终资源状态。

本地实验环境是 `go version go1.24.3 darwin/arm64`。持续集成固定使用 Go 1.24.3，在 Linux 上重新编译并执行 race 和行为测试。操作系统调度、TCP 缓冲区与计时结果会变化，因此验收尽量围绕“任务是否退出”“在途数量是否受限”“关闭后是否拒绝接纳”展开。程序输出和实际测试清单位于附录及实验包的 `EXPERIMENTS.md`；每一个输出都有对应命令与适用范围。

阅读顺序也反映了问题的依赖。先理解取消与资源所有权，再讨论 channel、锁和有界接纳；然后进入 HTTP 输入、输出和连接；最后研究退出协议、数据竞争与诊断。你可以跳到正在排查的问题，但仍建议先读第一、三和十一章，它们说明整个工程的责任边界。

每章练习都要求产生可观察结果，而不是只添加一段代码。比如“支持取消”必须能证明阻塞的工作确实退出；“优雅退出”必须能证明接纳关闭、已接纳任务的边界和进程状态；“修复连接泄漏”必须让受限制的下一次请求恢复。练习不要求购买云服务，不需要数据库容器，不依赖语言模型接口。

EPUB、网页和完整 Markdown 来自同一份有序书稿；完整代码附录在构建时直接读取实验工程。下载实验 ZIP 后，解压到一个空目录，先执行 `go version`，再执行 `go test -race ./...`。代码与文字的可追溯关系是本书的一部分：如果你改了实现，应当重新运行实验，修改对应解释，然后一起发布。

本书希望帮助你形成一种更可靠的工作方式：先写明边界，再在边界上制造失败，最后依据证据缩小结论。一个服务不需要永远不失败，但需要能够在失败时及时停止无用工作、保护有限资源，并留下足够的信息让下一位工程师看懂发生了什么。


# 第1章 服务工程首先是一组运行契约

## 从返回值走向生命周期

一个处理函数在正常输入下返回正确结果，只完成了接口的一部分。服务还要回答：请求最长占用多久，等待任务最多排多少，客户端断开后谁继续工作，退出时谁停止接收，失败会释放哪些资源。接口文档通常只描述状态码和字段，运行契约则把时间、数量与责任也纳入可验证范围。

随书工程提供 `/work`、`/tasks`、`/stream` 和 `/stats`。`/work` 的工作属于当前请求，客户端取消后继续执行没有意义。`/tasks` 返回接纳结果后，任务归工作池管理，它不能继续借用请求的取消信号，也不能借用即将回收的请求对象。`/stream` 把输出端的速度纳入资源预算。`/stats` 只统计工作池接纳的后台任务，包括 active、queued 与 completed；它不统计 `/work` 和 `/stream` 的 HTTP handler。后两者通过日志和测试中的 handler 退出通知观察。

这四个端点服务于同一个问题：HTTP 连接、业务工作和后台执行的生命周期并不相同。请求结束不一定代表任务完成，任务完成也不一定代表连接已经释放。将这些事件合并成一个“成功”计数，会让关闭与重试的判断失去依据。

## 写出真正可以失败的承诺

先为工程写下四条承诺。工作池只有固定数量的 worker；等待区只有固定容量；接纳关闭后不再成功提交；所有已启动工作最终会完成或响应服务取消。这里的“最终”必须有测试截止时间，不能写成无限等待。超时失败应保留诊断信息，而不是挂住整个测试进程。

这些承诺是有条件的。Go 无法强行终止一个任意函数，如果函数进入不检查取消、也没有网络截止时间的阻塞操作，工作池不能兑现及时退出。实现因此要求工作函数协作响应 context，外呼器把同一个 context 传入请求，流式输出设置写截止时间。工程保证必须建立在其依赖接口也遵守边界的前提上。

`202 Accepted` 在这里表示任务进入了本进程的内存管理范围。它不表示任务持久化，不保证机器掉电后恢复，也没有跨进程去重能力。把“进程内已接纳”写成“可靠消息投递”，会让读者误用教学工程。后续章节会讨论如何把内存任务升级为数据库状态机，但这不是本书实验的既有能力。

## 数量关系比一次快照更有用

假设 worker 数为 W，队列容量为 Q。在没有额外隐藏缓冲区的实现中，正在执行的任务最多 W 个，排队任务最多 Q 个。已接纳但未终结的任务数由二者共同约束。`len(channel)` 只能观察某一瞬间，不是接纳许可；先查看长度再发送会出现检查与动作之间的竞争。

测试通过人为阻塞工作函数建立稳定状态：先占满 worker，再装满队列，最后提交额外任务。这样可以明确证明拒绝来自容量边界，而不是机器恰好很慢。解除阻塞后，再验证所有已接纳任务进入终结状态。这种安排比发出一千个请求然后看平均延迟，更容易解释。

监控中的不同计数也要有清晰语义。`accepted` 在接纳发生时增加，`started` 在 worker 获得任务时增加，`completed` 表示所有终结，`failed` 是其中的错误终结，`cancelled` 又是可识别取消错误的子集。它们不能直接相加；关闭后可以检查 accepted 与 completed 是否相等。运行过程中取多个原子计数并不一定得到同一时刻的全局快照。

## 建立最小复现习惯

先在实验包中运行：

```sh
go version
go test -race ./...
go run ./cmd/service
```

第一条命令是环境证据；第二条验证当前执行路径的数据竞争与行为约束；第三条启动真实进程。不要把三个命令的结论互相替代。race 通过不说明退出协议正确，启动成功不说明过载时有限，单元测试通过也不说明公网代理正确处理流式响应。

调试时应保留请求输入、服务配置、观察窗口与最终状态。只有一句“偶尔卡住”很难复现；“两个 worker、一个等待槽、三个任务被闩锁阻塞，第四次提交立即拒绝”则提供了可以行动的边界。工程交流的质量往往取决于是否能把现象压缩成这样的条件。

## 练习：把接口说明变成验收表

为一个你熟悉的接口分别写出请求截止、最大输入、外呼截止、并发上限与退出责任。挑选其中最容易被违反的一条，用可控阻塞而不是随机延迟构造失败。验收结果必须包含拒绝是否及时、已接纳工作是否丢失，以及解除故障后服务是否恢复。记录哪些结论由测试证明，哪些仍然只是部署假设。

如果你的接口提交异步任务，再补充一句客户端可依赖的语义：响应是否意味着持久化、任务失败到哪里查询、响应丢失后如何重试。即便本轮不实现持久化，也要写清当前能力的范围。边界清楚的简单系统，比暗示了过强保证的复杂代码更容易可靠使用。

## 本章实测证据

固定worker和等待槽构成边界；测试先占满运行槽再装满队列，检查多余提交立即拒绝，释放后所有已接纳工作终结。 以下摘自最终本机 `go test -race -v ./...`，完整命令及环境见附录B；括号中的耗时仅是当次运行记录。

```text
--- PASS: TestPoolQueueBound (0.00s)
```


# 第2章 context 是取消协议，不是终止开关

## 一条从调用者延伸到依赖的链

context 携带截止时间、取消信号和请求范围的信息。HTTP 服务的请求已经有 context，处理函数通常从 `r.Context()` 出发，把它传给下游函数和 `http.NewRequestWithContext`。这条链必须延伸到实际可能阻塞的位置。只在最外层创建超时，内层却使用 `context.Background()`，得到的只是一个会准时放弃等待的调用者。

取消并不执行 `kill goroutine`。调用 cancel 后，`Done()` 关闭，`Err()` 可以报告取消原因，真正的工作仍需观察信号并返回。CPU 循环、等待 channel、睡眠和网络调用分别需要自己的响应方式。不能因为函数签名接受了 `ctx`，就认为它已经实现了取消。

随书 `/work` 的延迟使用可取消等待：工作等待计时器或 `ctx.Done()`，哪个先到就结束。若改成无条件 `time.Sleep`，客户端超时之后 handler 的 goroutine 仍会继续存活，直到睡眠结束。这个端点不进入后台工作池，因此也不占用 Pool 的 active 名额。响应可能已经无法写给客户端，资源却继续活着。这正是取消测试必须检查工作退出、不能只检查客户端报错的原因。

## 预算不能在下游重新充满

父请求只剩两百毫秒，下游却新建三秒超时，父 context 仍会把有效截止限制在较早的时间。如果下游改用后台 context，这一限制就消失了。服务应把总预算拆成接纳等待、连接、响应头、读取响应体和结果处理，而不是每一层重新许诺完整超时时间。

`WithTimeout` 返回的 cancel 应当调用，即使计时器最终会自行触发。提前释放关联资源比等待超时更明确。常见写法是创建后立即 `defer cancel()`；但在长期循环里，每次迭代都 defer 会延迟到整个函数返回。把单次操作抽成小函数，或者在该次操作结束时显式取消，才能让生命周期与作用域对齐。

截止时间是一个绝对时间点，超时是从当前计算的时长。日志中同时记录操作阶段和剩余预算，比只记录配置中的“三秒”更有用。收到 context deadline exceeded 时，原因可能是此前排队已经消耗预算，而非真正执行的那段代码运行了三秒。

## 等待也需要退出路径

任何可能一直等下去的发送或接收，都应考虑取消分支。典型形式如下：

```go
select {
case result := <-results:
    return result, nil
case <-ctx.Done():
    return zero, ctx.Err()
}
```

这段代码保证等待者可以离开，但不保证生产者可以离开。生产者若随后向无缓冲结果 channel 发送，会因接收者已退出而阻塞。解决办法取决于所有权：生产者发送时也观察取消，或者为只发送一次的结果准备容量为一的 channel。缓冲区只是容纳这一条结果，不能替代生产者对其他资源的清理。

select 不为取消提供绝对优先级。当结果和取消都已就绪，执行哪个分支取决于调度。因此若业务规定取消后不能提交副作用，需要在提交边界重新校验状态，必要时用锁或事务把检查和动作组合起来。context 的传播解决通知问题，不自动解决提交仲裁。

## 原因、值和生命周期各司其职

`context.Cause` 与带原因取消可以帮助区分服务关闭、依赖失败和请求取消，但错误分类仍应保持稳定。不要把用户输入原样作为取消原因记录到日志，也不要依赖错误文本做状态机判断。HTTP 返回给客户端的说明可以简短，内部证据则包含阶段、原因类别与请求标识。

context 的 Value 适合请求范围、跨接口传播的小型元数据，例如已经验证过的追踪标识。它不是依赖注入容器，不应藏数据库连接、锁或一大块请求体。值可以被多个 goroutine 读取，值所指向的对象却不会自动变成并发安全。将可变 map 放进 context 并不能获得同步保障。

`WithoutCancel` 可以保留值而脱离父级取消，但它不会替你确定新任务的截止和关闭责任。后台任务如果使用它，必须再挂到服务拥有的 context，并明确完成与失败记录。随书工程通过任务数据复制和工作池 context 建立所有权转移，避免让脱离取消成为随手绕过超时的办法。

## 练习：观察取消后的第二个结果

启动服务后，每次只发一个请求，避免没有请求 ID 的演示日志互相混淆。例如运行 `curl --max-time 0.05 'http://127.0.0.1:8080/work?delay=5s'`，先确认客户端超时退出，再检查对应的 `request started` 和 `request finished` 日志。正常实现应很快记录 `request finished` 且 error 为 `context canceled`，表示可取消等待已经结束。若故意把等待替换成 `time.Sleep`，客户端仍可能按同样预算返回，结束日志却要等五秒睡眠之后才出现。

不要用 `/stats` 的 active 判断这次请求是否释放：它只统计后台任务，对 `/work` 始终没有这项观察能力。若要严格确认整个 handler 已返回，参考 `TestDrainRequestsWaitsForHandlerAndGatesAdmission`：在 `ServeHTTP` 返回之后关闭测试专用的 finished 通知，再由测试等待它。日志定位阶段，通知证明返回，两者的证据范围不同。恢复实现后运行 race 与重复测试。

本次补丁在真实服务子进程中复核上述方法：五秒 `/work` 请求在客户端五十毫秒预算下返回 curl 退出码 28；随即观察到 `request finished` 的 `context canceled`，再发 SIGTERM，进程正常退出。这个结果没有使用 Pool 计数推断请求生命周期。

继续把取消放在工作刚开始、工作即将结束、结果已经生成三个位置。测试不应要求某一种调度顺序，而应要求不会永远阻塞、不会重复完成、资源最终释放。练习的产物是一张状态表：调用者观察到什么，执行者最终发生什么，两者在哪些竞争窗口可以不同。

## 本章实测证据

读取在响应头之后阻塞，取消必须使读取返回，并让下一次请求恢复。它验证的不是仅有调用者离开，而是实际网络资源释放。 以下摘自最终本机 `go test -race -v ./...`，完整命令及环境见附录B；括号中的耗时仅是当次运行记录。

```text
--- PASS: TestFetcherBlockedBodyCancellationReleasesConnection (0.05s)
```


# 第3章 goroutine 和资源必须有人负责到底

## go 语句只负责启动

启动 goroutine 的语法没有返回句柄，没有默认的父子关系，也没有自动 join。创建它的函数返回之后，它仍可能继续运行。因此每个 `go f()` 都应能回答四个问题：谁让它停止，谁等待它结束，错误交给谁，使用的资源由谁释放。如果这些答案都散落在注释里，代码结构通常需要调整。

可以把所有权理解为最终清理责任，而不是独占使用权。一个共享 Transport 可以供很多请求并发使用，但服务负责在不再需要时关闭空闲连接。一个任务可以被 worker 执行，工作池仍负责记录它的终结并等待 worker。通过参数传递指针不会自动转移责任，转移必须发生在明确的接纳动作上。

随书 `/tasks` 接收的是短小任务描述。请求参数经校验后构造成独立数据，工作池接纳成功才成为后台任务；提交失败则由请求直接返回错误。不能把 `*http.Request`、请求 Body 或 ResponseWriter 放到后台队列里。处理函数返回后，这些对象的协议生命周期已经结束，后台继续使用既难以推理，也容易出现竞争。

## 借用、共享与转移

借用意味着使用时间不能超过拥有者的范围。处理函数同步读取请求头属于借用；在后台保存请求头 map 的引用则延长了借用，通常应该复制需要的字段。共享意味着多个执行者共同访问同一对象，需要同步和关闭协议。转移意味着接收方接管清理，发送方不能再按原责任关闭它。

这些区分直接影响 channel 的设计。如果通过 channel 发送缓冲区，发送的是 slice 描述符，不是底层字节的深拷贝。生产者复用缓冲区时，消费者可能正在读取同一片内存。可以选择复制数据，也可以约定消费完成后归还缓冲区，但必须让归还成为协议的一部分。对象池提供复用能力，不提供这种所有权证明。

文件、响应体、计时器和网络连接也一样。最贴近创建动作的作用域通常适合安排 defer；一旦资源跨越函数边界，就要写清由谁关闭。返回 `io.ReadCloser` 的函数是在把关闭责任交给调用者。返回完整字节的函数则可以在内部关闭响应体，接口更简单，但必须限制读取大小。

## 等待结束比发出取消更重要

`cancel()` 表示请求停止，`Wait()` 表示确认结束，两者不可互换。服务退出时只广播取消然后直接返回 main，后台日志、文件 flush 或任务状态可能尚未完成。随书工作池把停止接纳、等待任务和最终取消分开，让测试能分别验证每个阶段。

WaitGroup 是等待工具，不能存储错误，也不能阻止新任务加入。通常应在启动 goroutine 前完成 Add，避免它先执行 Done 或 Wait 提前观察到零。若允许 Add 与关闭并发，就需要额外接纳门闩，使“允许加入”和“开始等待”具有明确顺序。把 WaitGroup 本身当作工作池状态机会产生难以复现的关闭竞态。

等待结束也不能无限进行。顶层需要截止时间，并在超时后记录尚未完成的责任。对协作式函数，服务取消能让它们退出；对完全不合作的代码，进程终止可能是唯一边界。这个事实应体现在接口契约与测试中，而不是被“优雅退出”四个字遮住。

## 错误的归属也是资源管理

后台 goroutine 里的错误不会自动返回给启动者。如果只是打印，调用方可能仍认为任务成功；如果把错误发送到无人接收的 channel，它又会变成泄漏。工作池需要决定哪些错误只终结当前任务，哪些错误应让整个服务失败，并保留可查询或可观测的结果。

panic 也不能无条件吞掉。对隔离的用户任务，可以在清晰边界 recover 并记录堆栈、标记失败；对破坏内部不变量的 panic，继续服务可能比退出更危险。随书工程不鼓励把任意错误包装成成功，只要求任何受控失败路径都完成计数与资源清理。生产扩展时应围绕真实故障域决定恢复策略。

goroutine 数是有用信号，但它不是所有权证明。HTTP 连接、运行时和诊断工具都会创建 goroutine，测试前后数量不一定精确相等。更稳健的断言是拥有的 worker 已退出、被阻塞的操作返回、后台任务 active 归零；HTTP handler 则另用返回后的完成通知或 DrainRequests 确认。然后辅以 goroutine profile 检查异常堆栈。不要用一次全局计数相等替代这些证据。

## 练习：写一张责任表

在实验工程里寻找每一个 `go`、`NewTimer`、`NewRequestWithContext` 和 `Close`。为每项写下创建者、借用者、取消者、关闭者和等待者。若某一格填写“系统会处理”，继续追问是哪一个具体函数。再增加一个故意不检查取消的工作，观察 Shutdown 的截止能够保护等待者，却不能神奇地终止函数。

最后移除这个故障工作，检查所有测试仍然通过。练习不是证明 Go 不可靠，而是认识到服务保证来自整条调用链的合作。你写出的责任表应足以让另一个人指出：在哪个函数返回之后，哪些资源绝不应继续存在。

## 本章实测证据

提交成功后取消调用者，已接纳工作仍由池负责；关闭池后等待Done，确认责任转移与结束等待。 以下摘自最终本机 `go test -race -v ./...`，完整命令及环境见附录B；括号中的耗时仅是当次运行记录。

```text
--- PASS: TestAcceptedTaskOutlivesCaller (0.00s)
```


# 第4章 channel、锁与内存可见性

## 先选需要维护的不变量

channel 适合表达事件交接与有容量的等待区，锁适合保护一组需要共同成立的状态。两者都可以用于并发控制，但表达能力并不相同。工作池的任务传递使用 channel 很自然；“已经关闭且禁止新任务进入”的状态，需要与提交动作形成同一个临界区。坚持所有问题只用 channel，往往会把简单状态机改造成复杂消息协议。

Go 内存模型关注的是并发读写之间如何建立同步关系。一个 goroutine 写完字段，另一个 goroutine 在没有同步的情况下轮询，并不因为“通常先启动前者”就安全。调度顺序、睡眠时长和本机输出都不能建立 happens-before。通过锁、channel 或适当原子操作建立顺序，才让另一个执行者获得可依赖的可见性。

channel 发送可以作为交接点，但发送的对象之后是否继续变化仍然重要。若发送指针后继续改它的字段，接收者可能与发送者并发访问同一内存。把交接当成逻辑所有权转移，可以避免许多共享写；这不是 channel 自动禁止修改，而是程序设计上的纪律。

## 关闭只表达不会再有值

关闭 channel 表示发送方承诺不会再发送。通常由能够证明所有发送者结束的一方关闭。接收者提前关闭共享任务 channel，会让仍在提交的请求 panic。多个 worker 也不应各自关闭同一个结果 channel，除非另有一次性关闭与发送完成的协议。

从已关闭 channel 接收会立刻得到零值与 false。忘记检查 ok 的循环可能高速运行，不断处理假任务。nil channel 的发送和接收则永远阻塞，可以用于动态禁用 select 分支，但不应成为未初始化字段的隐蔽故障。理解这三种状态，比背诵“channel 是安全队列”更有帮助。

关闭操作也不是取消所有工作。队列关闭后，range 会继续取出已经缓冲的任务。若希望排空，这正合适；若希望立即停止，就需要额外服务 context，并决定剩余任务如何计数。只写一个 `close(queue)` 无法同时表达停止接纳、取消执行与丢弃排队任务。

## 锁保护的是关系

一把锁可以保护 closed 标记与一次非阻塞入队之间的关系。提交者持锁检查可接纳状态，再完成发送或返回满；关闭者持同一把锁修改状态并关闭队列。这样就不会发生检查时未关闭、发送时已关闭的窗口。锁的作用在这里不是防止 map 崩溃，而是把协议边界做成不可分割的动作。

临界区必须短。不要持有接纳锁等待外部网络，也不要在持锁期间等待整个工作池退出，否则 worker 可能需要同一把锁更新状态，形成死锁。原则是先在锁内改变和提取状态，再在锁外执行耗时清理。需要同时获取多把锁时，统一顺序，并考虑能否减少状态交叉。

RWMutex 也不天然优于 Mutex。读操作是否短、写频率如何、持锁区是否只做几个整数操作，都影响实际成本。教学代码应先选容易证明正确的方案，再在有证据时优化。为了“高并发”提前拆出多把锁，会增加一致性快照和关闭协议的复杂度。

## 原子计数与一致快照

原子变量适合独立计数或状态发布，但多个原子值的读取不是事务。读取 accepted 后，另一个任务可能完成，再读取 active 时看到不同时间的状态。这种快照用于趋势观察通常足够，用它判断是否可以关闭资源则不够。关闭判断应依据专门同步，而不是监控数字碰巧归零。

同样，先 Load 再 Store 不是原子增量。需要 `Add`、比较交换或锁来维护读改写。不要混用普通访问与原子访问同一个字段；race detector 会帮忙发现部分问题，但建立清晰访问规则比依赖检测更可靠。字段注释可以简短写明“由 mu 保护”或“仅通过 atomic 访问”。

race 通过并不意味着没有死锁、丢任务或关闭顺序错误。一个程序可以完全没有数据竞争，却因为等待无人发送的 channel 而永远挂住。因此本书同时使用 race 和行为测试：前者检查执行过的共享访问，后者检查协议的可完成性和边界结果。

## 练习：构造关闭与提交的竞争

同时运行多组提交者与关闭者，记录每次成功接纳和拒绝。验收应要求无 panic、无重复关闭、成功接纳的任务都有终结结果，关闭完成后不再接纳。将测试重复执行，而不要求成功接纳的总数每次相同，因为竞争点之前有多少提交成功本来就可能变化。

再故意把 closed 检查与发送之间的锁移开，用 race 或重复测试观察故障。可能出现的是 send on closed channel，它不一定以数据竞争报告出现。恢复锁之后，写下这把锁维护的完整句子。如果只能解释成“防并发”，说明你还没有抓住它保护的真正关系。

## 本章实测证据

关闭者与多组提交者同时执行。测试允许合法接纳数量变化，要求无发送到已关闭channel、无重复终结。 以下摘自最终本机 `go test -race -v ./...`，完整命令及环境见附录B；括号中的耗时仅是当次运行记录。

```text
--- PASS: TestPoolConcurrentCloseAndSubmit (0.00s)
```


# 第5章 有界并发与过载接纳

## 限制执行数量只是第一步

在每个请求里启动 goroutine，然后在 goroutine 内获取 semaphore，只限制了同时执行的数量。所有等待许可的 goroutine 仍然存在，携带请求数据、栈和计时器。流量持续超过处理能力时，内存和延迟仍会不断增长。真正的边界必须同时约束执行、等待与等待时间。

随书工作池预先启动固定数量 worker，并提供固定容量任务 channel。接纳使用非阻塞发送，队列已满就拒绝，不为每一个等待提交再创建 goroutine。因此 W 个执行槽与 Q 个等待槽直接构成进程内接纳预算。这不是所有业务的唯一方案，但它的资源关系足够简单，适合作为起点。

过载不是偶发错误，而是需求超过可用预算时的必然状态。服务应事先决定拒绝发生在哪里、返回什么、客户端是否可重试，以及重试会不会加重负载。接纳路径不能为了“保证不丢”无限阻塞，否则只是把拒绝推迟为更昂贵的超时。

## 容量来自等待预算

假设稳定状态下平均到达率为每秒五十个任务，平均在系统内停留一秒，平均在途数约为五十。这个关系可以帮助理解队列与延迟，但不能直接计算安全峰值。任务时长分布、突发流量、上游抖动和重试都可能让平均值失去保护作用。容量设计应使用业务可接受的等待与测量分位数，而不是只看平均处理时间。

当下游变慢，worker 的占用时间增长，队列将更快满。增大队列只能暂时延后拒绝，也会让排在后面的任务更可能过期。对于仍然挂在请求上的工作，等待已经耗尽预算后继续执行通常没有价值。后台任务则需要自己的到期规则，不能因为客户端已经拿到接纳响应就无限有效。

队列中的每项数据也有体积。即使任务数固定，若每项保存十兆字节请求体，内存预算仍可能很大。因此 `/tasks` 只保存校验后的短描述，而非任意大对象。输入大小限制、队列容量和外呼响应限制是同一资源预算的不同侧面。

## 接纳和执行不能混淆

提交成功的时间点是责任转移点。之前失败由请求报告；之后失败由工作池记录。处理函数不能在提交成功后因为客户端写响应失败就随意撤回任务，因为 worker 可能已经执行。反过来，如果响应丢失，客户端也不能确信任务未接纳。这是异步接口天然具有的结果不确定窗口。

本书教学工程没有持久化任务 ID 去重，重试提交可能产生第二个任务。对付款、邮件或外部修改等副作用，生产实现必须引入业务幂等标识和可查询状态，必要时使用事务性队列。增加更多 worker 不会解决这一协议问题，返回 202 也不会自动产生可靠投递。

拒绝响应应尽量廉价。不要在队列满时再调用昂贵的依赖查询，或把每次拒绝都同步写进慢日志后端。可以记录采样日志和计数，保留能够解释过载的信息。若返回 Retry-After，要确保它只是合理建议，不能暗示服务保证届时一定有容量。

## 公平、隔离与扩展

一个全局有界队列能保护进程总量，却不能保证租户公平。某个租户持续占满等待区，其他租户仍可能被拒绝。后续扩展可以按租户设预算、按业务类型隔离 worker，或在共享总预算下调度。每增加一层队列，都要重新计算总等待量并定义关闭顺序。

多个进程各自限制到十个并发，不等于整个系统限制到十个。扩容会扩大对下游的总压力，因此服务预算要与数据库连接、外部配额以及副本数一起讨论。进程内 semaphore 是局部机制，不是分布式限额。书中实验只证明单进程约束，不能直接外推到集群。

工作池的大小也不应按 CPU 核数机械设定。CPU 密集任务、网络等待和大内存转换有不同瓶颈。先测量任务占用什么资源，确定不能超出的那一项，再选择并发预算。一次微基准得出的纳秒成本，不能决定有真实网络与重试的生产容量。

## 练习：证明上限而不是绘制好看的曲线

使用闩锁占满 worker，把队列填到容量，再执行一次提交。记录成功接纳数量、拒绝是否在短窗口内返回以及运行中的任务数。随后释放闩锁，验证所有已接纳工作终结，且服务能够再次接纳。这个实验同时检查容量边界与故障后的恢复。

再把工作延迟提高十倍，保持队列容量不变，观察拒绝提前出现而资源仍受控。若只关心吞吐量，可能误把拒绝看成失败；从服务保护角度，及时拒绝正是契约兑现。最终报告应同时给出完成、拒绝和等待分布，不能把拒绝请求从统计中无声删除。

## 本章实测证据

HTTP路径实际返回202、202和503，并检查Retry-After。取消第一个请求后，两个任务仍排空成功。 以下摘自最终本机 `go test -race -v ./...`，完整命令及环境见附录B；括号中的耗时仅是当次运行记录。

```text
--- PASS: TestHTTPOverloadAndTaskOwnership (0.31s)
```


# 第6章 超时、取消与重试共享同一份预算

## 一次请求包含多种等待

HTTP 请求可能先在应用接纳处等待，再等 DNS、连接建立、TLS、请求发送、响应头和响应体。日志里的一条 timeout 无法说明哪段最慢。Go 提供多个超时字段，但这些字段覆盖的阶段不同。把所有字段都设置成同一个值，既不能保证总时长，也很难解释失败。

请求 context 通常承担整次操作的截止。Transport 的连接和响应头相关配置限制特定阶段，Client.Timeout 则覆盖包含读取响应体在内的整个客户端交换过程。不同限制会相互竞争，最先触发的那个结束当前路径。服务应在日志中记录阶段，而不是仅凭错误字符串猜测哪个参数生效。

随书外呼器使用传入 context，使 worker 取消或调用预算能够终止连接等待与读取。这个设计的核心不在某个固定秒数，而在所有阻塞点均受一份可传播的预算约束。教学默认值便于实验，生产值需要依据依赖行为和业务响应要求重新制定。

## 超时并不回滚副作用

客户端超时可能发生在服务执行之前、执行之中或执行完成之后。对于读取接口，重试通常只是增加负载；对于修改接口，重试可能产生第二次效果。context 取消无法回滚远端已经提交的事务。因此错误处理应区分明确失败和结果未知，而不是把所有超时都翻译成“操作未发生”。

如果业务支持幂等键，应让同一个逻辑操作的重试复用该键，并由实际执行业务的一方持久化去重。仅在网关缓存响应，无法覆盖缓存写入前进程崩溃的窗口。对于无法查询也无法去重的副作用，需要对账或人工处理，不应依赖不断重试获得心理上的确定性。

后台任务也有类似边界。服务取消时，worker 返回错误并不代表远端动作没有发生。任务记录需要允许“状态待核查”，而不是强行归入成功或失败。本书内存实验没有实现完整业务账本，但会持续提醒这个边界，避免把资源释放等同于业务回滚。

## 重试次数必须受总预算限制

一次三秒预算的操作，不能重试三次且每次都等三秒。合理流程是每次尝试前计算剩余预算，为本次尝试设上限，为退避预留时间，并在 context 取消时立即结束等待。退避应使用可取消计时器，不能无条件 Sleep 让服务退出被延后。

指数退避可以降低同步重试，但加入随机抖动也不能创造容量。过载时，如果客户端、网关、服务和 SDK 各自重试三次，放大倍数会很快失控。负责重试的一层应了解幂等性和预算，其他层尽量清楚暴露失败。是否重试应依据错误阶段与业务语义，而非统一处理任何非二百响应。

服务器返回明确的限流或过载状态时，重试应尊重建议并设置最大次数。客户端必须能承受最终失败；无限重试只是把故障转化为无限排队。对后台恢复型任务，可以持久化下一次执行时间，释放当前 worker，而不是让 worker 长时间睡眠占据执行槽。

## 错误分类服务于行动

工程需要知道的是：调用者已取消、总截止已到、连接阶段失败、响应状态不可接受，还是响应体超过允许大小。分类应足以决定是否记录、重试、报警或归因。过细的字符串匹配脆弱，过粗的“网络错误”又无法指导行动。通过 `errors.Is` 与结构化错误保留原因链，通常比重新拼接文本可靠。

取消与超时的日志级别也要结合上下文。用户主动离开不一定是服务故障，持续大量 deadline exceeded 则可能意味着预算不足或下游退化。将它们都作为高优先级报警会淹没真正异常；全部忽略又会看不到容量问题。用速率、阶段和影响范围来判断，比单条日志更稳妥。

实验中的超时应留出合理余量。要证明操作在一秒内响应取消，不需要断言恰好十毫秒返回。CI 的负载和 race 插桩会放大时延。测试可以设明确上限、用事件同步确认操作已进入阻塞，再触发取消。这样既有界，也不会把偶然调度当成协议失败。

## 练习：为一个外呼画预算账本

为外呼列出总预算、最长排队、单次连接、响应头和响应体阶段。故意让上游先慢响应头，再快速响应头但慢响应体，比较触发的限制。保存每次命令、阶段与返回错误。最后加一次受限重试，确认总时长仍受父预算限制，而不是随尝试次数线性增长。

把外呼换成有副作用接口时，补充结果未知的处理说明。不要在实验中真实重复付款等操作，可以用本地计数器模拟服务提交后延迟响应。验收客户端超时之后计数器是否已经变化，并据此解释为何“超时就安全重试”不是通用规则。

## 本章实测证据

任务在worker开始后获得独立截止，超时工作响应取消。这个实验不证明排队时间包含在TASK_TIMEOUT中，事实上本工程不包含。 以下摘自最终本机 `go test -race -v ./...`，完整命令及环境见附录B；括号中的耗时仅是当次运行记录。

```text
--- PASS: TestPoolTaskDeadline (0.02s)
```


# 第7章 HTTP 输入边界与慢上传

## 请求头和请求体是不同阶段

一个连接建立以后，可以很久都不发送完整请求头，也可以快速发送请求头后极慢上传请求体。`ReadHeaderTimeout` 主要限制头部读取，不能替你定义所有请求体的可接受速度。全局 ReadTimeout 又可能把大文件上传和普通小请求放在同一种时间限制下。应先区分端点的输入协议，再选择对应限制。

随书 `/tasks` 使用经过校验的查询参数构造任务，不接受任意大 JSON 作为队列元素。这让实验聚焦生命周期，也降低了输入体积的影响。但真实业务如果读取 JSON，仍需先限制 Body，再限制解码结构和字段。只设置 Content-Length 上限不够，因为客户端可以不发送该头，或使用其他传输方式。

`http.MaxBytesReader` 可限制处理函数读取的字节数；解码失败应及时结束，不要把超限输入继续完整读入内存。限制还应覆盖解压后的体积、字符串长度、数组项数量等业务维度。一个压缩后很小的请求，解压后仍可能占据大量内存。协议字节预算和业务对象预算互相补充。

## 不信任可以无限增长的表示

HTTP header 的大小、URL 长度、路径段、表单值和错误消息都有内存成本。标准库会提供一些协议层保护，但业务仍需限制自己保留的数据。尤其不要为了日志把整个 Body 复制两份，再把它保存进 context 或后台任务。诊断信息应保留长度、摘要和必要字段，而不是默认保存全部输入。

参数解析也要有语义边界。延迟参数不能为负，也不能大到让任务长时间占槽；字节数不能溢出或超过演示允许范围；外呼地址必须说明教学场景和部署限制。随书外呼能力用于本地故障实验，若放到不可信公网，就需要目标地址、DNS 与网络出口策略，不能把它直接当成通用代理。

校验失败应在接纳之前发生。一个格式错误的任务若先占入队槽，再交给 worker 判断，会让便宜的无效输入消耗宝贵执行资源。与此同时，接纳前校验也应保持有界，不能为了验证请求而同步执行长时间外呼。把校验和业务执行混在一起，会破坏“满即拒绝”的廉价路径。

## 请求取消与读取进度

服务器请求 context 会在客户端连接关闭、请求取消或处理函数结束等条件下取消，但协议版本与代理行为会影响具体传播路径。测试应使用真实 TCP 或真实 HTTP 客户端，不应只手工 cancel 一个 context 就宣称已经覆盖网络断开。手工取消单元测试检查的是函数合作；网络测试检查的是协议连接到函数的链路。

对于慢上传，处理函数可能阻塞在 Body.Read。要让它最终退出，需要适当读取截止或连接生命周期约束。仅在 Read 之前检查一次 ctx.Err 不够，因为随后可能进入长时间阻塞。ResponseController 提供按响应控制器设置读取截止的能力，但包装器需要支持相关接口；使用中间件时应验证实际路径。

不要为了感知断连持续启动额外 goroutine 轮询。标准库已经管理请求取消，业务只需把信号传下去。多加一层侦听者不仅增加资源，还会引入它自己的退出责任。每个监控 goroutine 都应有清楚终点，否则诊断代码本身也会制造泄漏。

## 错误响应也遵守写入规则

头部写出之后就不能再更改状态码。流式端点尤其容易先返回一部分成功内容，随后才发现上游错误；这时应使用协议内错误帧或关闭连接，不能假装还能重新发送 HTTP 错误状态。普通 JSON 接口若希望统一错误结构，可以在有限大小缓冲区完成结果后一次写出，但缓冲也需要预算。

客户端中断时写错误响应常常也会失败。处理函数应记录必要信息并结束，避免为了“必须告知客户端”进入反复重试。一次已无法交付的响应不值得继续占住 worker。写入错误需要参与控制流，而不是被 `_ =` 一概忽略。

请求 Body 的使用者也应遵守生命周期。标准 HTTP Server 通常负责处理请求体关闭，但业务若把它交给后台继续读取，就越过了处理函数范围。真正异步的上传处理应先把数据存入受控持久介质，接纳成功后只传引用。内存队列里的任务描述应该独立于即将结束的 HTTP 请求。

## 练习：用很慢的客户端做输入验收

通过 `net.Dial` 手工发送不完整请求头，确认头部读取期限最终关闭连接。再发送完整头部但缓慢写 Body，观察它受到哪一种限制。两次实验都记录实际等待上限，不要只检查服务是否返回错误。使用独立测试端口，避免把故障流量发向真实服务。

把输入解析限制与工作池容量测试串联：超大或非法输入不应增加 accepted；合法输入在队列满时应及时拒绝；解除阻塞后仍可正常提交。这样可以证明输入路径没有绕过接纳边界，也不会因某个失败请求留下持续占用。

## 本章实测证据

负延迟、非HTTP目标和过大的流字节数被输入校验拒绝。慢上传是本章扩展练习，不属于这个测试已覆盖的结果。 以下摘自最终本机 `go test -race -v ./...`，完整命令及环境见附录B；括号中的耗时仅是当次运行记录。

```text
--- PASS: TestHTTPInvalidInput (0.00s)
```


# 第8章 流式响应、慢客户端与写入截止

## 输出端也会施加背压

流式响应把结果逐块交付，能够降低首块等待和缓冲内存，却让客户端读取速度直接影响服务端资源。客户端不读时，数据会先进入用户态与内核缓冲；缓冲填满后，Write 才阻塞。服务端看见 Write 暂时成功，并不代表用户已经收到，更不代表已经消费了业务结果。

随书 `/stream` 专门用于这个实验。它重复写出有限数量字节，并通过 ResponseController 设置写截止。测试使用真实 TCP 连接，发完请求后不读取响应，使输出最终停在网络边界。这里故意使用很大的响应量，是为了越过本机缓冲；它不代表业务应当默认发送如此大的响应。

本机实测中，慢客户端请求不读取二百五十六 MiB 演示响应，八十毫秒写截止使写入返回 `i/o timeout`，处理函数结束。测试重点是有界结束和资源释放，具体写出了多少字节依赖系统缓冲和调度，不作为跨平台断言。把字节数量写成固定性能结论，会误读实验。

## Flush 只推动数据，不确认交付

`Flush` 尝试把已缓冲输出推进协议层，不等待业务客户端确认处理。它还可能触发真正网络写入并阻塞，因此也要受写截止约束。某些中间件包裹 ResponseWriter 后没有暴露控制接口，会使 Flush 或设置截止失效。服务应针对实际包装链验证能力，并处理不支持的返回值。

分块协议需要自己的结束语义。正常完成帧、应用错误帧和连接中断代表不同结果。对于 SSE，可以使用事件类型与事件 ID；对于自定义 JSON 行协议，应约定每行是否完整、最后是否有终结记录。客户端不能只看到 HTTP 200 就把中途断开的结果当成成功。

代理也可能缓存响应、设置空闲超时或合并小块。随书实验直接连接本地 Go Server，证明的是应用与本地 TCP 的行为；不能据此宣称任意 CDN、Ingress 或浏览器都按同样节奏收到块。部署时应沿完整路径测试首块、间隔和结束帧，并记录代理配置。

## 绝对截止与空闲截止

一个固定 WriteTimeout 可以约束响应总体写入时间，但长连接流可能合法持续很久。如果希望限制的是客户端长时间不读，就要设计按块或按进度刷新截止的方式。刷新必须发生在合适边界：不断生成但一直堆积在缓冲中的数据，不应被误判为有效交付进度。

教学实现选择简单的有界写窗口，便于证明故障最终释放。对生产流式接口，需要在总时长、块间空闲、最大字节数和每连接资源之间做明确选择。没有一个超时字段同时满足所有流协议；“把超时调大”只是改变故障暴露时间，不会解决无界占用。

写截止到达后，当前连接可能已经不适合复用。随书 HTTP/1.1 流端点明确使用 `Connection: close`，让最后的协议 flush 也有清楚的连接终点；普通端点仍可复用连接。这是为本实验生命周期选择的策略，不能机械照搬到所有长连接或 HTTP/2 流上。

## 取消必须穿过生产链

如果流式响应背后还有一个生产 goroutine，处理函数因慢客户端退出时，应取消生产并等待结束。否则只是释放了网络写入者，生成数据的一端还可能继续运行。若生产者向无人读取的 channel 发送，它会永远阻塞；若 channel 无限制缓冲，它会继续消耗内存。

应让生成、传输与关闭共享一个明确取消范围，且只有有权的一方关闭输出 channel。每块数据的缓冲区也要遵守所有权，不要在 Write 或异步编码尚未结束时就归还对象池。所谓低分配优化一旦破坏数据稳定性，可能产生只有压力下才出现的内容损坏。

流式请求计费和完成状态也不能只看处理函数是否开始。可以分别记录连接建立、首块写出、正常终结、客户端取消和写超时。不要为每个小块产生一条同步日志，以免观测成本反过来改变传输节奏。聚合计数与必要的失败摘要通常更合适。

## 练习：同时观察两端

运行慢读取实验，一次完全不读，一次每隔固定时间读取少量字节。记录 `stream finished` 日志的错误类别和客户端读到的终结状态，再参考 `TestSlowStreamDeadlineReleasesHandler`，等待包装 handler 在 `ServeHTTP` 返回后关闭的 finished 通知，确认服务端已经退出。`/stats` 的 active 不包含 `/stream`，不能用它证明流处理完成。把写截止移除后，只在独立子进程中复现阻塞，并由测试总截止清理该进程，防止故障实验拖住整个测试套件。

再给生成端加一个退出通知，要求网络端失败后通知也在有界时间内到达。最后检查 HTTP/1.1 与你计划部署的协议是否具有不同关闭语义。报告应清楚写出本次覆盖哪一层，哪些代理和协议尚未测量。

## 本章实测证据

真实TCP客户端不读取响应，八十毫秒绝对写截止触发i/o timeout，handler有界返回。实际写出字节数不作跨平台断言。 以下摘自最终本机 `go test -race -v ./...`，完整命令及环境见附录B；括号中的耗时仅是当次运行记录。

```text
--- PASS: TestSlowStreamDeadlineReleasesHandler (0.08s)
```


# 第9章 连接池与响应体的完整生命周期

## Client 与 Transport 应当复用

Go 的 http.Client 与 Transport 为并发使用而设计。每次请求创建全新 Transport，会丢失连接复用，增加建连、TLS 与空闲连接成本；把共享 Client 的配置在请求中随意修改，则可能引入竞争。通常在服务启动时构造并配置，在运行期间复用，到关闭阶段清理空闲连接。

随书 Fetcher 复用同一个 Transport，并把 `MaxConnsPerHost` 设为二。这一限制包括连接建立中、活跃和空闲连接。超过上限时，新的请求可能等待连接，而不是立即报“连接池满”。因此等待本身必须受请求 context 约束，否则下游变慢会在客户端内部形成看不见的队列。

MaxIdleConnsPerHost 管理的是空闲连接保留，不是所有活跃请求的并发上限。把它调小不会自动限制并发外呼；把它调大也不表示下游允许更高负载。应用工作池和 Transport 有不同职责：前者限制业务接纳，后者管理网络连接。两层预算都需要明确。

## Do 成功之后，工作还没结束

Client.Do 返回的响应包括 Body。只要 Body 尚未处理完，连接相关资源可能仍然被占用。调用者必须关闭 Body；若希望 HTTP/1.x 连接复用，通常还需要读到 EOF。只检查状态码然后忘记关闭，是最常见的连接耗尽来源之一。错误状态同样有 Body，也不能遗漏。

随书真实 TCP 测试先让响应头到达但保留未读响应体，在小连接预算下发起后续请求。后续请求等不到连接，最终由 context 超时。关闭先前响应体后，再次请求能够恢复。这个实验直接证明资源占用与恢复，比观察进程打开文件数的偶然变化更明确。

不要用无限 `io.Copy(io.Discard, body)` 试图保证复用。恶意或损坏上游可以无限发送，清理动作反而无限占用。应设置读取上限和总预算：小响应可以读完并复用；超过大小限制就关闭，必要时牺牲该连接。资源保护优先于复用率。

## 大小限制有一个边界字节

要区分恰好达到上限和超过上限，可以最多读取允许大小再加一个字节。若直接用 LimitReader 读取 N 字节，读到 N 并不意味着上游已经结束。随书 Fetcher 限制响应体为一 MiB，超限时返回明确错误并关闭响应体。它不承诺超限连接仍可复用，下一次请求可以建立新连接。

返回数据之前关闭 Body，可以把关闭责任留在 Fetcher 内部。若业务需要边读边处理，就应返回一个明确的 ReadCloser 或采用回调，让调用方知道自己接管了什么。接口隐藏网络流却不交代关闭责任，会使调用者无法正确释放资源。

取消读取也必须能释放。测试让上游发头后阻塞 Body，客户端取消传入 context，随后验证读取结束、服务端请求 context 观察到断开，以及连接槽可供下一次请求。这里的恢复断言非常重要：一个函数虽然返回错误，但 Transport 仍不可用，依然是资源故障。

## 连接复用不等于请求序列化

HTTP/1.1 与 HTTP/2 的连接、流和并发关系不同。一个连接上可能承载多个并发流，因此用 TCP 连接数推断请求并发并不普遍成立。随书连接耗尽实验使用明确的本地 HTTP/1.1 行为；若升级协议，需要重新定义对流数量和取消的断言。

IdleConnTimeout 用于空闲连接，不会终止正在缓慢读取的响应体。ResponseHeaderTimeout 主要限制请求发送完成后等响应头，不覆盖整个 Body 读取。理解每个字段的起止点，才能解释“明明配置了超时，为什么还在等”。总预算依然需要 context 或 Client.Timeout 承担。

CloseIdleConnections 只清理当前空闲连接，不是取消所有外呼的按钮。服务退出时应先停止新工作、让现有工作结束或取消，再清理共享 Transport。过早清理空闲连接通常不能解决在途阻塞；无限等待在途请求则需要从它们自己的 context 与截止检查。

## 练习：制造并修复连接耗尽

先把每主机连接上限设为一，发起一个不读 Body 的请求，再发第二个带短截止的请求。记录第二个失败的位置，并确认上游是否收到它。关闭第一个 Body 后重试，要求恢复。随后比较读到 EOF 后关闭与直接关闭的连接复用差异。

扩展实验时增加响应体上限与非二百状态，确保每个分支都关闭 Body。不要只检查返回错误；同时检查后续请求能够完成。最后将测试多次运行，确认没有依赖端口、固定睡眠或某次恰好的连接重用。

## 本章实测证据

测试使用真实TCP，未读Body占住小连接预算，额外请求受context限制失败；关闭Body后连接预算恢复。 以下摘自最终本机 `go test -race -v ./...`，完整命令及环境见附录B；括号中的耗时仅是当次运行记录。

```text
--- PASS: TestConnectionExhaustionAndRecovery (0.05s)
```


# 第10章 后台任务的接纳、执行与结果

## 从请求独立出来需要新的主人

后台任务的目的通常是缩短请求等待，或把执行安排到受控资源中。它并不意味着工作可以不受管理。处理函数一旦返回，请求 context 结束；如果任务继续依赖它，就可能立即取消。若改成无期限 Background，又会让任务绕过服务关闭。正确转移需要独立数据、服务拥有的根 context 和任务自身的截止。

随书 Pool.Submit 接受调用者 context，但只在接纳边界使用它。接纳成功之后，执行由工作池根 context 和每任务超时共同管理。这个语义使客户端在收到接纳之后断开，不会任意取消已经归服务负责的工作；服务退出却仍可取消它。两种取消分别对应不同责任。

如果业务希望用户可以撤销任务，就应提供任务状态与取消接口，而不是借用已结束请求的 context。撤销与完成可能竞争，需要状态机决定谁先获得终结权。对于已经发出的外部副作用，撤销成功也只能说明后续步骤停止，不能隐含远端已经回滚。

## 接纳结果不是业务结果

`/tasks` 返回任务 ID，代表本进程接纳。任务随后可能成功、失败、超时或随服务关闭而取消。监控应区分这些结果，客户端若需要可靠查询，就必须引入持久化状态。本书的内存统计用于实验，不提供跨重启查询能力，不能作为生产订单处理记录。

任务描述应尽量不可变。延迟、目标地址等字段经过校验后交给 worker，不再由请求修改。若任务依赖大文件，队列中应保存受控存储引用和必要版本信息，避免把整个文件放在内存。若依赖用户授权，应决定是在接纳时验证一次，还是执行前根据业务要求重新授权。

错误也应成为任务结果。仅在 worker 中 `log.Println(err)`，无法让上层区分是否已终结；一条日志可能丢失、被采样或与任务 ID 脱节。即便教学工程不持久化，也应让计数和完成通知遵守一致路径。统计修饰不了错误语义，但可以暴露未终结任务。

## 任务截止与队列等待

任务超时从何时开始必须写清。若只从 worker 开始算，排队时间可能没有上限；若从接纳时开始算，任务可能在开始前就过期。两种选择都合理，关键是符合业务承诺。延迟敏感任务通常需要把排队纳入总截止；离线批处理可能容忍长等待，却仍要限制保留时间。

本工程的任务超时从 worker 开始执行时计算，不包含排队时间；排队总量受队列容量约束，退出时所有等待又受共享清理预算约束。若业务要求接纳到终结的总时限，需要新增接纳时绝对截止。具体计时位置是实验契约的一部分。读代码时不要凭字段名猜测，应追踪 context 创建发生在 Submit 还是 worker。扩展时可以在任务里记录 acceptedAt 与绝对截止，在 worker 开始前拒绝已过期任务，同时把它们记为终结而不是悄悄丢弃。

任务也可能具有多步骤外呼。每一步应共享总预算，不能每步刷新成完整超时。工作池根取消则是更高层的停止边界。若某步骤必须在退出时完成不可中断提交，就需要在业务状态机中显式表示，不应通过忽略 context 偷偷实现。

## 崩溃后的保证需要持久化

内存工作池在进程崩溃或机器断电后丢失排队任务，这是明确能力边界。若要恢复，应将任务状态写入数据库或消息系统，并设计领取、重试、幂等与结果未知处理。持久化并不会自动消除重复执行：worker 在副作用成功后、标记完成前崩溃，恢复执行可能重复产生效果。

因此从本书工程升级到可靠任务系统时，首先补业务标识和状态转换，而不是先换一种队列库。任务是否至少投递一次，与业务效果是否只发生一次是不同层次的承诺。对无法幂等的依赖，应保留核查入口。不要用“最终一致”掩盖缺少终结策略。

任务隔离也要基于资源。长任务与短任务共享同一个小工作池，会发生队首延迟；网络任务与 CPU 大任务共享预算，则可能互相拖累。可以按资源类型设置独立池，但每个池都要有有界队列、关闭顺序与监控。简单复制几个无限队列只会分散故障。

## 练习：画出任务状态机

画出未接纳、排队、执行、成功、失败和取消状态，标明由谁改变状态、哪一刻可以重试。增加“响应丢失但已接纳”与“外部副作用结果未知”两种情形，检查现有接口能否让调用方正确行动。无法判断的地方就明确写为当前工程不提供保证。

接着验证请求断开不取消已接纳任务，而服务根取消可以使它结束。再构造一个排队时就过期的任务，决定它是否应执行，并为这个决定增加行为断言。练习应留下语义说明与测试结果，而不只是新增几个状态常量。

## 本章实测证据

工作属于池之后不再依赖请求context。这个通过结果不支持跨重启恢复或业务副作用恰好一次。 以下摘自最终本机 `go test -race -v ./...`，完整命令及环境见附录B；括号中的耗时仅是当次运行记录。

```text
--- PASS: TestAcceptedTaskOutlivesCaller (0.00s)
```


# 第11章 优雅退出是一段有截止的协议

## 信号只是开始

收到 SIGTERM 并不表示进程应该立刻结束，也不表示所有 goroutine 会自动停止。服务必须协调接纳、HTTP 在途请求、后台任务、外呼连接和诊断端口。任何一个环节没有截止，都可能让部署长期卡住；任何一个环节过早关闭，都可能让已经承担的工作中途失去资源。

随书进程使用 signal.NotifyContext 接收退出信号，并把关闭步骤放在 main 控制流程。实际顺序是停止 HTTP 监听并等待现有处理函数，随后关闭任务接纳并排空工作池；共享预算不足时关闭 HTTP 连接、取消任务，再额外等待最多一秒合作清理，确认拥有的执行者结束，最后清理共享连接与管理端口。具体步骤可以按业务调整，但必须把依赖顺序解释清楚。

HTTP Shutdown 等待期间，已进入处理函数的请求仍可提交后台任务；本书选择让这些请求完成自己的接纳责任，再关闭工作池。若预算耗尽，两者仍可能存在并发窗口。工作池自己的关闭门闩保证这个请求要么成功接纳且被后续排空，要么得到关闭拒绝，不能发到已关闭 channel 上。不能仅依赖停止监听来保证业务接纳已经停止。

## Server.Shutdown 不拥有所有资源

HTTP Server.Shutdown 会关闭监听器、处理空闲连接并等待活动连接变为空闲，但它不会自动等待业务自己启动的 goroutine。它也不直接管理被 hijack 的连接，例如某些 WebSocket。后台任务、独立管理服务器和业务连接仍需要各自的退出协议。

Shutdown 的 context 过期意味着等待预算结束，不意味着所有处理函数都被强制杀死。必要时可以调用 Close 终止连接，并取消服务拥有的工作 context；工作函数仍需合作返回。正常服务请求 context 不应简单绑定在 signal context 上，否则收到信号的一刻就取消所有在途工作，失去排空机会。

使用已经取消的信号 context 作为 Shutdown 参数是常见错误。它会让等待立即失败。应创建一份独立、有限的清理 context，例如从 Background 派生超时。独立并不意味着无限；它表示清理阶段有自己的预算，不被触发退出的那次取消提前清空。

## 排空和强制取消分开记录

工作池 Close 表示不再接纳；Shutdown 在预算内等待队列排空，预算到达后发出取消。Done 则表示 worker 真正结束。三个动作的区分让主进程不会把“已经通知停止”当成“已经停止”。如果工作不合作，必须明确返回失败、保留诊断并由进程边界结束，而不是声称已经优雅完成。

本书正常退出实验在真实子进程中保持一个一百五十毫秒请求和两个后台任务，发送 SIGTERM 后检查请求与任务全部完成再退出。超时实验给退出阶段八十毫秒预算，让五秒任务被取消，确认 active 归零后进程以非零状态退出。这两类退出需要不同结论：前者兑现排空，后者兑现有界取消并报告未正常完成。

非零退出不是证明代码坏了，而是让编排器和操作者知道本次不能宣称全部正常完成。对于已接纳但未持久化的任务，退出日志应提供必要数量与原因。若业务要求部署期间不丢任务，应该增加可靠任务存储或外部协调，而不是把退出预算无限调大。

## 总预算需要统一管理

如果 HTTP 等五秒、工作池等五秒、管理端口再等五秒，总退出可能达到十五秒。外部编排器却可能只给十秒。较稳妥的做法是创建共享绝对截止，各步骤消耗剩余预算；或者显式分配阶段预算并保证总和有余量。容器停止宽限期、负载均衡摘流延迟也需要计入部署设计。

Readiness 摘除与监听关闭不是同一事件。负载均衡可能仍发送旧连接请求，客户端也可能已有 keep-alive 连接。服务必须在自己的业务接纳处继续守住关闭状态，而不是假设外部已经完全没有流量。这个原则同样适用于消息消费者停止拉取之后仍有在途回调。

第二次信号如何处理也应决定。常见策略是第一次触发清理，再次信号允许立即终止，防止操作员无法结束卡住进程。signal.NotifyContext 返回的 stop 函数应在不再需要通知时调用。退出代码应尽量直接，避免深层 goroutine 随意 os.Exit 导致 defer 不执行。

## 练习：证明进程而不是函数退出

构建二进制，用测试启动子进程，通过就绪信息获得实际监听地址。发出任务与在途请求，等待它们真正开始，再发送 SIGTERM，最后 Wait 子进程。断言请求结果、任务统计、退出码和最长等待；不能只调用某个 Shutdown 函数就说已经验证信号退出。

再让任务超过清理预算，要求取消后资源最终归零，并确认退出码体现失败边界。测试自身必须有总截止和失败清理，以免坏实现遗留子进程。这个练习产生的证据，比一条“优雅退出支持”功能列表更值得信任。

## 本章实测证据

真实子进程同时运行五秒请求和任务，八十毫秒清理预算耗尽后，检查请求结束日志、任务active归零和非零退出码。 以下摘自最终本机 `go test -race -v ./...`，完整命令及环境见附录B；括号中的耗时仅是当次运行记录。

```text
--- PASS: TestSIGTERMDeadlineCancelsBlockedTask (0.11s)
```


# 第12章 测试并发行为，而不是复制实现

## 从观察契约出发

并发测试最常见的问题不是断言太少，而是断言了错误对象。检查内部 channel 的长度，可能只是重复实现；检查额外任务在容量耗尽时被及时拒绝，才对应用户可依赖的行为。检查 cancel 被调用，不代表工作结束；等待退出通知并确认连接恢复，才覆盖资源契约。

随书测试分为工作池单元行为、真实 HTTP/TCP 行为和进程级退出。每层提供不同证据。单元测试可以精确制造调度点，真实网络暴露缓冲与连接生命周期，子进程验证信号、main 流程和退出码。它们互相补充，不应为了“全部都是集成测试”而丢失可定位性。

一个好测试还应有失败诊断。等待某状态超时后，报告当前 accepted、active、completed 等信息，比只说 timeout 更容易定位。必要时输出 goroutine 堆栈，但避免正常路径打印大量数据。诊断应围绕尚未完成的责任，而不是堆满与故障无关的环境细节。

## 用同步点代替猜测

固定 Sleep 很容易在本机通过、CI 失败。要测试取消中的阻塞读取，可以让服务端先发送“已进入”通知，再等待客户端取消。要测试队列满，可以让 worker 在闩锁上停住，再提交后续任务。这样测试控制的是状态，不是机器速度。

有时必须等待网络或进程状态，可以使用有截止的轮询。轮询间隔影响测试成本，截止决定失败边界；成功条件则必须可观测。不要用无限 for 循环等计数变化，也不要让断言失败跳过清理，导致后面的测试受到遗留 goroutine 或端口影响。

时间断言应给调度留余量，同时仍然检验有界性。八十毫秒写截止不应要求测试在八十一毫秒完成；race、虚拟化和共享 runner 会增加耗时。可以要求在数秒总上限内返回，并检查实际错误来自写截止。这样不会把快慢误当成协议对错。

## 不依赖某一种合法竞争结果

提交与关闭同时发生时，一部分任务可能被接纳，一部分被拒绝。只要边界满足契约，具体数量不必固定。测试应统计成功接纳任务，并要求这些任务都有合法终结，而不是硬编码每轮必须成功十七个。后者常常测试的是偶然调度顺序。

结果与取消同时就绪时，也不能假定 select 总选择取消。如果业务确实要求某种优先级，就要在实现中设计提交仲裁，测试对应不变量。没有设计的地方，不应通过偶然测试输出暗示更强语义。并发程序的正确结果往往是一组允许状态，而非单一路径。

重复执行有助于暴露稀有竞争，但不是穷举证明。随书故障测试在 race 下多轮运行，进程 SIGTERM 也重复验证，目的在于提高对常见调度变化的覆盖。通过一百轮之后仍需保留逻辑推理：谁关闭、谁发送、谁等待，为什么不会发生循环等待。

## race 与超时各有证据范围

`go test -race ./...` 只检测实际执行到的竞争访问。没有覆盖的路径不会自动被证明安全，逻辑竞态也可能没有数据竞争。例如两个线程都在锁内检查然后分别执行副作用，内存访问安全但业务仍可能重复。测试应从业务结果统计，而非把 race 当作万能认证。

测试总超时能够避免无限挂起，但总超时触发时往往已经失去局部上下文。更好的做法是在每个等待处设置局部截止，及时输出尚未完成的状态。全局 `-timeout` 是最后保护，不应成为日常判断机制。子进程测试还必须在失败路径终止并等待子进程，避免孤儿进程。

测试本身也可能产生竞争。多个 goroutine 调用测试辅助状态时需要同步，handler 中不宜直接 Fatal 终止当前 goroutine而让主测试继续等。可以通过有缓冲错误 channel 报告给测试主流程，统一断言与清理。测试代码同样需要生命周期设计。

## 练习：评价一条测试的证明能力

挑出一个测试，为它写一句“通过说明什么”和一句“通过不能说明什么”。例如连接耗尽恢复测试说明 Body 释放能恢复连接槽，却不能说明真实上游的容量和 TLS 行为。然后删除某一关键清理操作，确认测试会失败；如果仍然通过，说明它可能只测试了表面输出。

最后从新目录解压实验 ZIP，执行 race 与重复故障测试。确认没有依赖仓库外缓存、固定路径、已启动服务或隐藏环境变量。能独立运行的实验包，是读者检验书中结论的入口，也能反过来检查作者是否遗漏了真正的构建输入。

## 本章实测证据

先让handler进入等待，再调用DrainRequests；短截止必须超时，取消handler后必须完成，之后的新入口被拒绝。 以下摘自最终本机 `go test -race -v ./...`，完整命令及环境见附录B；括号中的耗时仅是当次运行记录。

```text
--- PASS: TestDrainRequestsWaitsForHandlerAndGatesAdmission (0.02s)
```


# 第13章 用 race detector 找到真实共享访问

## 竞争报告描述的是访问关系

数据竞争指向的是不同 goroutine 对同一内存的未同步冲突访问。它不等同于业务上的所有竞态。两个经过锁保护的扣款步骤仍可能重复执行同一订单；一个没有数据竞争的 channel 协议仍可能死锁。race detector 的价值在于把实际执行到的冲突访问及其创建栈报告出来，让我们从猜测转向具体共享对象。

运行 `go test -race ./...` 后，报告通常包含发生冲突的读写位置，以及相关 goroutine 从哪里启动。阅读时先确认共享的是哪个对象，再看两个生命周期为何重叠，最后决定应该复制、转移还是同步。只盯着报错行添加一把锁，可能漏掉同一对象的其他访问路径。

随书工程把任务统计放在原子变量中，把接纳与关闭关系放在同一互斥锁下。两种机制分别服务于独立计数和跨字段状态关系。测试覆盖提交者、worker 与关闭者的并发执行，也覆盖请求处理中的共享状态。race 通过说明这些实际路径未发现冲突，不代表任何未来扩展都自动安全。

## 把报告转成所有权问题

假设处理函数把一个 map 交给后台任务，随后中间件又向同一个 map 添加字段。两个访问即使都“发生在请求附近”，也可能位于不同 goroutine。最简单的修复可能是接纳前复制必要字段，让后台任务拥有独立数据；如果必须共享，则需要统一锁，而不是只保护其中一处写。

类似问题常见于复用字节缓冲区、修改共享 Client 配置、按请求更改全局 logger 属性，以及把一个闭包捕获的变量当作每次调用独立状态。现代 Go 已经改进部分循环变量语义，但不能据此认为闭包捕获都不会竞争。对象引用、外层变量和底层数组仍需逐项分析。

修复时要考虑读者。字段由哪把锁保护、对象什么时候变为不可变、谁可以归还对象池，最好体现在类型和函数边界里。过度使用原子字段可能让代码通过 race，却很难证明字段之间一致。同步方式应服务于完整不变量，而非仅让工具安静。

## 构造能触发的路径

race detector 需要路径实际执行。若测试总是顺序提交再关闭，它不会覆盖接纳竞争；若外呼测试从不取消读取，它不会覆盖取消回调与完成处理的重叠。测试应根据生命周期边界安排交错，用闩锁和通知明确让两个阶段同时存在，再解除阻塞验证终结。

不要靠缩短 Sleep 强行扩大竞争概率。可以先让两个执行者都到达屏障，然后一起继续；可以重复关闭与提交；可以让客户端在响应头之后取消，以确保读取路径真正启动。这样的测试即使在 race 之外也有行为价值，而不是为了工具专门拼凑访问。

重复运行对竞争窗口有帮助。随书故障组在 race 下重复三十轮，信号退出组重复十轮。次数不是正确性证明，只是提供比单次执行更广的调度观察。若重复测试稳定失败，应优先保留最小重现，而不是继续增加等待时长直到它偶然通过。

## race 插桩会改变运行成本

race 构建通常更慢、占用更多内存，也可能改变调度。不能把 race 下的吞吐量当作普通二进制容量。超时测试要区分协议截止与外部测试总截止，给调度留余量，同时仍然验证操作最终结束。为了让 CI 通过把所有截止改成一分钟，会掩盖本应发现的及时性问题。

同样，race 没有报告不意味着在所有平台都安全。未执行路径、外部系统以及某些不在检测范围内的交互仍需要单独分析。对于本书的标准库 Go 工程，它提供强有力的动态检查，但我们仍通过计数守恒、连接恢复和进程退出验证工具不能覆盖的语义。

若报告出现在测试辅助代码，也应认真修复。共享错误列表、未经同步的完成标志、handler 内修改主测试变量都可能让测试结果不可信。测试的同步复杂度应尽量低，错误通过有容量的通道回到主流程，清理通过明确的 defer 安排。

## 练习：修复一种真实共享数据

在独立练习分支中，让提交任务引用一个请求侧会继续修改的 map，构造同步屏障使访问重叠，观察 race 报告。随后分别用复制与锁修复，比较哪种方案更清楚表达任务所有权。不要把故意竞争代码混入主实验默认测试，以免读者误以为书中交付本身不能通过验收。

为修复增加行为断言：任务看到的是接纳时的值，还是调用者后来更新的值？这两种语义都可能合法，但必须选定。最终提交应同时包含消除数据竞争和明确结果语义的证据。只删掉并发执行让测试不再报错，不算修复了原来的服务需求。

## 本章实测证据

这是最终完整race命令中的实际输出。通过只覆盖执行到的共享访问；关闭协议还依靠测试内的行为断言。 以下摘自最终本机 `go test -race -v ./...`，完整命令及环境见附录B；括号中的耗时仅是当次运行记录。

```text
--- PASS: TestPoolConcurrentCloseAndSubmit (0.00s)
```


# 第14章 pprof：CPU、分配量与存活堆

## 先提出一个可检验的问题

pprof 不是性能结论生成器。采样之前应先问：CPU 忙在哪里，累计分配为什么高，还是 GC 后仍有对象存活？这三种问题对应不同观察。CPU profile 记录采样窗口中的执行成本；alloc_space 观察累计分配；inuse_space 更接近当前仍保留的对象。把累计分配解释成内存泄漏，是常见误判。

随书服务在独立管理地址暴露 pprof，默认绑定本机。业务路由不使用默认管理 mux，避免把诊断端点混进普通接口。采集 profile 是有成本的，某些采样还会影响时序。生产应通过受控管理网络与授权机制访问，书中本地默认值不是开放公网的部署建议。

获取 CPU 或 heap 后，保留二进制版本、Go 版本、负载、采样窗口和命令。只保存一张火焰图截图，无法让别人确认符号、采样类型或当时工作量。原始 profile 可保存在内部证据目录，公开书稿则记录命令、关键摘要与适用范围，不需要把每个二进制采样都塞进电子书。

## 本机采样实际说明了什么

实验对工作池执行基准并生成 CPU 与内存 profile。在本机累计分配样本中，`context.WithDeadlineCause` 和 `time.newTimer` 占据主要部分。这与每次任务执行创建超时 context 和计时器的实现一致。但它说明的是该负载下的累计分配来源，不能直接说这些对象一直存活，更不能据此宣称程序内存泄漏。

本次 CPU 样本在 runtime 等待与平台相关函数上占比较高。基准包含任务交接、调度和等待完成，样本不适合用来推断真实 HTTP 业务的热点。这里正确的结论是采样问题需要调整，而不是从最大的栈框随意选一个优化。书中保存具体摘要，是为了让读者看到证据限制，而不是只展示理想图形。

如果想研究业务 CPU，应加入代表性的解析、编码或计算负载，并确认采样期间 CPU 确实忙碌。若系统主要在等待网络，CPU profile 很可能解释不了延迟。此时应转向 goroutine、阻塞信息与请求阶段测量。工具选择来自问题，不来自某个图看起来更专业。

## flat、cum 与调用路径

flat 表示成本直接归在某函数，cum 包含它调用的下游。一个顶层处理函数 cum 很高，可能只是所有请求都经过它；真正分配或执行发生在内部函数。阅读 top 列表后还要看调用路径，避免把框架入口当作根因。采样误差和内联也会影响归属，尤其是短小操作。

比较 profile 时保持工作量可比。一个样本处理一万次任务，另一个处理十万次，累计分配当然可能差十倍。可以按完成任务归一化，再结合分布与峰值。若优化减少拒绝却增加实际完成量，绝对 CPU 上升也不一定更差。单一数字必须放回吞吐、延迟和拒绝语境。

heap profile 也有采样机制，不是逐对象完整清单。小对象分配和采样比例可能导致估计差异。强制 GC 后再看存活堆有时有助于调查保留，但会改变工作状态，也不能无限频繁进行。公开结果应写清是否手工触发 GC、负载是否停止，以及采集时仍在运行哪些任务。

## 优化首先保护正确性

看到每任务 context 有成本，不代表应该删除截止。超时对象支撑的是故障有界性。优化可以研究是否在无截止需求的路径复用更合适的生命周期，或减少不必要的层级，但必须保留原先取消和退出契约。为了几百字节分配让任务永远挂住，是把局部成本换成无界风险。

sync.Pool 也不保证对象永远存在，不适合做任务状态存储。复用缓冲区前要保证没有其他使用者；清空敏感字段和大切片引用也有必要。只有 profile 证明分配确实重要，并且行为测试仍保持通过时，才值得增加对象复用复杂度。

一次优化验收至少包含可重复基准、相关 profile 以及原先行为测试。不要反复运行直到得到最好数字，然后只发布那次。报告多次结果、环境与方法，让读者判断噪声。若差异小于波动，应承认当前测量不能支持结论。

## 练习：回答三个不同内存问题

分别观察累计分配、负载结束后的存活堆和持续负载时的峰值。给每个数字写出它能回答的问题。再让一个任务错误地保留大缓冲区引用，比较累计分配与 GC 后保留量的变化。修复引用后重新采样，确认资源释放而不是只让分配速度降低。

最后为 CPU profile 写一句范围声明：这个负载代表什么，不代表什么。若样本主要是等待，保留这个结果并解释下一步应采集哪类证据。诊断的可信度来自承认工具限制，而不是保证每次采样都有一个漂亮热点。

## 本章实测证据

以下是本次 `go tool pprof -top -alloc_space` 的实际摘要，完整命令见附录B。它解释累计分配来源，不表示同等大小存活堆。

```text
Showing nodes accounting for 663.06MB, 98.71% of 671.74MB total
344.03MB  51.21%  context.WithDeadlineCause
254.53MB  37.89%  time.newTimer
```


# 第15章 goroutine、阻塞与锁等待诊断

## CPU 很低也可能已经过载

服务响应慢但 CPU 不高，往往意味着请求在等待。可能等连接、等锁、等 channel、等慢客户端，也可能等一个永远不会到来的结果。此时继续分析 CPU 热点容易偏离方向。goroutine profile 能展示当前执行者停在哪里，配合业务计数与阶段信息，可以把等待归到具体资源。

随书现场采样在两个 worker 与八个等待槽都占满时，`/stats` 观察到 accepted 为十、started 为二、active 为二、queued 为八。这个稳定状态由故障负载维持，goroutine 栈可以与工作池实现逐项对照。我们不只数 goroutine 总量，而是找哪个栈负责哪一种等待。

若大量请求卡在 Transport 获取连接，先检查活跃响应体与连接上限；若大量 worker 卡在同一锁，检查持锁操作；若生成端卡在 channel 发送，检查下游是否已经离开。每种堆栈都应引出一个可以证伪的假设，而不是直接贴上“Go 调度器问题”的标签。

## 一张堆栈只是一个时刻

goroutine dump 是快照。正常系统也会有许多等待网络的 goroutine，单张图不能证明泄漏。应在相同负载阶段采集多次，看数量是否持续增长、相同任务是否长期不消失、解除故障后是否恢复。与任务 ID、在途计数和接纳速率关联，比孤立查看栈更可靠。

泄漏也不一定表现为 goroutine 增长。连接、文件、缓存引用或定时任务可能占用资源而没有新增 goroutine。反过来，连接池扩张到稳定规模会增加 goroutine，却未必是泄漏。判断依据是是否违反资源上限与生命周期，而不是增长本身令人不安。

现场采样结束后发送 SIGTERM，并给很短排空预算，本机记录十个已接纳任务全部进入终结，其中取消为十，active 和 queued 都归零，进程以失败状态退出。这里 completed 表示终结总数，包含失败与取消，不能把它解读成十个业务成功。准确理解字段比漂亮的归零截图更重要。

## 阻塞采样和锁采样

block profile 可用于观察某些同步阻塞，mutex profile 关注锁竞争。这些采样通常需要显式启用并选择采样率，默认行为与成本要查实际 Go 版本文档。启用很高采样率会改变程序运行，不应在未测量开销时长期全量运行。

锁竞争高时，先看锁保护了什么。若临界区包含网络、磁盘、日志写入或大对象编码，减少锁内工作可能比替换锁类型更有效。拆锁之前还要检查跨字段不变量，否则吞吐提高却引入状态不一致。优化结束后必须重跑关闭与接纳竞争测试。

channel 阻塞也可能是正确背压。一个有界 worker 等待任务，或者生产者在受控容量内等待消费者，不一定有问题。关键是等待是否符合预算、是否能取消、是否有明确拥有者。诊断不是消除所有等待，而是识别无界、无价值或违反契约的等待。

## trace 与事件顺序

运行时 trace 能帮助理解调度、系统调用、GC 与任务时序，但采集窗口和数据量需要控制。本书没有声称完成所有内核或调度器实验；如果读者进一步使用 trace，应先设计问题，例如某次延迟是否与 GC 或调度等待重叠，再选择短窗口验证。

应用级阶段事件也很重要。记录接纳、开始、外呼头到达、读取完成和终结，可以区分排队时间与执行时间。事件必须拥有稳定 ID 和一致时间基准，避免把不同请求的日志拼成一条虚假链路。高频路径宜聚合或采样，不能让诊断本身成为新的瓶颈。

现场不能随意暴露完整堆栈。栈、请求元数据和 profile 可能携带内部地址、路径与业务线索。受控采样与必要脱敏是工程操作要求，不妨碍本书本地实验。公开复现包应只保留教学负载，不包含真实用户数据。

## 练习：给三种等待分别命名

依次制造连接槽被占、worker 被可取消任务占用、客户端不读取响应。每次采集 goroutine 栈并写出一个判断：等待的是哪项资源，谁拥有它，解除条件是什么。然后解除故障，检查对应的恢复信号：连接实验要求下一次外呼完成，worker 实验可以检查 Pool 的 active，流实验则等待 handler 退出通知并核对 `stream finished`。`/stats` 不提供普通请求或流的在途计数，不能跨场景套用它。

最后选择一个等待，移除其取消路径，在独立子进程中验证总截止能够结束实验。不要把未退出 goroutine 遗留在整个测试进程。报告应同时包含故障状态与恢复状态，因为只有恢复才能证明你的解释触及了实际资源关系。

## 本章实测证据

以下摘自本次真实运行服务及管理端口采样记录。故障解除采用取消，终结计数包含失败与取消。

```text
LIVE STATS {accepted:10 started:2 completed:0 failed:0 cancelled:0 active:2 queued:8}
shutdown complete {accepted:10 started:10 completed:10 failed:10 cancelled:10 active:0 queued:0}
process exit: 1, error: context deadline exceeded
```


# 第16章 从现象到根因的故障诊断流程

## 先保护证据和容量

线上出现超时，第一反应常是增加线程、连接或超时。这样有时暂时改善，也可能把下游压垮并销毁原本清晰的边界。更稳妥的起点是确认影响范围、接纳与完成速率、拒绝比例和资源趋势。必要时降低接纳，给系统恢复空间，同时保留能够解释等待的证据。

本书可以在本地构造四类症状：客户端超时但工作仍在、队列饱和但 CPU 不高、上游看似正常却拿不到连接、部署停止迟迟不退出。每一类先写观察，再提出原因，最后用一个最小干预验证。不要把熟悉的故障名称直接当成根因，诊断需要能被证伪。

观察窗口要覆盖故障形成与恢复。只有峰值截图，可能把正常突发当成泄漏；只有恢复后截图，又看不到触发关系。保留配置、版本、请求参数与时间线，能够让后续实验重建相同边界。涉及业务数据时保留摘要，不必为了复现传播完整敏感内容。

## 场景一：客户端走了，工作还在

对 `/work`，先比较客户端返回时间与 `request finished` 日志出现时间。该日志表示可取消等待已经结束；要确认整个 handler 返回，可在测试中等待 `ServeHTTP` 返回后发出的 finished 通知，或使用 DrainRequests。若客户端超时后等待仍未结束，追踪 context 从处理函数到实际阻塞点是否连续。常见断点包括 Background、无条件 Sleep、无取消的 channel 发送，以及没有读取截止的外部库。修复应发生在真正阻塞处，不能只在外层 select 增加超时。

`/stats` 中的 active 只反映工作池任务，用它观察 `/work` 会一直得到与该请求无关的数值。只有诊断已接纳的后台任务时，才把 active 归零作为工作池释放证据。首先确认指标属于哪个执行者，是排查的前提。

验证时重复同一故障，并要求工作退出、计时器释放、后续请求恢复。客户端错误文本可能完全不变，这是预期的：原先客户端就已及时返回，修复的是服务内部资源。若验收只看 HTTP 状态，就看不到这类修复的价值。

如果工作包含外部副作用，还要单独核查是否已经提交。取消后的资源释放不能推导出业务未发生。结果未知时应记录核查入口，避免把一次资源故障升级为重复副作用。诊断报告要同时解释执行状态与业务状态。

## 场景二：连接池等待不断增长

确认请求究竟停在 DNS、建连、获取连接、等响应头还是读 Body。若上游没有收到新请求，且连接预算已被占满，检查先前响应体是否关闭及是否读到 EOF。将每主机连接限制调得很大可能暂时缓解，但会让泄漏更晚、更昂贵地暴露。

用随书连接测试验证：保留一个未读 Body，下一请求受 context 限制失败；关闭前一 Body 后恢复。若恢复成立，说明问题与连接持有直接相关。接着检查所有状态码分支和超限分支，确保错误响应也释放资源。不要只修复成功路径。

若响应体本身持续慢读，应设置总预算与大小上限，必要时放弃连接复用。与其无限 drain 试图“清理干净”，不如在有限成本内关闭。清理路径同样需要预算，这是许多连接问题被反复引入的原因。

## 场景三：队列满，重试让情况更坏

区分服务过载拒绝与执行错误。队列满时快速拒绝是在保护已接纳工作，如果客户端立即重试，接纳路径也可能被打满。检查各层重试是否叠加，是否有抖动与总预算，是否把拒绝从监控分母中移除。漂亮的成功请求平均延迟可能只是隐藏了大量拒绝。

恢复策略可以降低入口速率、暂停非关键任务或缩小某类并发，而不是无限增加队列。容量调整应关联最稀缺资源：数据库连接、远端配额、CPU 或内存。排队只是时间上的缓冲，不会创造处理能力。故障解除后还需控制积压回放，防止恢复洪峰。

验收使用占满、拒绝、释放、恢复四阶段。只证明拒绝及时，不证明释放后能恢复，仍可能遗漏关闭标志或计数损坏。重复测试应覆盖相邻多轮饱和，而不是每次重建进程让状态自然清空。

## 场景四：SIGTERM 后进程不退出

先确认信号是否到达，再看当前停在 HTTP Shutdown、工作池等待还是其他资源清理。使用已经取消的 context、无限等待不合作任务、持锁等待 worker、未管理的后台 goroutine 都可能造成问题。不要在没有诊断时直接把宽限期从十秒调成十分钟。

随书子进程测试给出了两个基线：预算内全部排空时正常退出；预算不足时取消工作、等待实际清理并以失败状态退出。若你增加消息消费者、数据库或 WebSocket，要把它们放进同一责任表，并增加真实关闭验收。函数返回不一定意味着进程可以结束。

## 练习：写一页可以复现的事故记录

选一个场景，按触发条件、用户影响、观察证据、被排除原因、修复和回归验收写记录。每个因果判断至少对应一个可重复实验；不确定之处直接标为待验证。把报告交给没有参与排查的人，要求他仅凭记录复现并确认恢复。能做到这一点，说明你已经把个人经验转化成可协作的工程知识。

## 本章实测证据

这一故障用恢复验证原因：取消阻塞读取后下一次请求可完成。它不能代表真实上游的所有协议和容量。 以下摘自最终本机 `go test -race -v ./...`，完整命令及环境见附录B；括号中的耗时仅是当次运行记录。

```text
--- PASS: TestFetcherBlockedBodyCancellationReleasesConnection (0.05s)
```


# 第17章 基准、容量与性能结论的边界

## 微基准回答局部成本

随书基准测量工作池接纳、调度与完成一条轻量任务的成本。它不是完整 HTTP 压测，不包含真实 TLS、代理、数据库或业务序列化。测试确保每个 benchmark operation 真正被接纳，队列饱和时的调度重试也计入成本，避免只测到“满了立即拒绝”的便宜路径。

在 Go 1.24.3、darwin/arm64、Apple M4 本机，三次记录分别约为九百五十点九、一千二百一十八和一千一百七十六 ns/op，均为三百零四 B/op、八次分配。这个波动本身就是证据：即便轻量基准也会受到调度、频率和后台活动影响。不能挑最小值宣称生产每秒可以处理多少任务。

这些数字更适合帮助定位下一步问题。例如每任务分配是否主要来自超时对象，某个改动是否引入额外复制，等待完成是否主导基准。只有在负载与环境可比、重复结果足够一致时，才讨论改进。若新实现更快却破坏取消与关闭契约，性能结果没有交付意义。

## 服务容量需要包含失败

完整服务实验至少要记录到达、接纳、完成、拒绝、超时和取消。只统计成功请求会让过载时的延迟看起来很好，因为最慢或最难的请求已被排除。对于有界服务，拒绝比例是容量曲线的一部分；对于无界服务，排队延迟和内存增长则可能是隐藏代价。

负载模型也要说明。闭环客户端完成一个再发下一个，服务变慢时到达率自动下降，可能掩盖排队；开放到达模型按计划持续发送，更容易暴露过载，但必须确保压测端自身没有先成为瓶颈。书中没有把本机微基准冒充开放流量容量实验。

平均延迟不能代表尾部。少量慢外呼就可能长时间占住 worker，使后续任务排队。应结合分位数、最长等待与拒绝观察，并按任务类型拆分。样本很少时，高分位数不稳定，应给出样本数量和运行时长，而不是用精确小数制造确定感。

## 资源预算应该共同验收

容量不仅是每秒完成多少，还包括峰值内存、连接数、队列上限和退出时间。一个服务吞吐提高一成，却让慢客户端无限保留连接，不能算完成性能工作。优化报告要把原先故障测试与新的负载结果一起呈现，证明资源边界没有倒退。

并发提高可能先改善吞吐，随后放大上下文切换、内存和下游排队。不存在适用于所有服务的 worker 数公式。CPU 密集、远端等待和大对象处理应分别测量。集群扩容还会放大对共享下游的总压力，单副本安全参数需要与副本数共同管理。

队列长度也不宜用作吞吐旋钮。更大队列可能降低短突发拒绝，却增加尾延迟和过期任务。应依据允许等待时间、到达波动与任务占用计算范围，再用故障实验验证。若业务不允许超过某个截止，接受大量注定过期任务只会浪费资源。

## 优化实验要有退出条件

开始优化前写下目标与守护约束。例如减少任务分配，同时保持满即拒绝、调用者取消语义和排空退出。若测量无法区分改进与噪声，就停止扩大结论，转向更具代表性的负载。不要因为已经花了很多时间就强行宣布成功。

profile 可以解释变化来源，基准可以量化局部成本，行为测试可以保护语义，三者的作用不同。一个 top 列表变短不一定意味着服务更好，一个基准数字下降也可能只是少做了必须完成的工作。应核对每个操作确实接纳、确实终结，且结果没有被静默丢弃。

环境记录应包含 Go 精确版本、架构、操作系统、CPU、命令和运行次数。不要用“最新版 Go”替代版本，因为编译器、运行时和标准库会变化。Linux CI 重新运行验证可移植行为，但其共享 runner 性能数字不宜与本机直接拼成优化对比。

## 练习：设计一个诚实的性能报告

先重复微基准至少三次，保留全部结果，再写出它能支持的一项判断和不能支持的两项判断。随后构造轻量、慢外呼和慢客户端三种服务负载，分别记录完成、拒绝、超时与资源峰值。不要为了一个统一吞吐数字把不同资源类型混在一起。

最后做一次过载后恢复实验，确认停止施压后队列排空、连接可用、内存回到合理稳定范围，并能在给定预算内退出。性能报告的最后一页应该说明系统如何退化与恢复，而不只是正常负载下最快的一秒。

## 本章实测证据

以下是本次三次完整样本，保留波动而不只挑最好值。具体方法见附录B及实验包记录。

```text
BenchmarkPoolSubmit-10  1279885   950.9 ns/op  304 B/op  8 allocs/op
BenchmarkPoolSubmit-10  1000000    1218 ns/op  304 B/op  8 allocs/op
BenchmarkPoolSubmit-10   910029    1176 ns/op  304 B/op  8 allocs/op
```


# 第18章 把实验交付成可持续维护的服务

## 一次通过不够，入口必须一致

本书实验可以从仓库的 `lab` 目录运行，也可以从网页下载 ZIP 后在解压目录运行。两种入口使用同一份 Go 源码和模块文件，不依赖仓库外私有路径。发布构建把实验文件直接放进代码附录，并生成文件哈希清单；正文、网页与 EPUB 的逐章文本由独立校验确认一致。

这套发布方式解决的是内容追溯，不替代工程审查。文字可能仍有需要修正的解释，测试也有覆盖范围。读者应结合实验清单判断结论：真实 TCP 连接耗尽与恢复已经执行，真实慢读取与写截止已经执行，真实子进程信号退出已经执行；没有执行的内核或集群实验不能借此获得证明。

持续集成固定 Go 1.24.3，在 Linux 重跑 race、重复故障和进程测试，再构建电子书、验证正文一致、运行 EPUBCheck 与浏览器阅读检查。发布附件来自成功 CI 的下载产物，网页与 Release 中的 EPUB 应逐字节一致。这样读者可以确认自己读到的版本与被验证版本相同。

## 将实验不变量带到生产设计

第一项是接纳边界。无论接入 HTTP、消息消费还是定时任务，都要找到最早可以廉价拒绝的位置，限制等待与执行总量。第二项是取消链。把停止信号传播到实际阻塞点，并用资源恢复证明链路完整。第三项是退出所有权。停止接纳、等待结束和清理共享依赖必须由明确组件负责。

第四项是结果语义。内存接纳不等于持久化，超时不等于未执行，取消不等于回滚。需要可靠副作用时，将这些窗口交给业务状态机和幂等协议处理。第五项是可观测性。计数、阶段和 profile 应帮助回答具体问题，不应生成无法关联的噪声。

把这五项写进代码评审清单，比要求每个人“注意并发安全”更可执行。评审者可以直接问：这个 goroutine 谁等待？这个响应体谁关闭？队列满时谁拒绝？Shutdown 的 context 从哪里来？新增库是否真正响应取消？每个问题都对应本书一个可运行实验。

## 可运行证据如何阅读

实验包 `EXPERIMENTS.md` 保存实际命令、版本、输出与解释。先看完整 race 测试，再看重复故障组与信号组，最后看基准和 profile 摘要。重复测试提高对调度变化的覆盖，不能取代所有权推理；profile 支持成本解释，不能证明所有场景的性能。

终结统计字段要特别注意：completed 表示已终结的总任务数，failed 是其中错误终结，cancelled 又是可识别取消错误的一部分。它们不是互斥三类，不能简单相加。稳定退出后 accepted 与 completed 可用于守恒检查；运行中跨多个原子字段的快照可能暂时不一致。

本机现场实验有一个很好的闭环：先看到十个 accepted、两个 active、八个 queued；退出预算不足时取消所有工作；最后看到十个 completed、十个 cancelled，active 与 queued 归零。这里正确表述是全部任务进入终结且均被取消，绝不能写成十个任务业务成功。

## 发布与修复也有边界

如果正文修正但代码不变，仍要重新构建并验证文字同步。若代码变化，则重跑对应行为与 race。发现已发布版本存在问题，应发布补丁版本并保留旧附件，让读者根据版本和哈希核对。静默覆盖同一个 Release 文件，会让事故记录失去可追溯性。

构建输入应覆盖 Go 源码、模块文件、嵌入资源、README、许可证、书稿与网页脚本。证据记录和内部原始参考缓存可以单独管理，避免一次修改交付记录又触发整套产品重建。这个区分是构建依赖管理问题，不是减少必要校验的借口。

公网验证应从真正部署的 URL 读取全篇，在窄屏和无 JavaScript 下确认正文可读，同时检查下载附件。站点交互与书稿内容是两个验收面：搜索好用不代表章节完整，EPUB 合规也不代表在线目录可用。最终记录应同时包含源版本、CI run、公开地址与产物哈希。

## 练习：给自己的服务建立最小交付证据

从一个现有接口开始，选择取消、连接、过载和退出四类边界各写一条测试。把测试放进固定版本 CI，要求实验从干净目录可运行。再写一份简短证据说明，列出环境、命令、结果与未覆盖范围，不必追求庞大的报告系统。

后续每次增加新的后台执行者、缓存、外呼库或流式协议，都更新责任表与故障实验。服务可靠性不是一次重构得到的永久属性，而是代码变化之后仍能持续证明边界成立的能力。愿你在下一次遇到卡住的请求时，能够沿着取消、所有权与资源预算，找到一个可以验证的答案。

## 本章实测证据

最终完整race执行的包级实际结果如下。独立实验ZIP随后再从空目录解压运行；发布校验及公网版本以仓库DELIVERY记录为准。

```text
ok  example.com/go-service-engineering/lab/cmd/service       3.075s
ok  example.com/go-service-engineering/lab/internal/service  2.009s
```


# 附录A：官方来源与阅读边界

本书于2026年10月21日实际下载并阅读以下官方材料。标准库API链接固定Go 1.24.3；Go博客和语言规范是持续维护文档，不能把页面当前内容自动当作全部版本的行为。原始网页仅存本地研究缓存，不随仓库或电子书再分发；来源索引保存读取日期、URL与缓存SHA256。

| 来源 | 对应问题 | 使用范围 |
| --- | --- | --- |
| [Go 1.24发布说明](https://go.dev/doc/go1.24) | 版本基线、运行时与标准库变化 | 实验基线是实际go1.24.3，不宣称最新版 |
| [context@go1.24.3](https://pkg.go.dev/context@go1.24.3) | CancelFunc、截止、Cause、WithoutCancel | 第2、3、6、10章的取消协议与调用者责任 |
| [net/http@go1.24.3](https://pkg.go.dev/net/http@go1.24.3) | Body、Transport、Shutdown、ResponseController | 第7至11章协议和生命周期 |
| [os/signal@go1.24.3](https://pkg.go.dev/os/signal@go1.24.3) | NotifyContext与stop | 第11章信号处理范围 |
| [Go内存模型](https://go.dev/ref/mem) | 同步关系与可见性 | 第4章，不把调度或Sleep当作同步 |
| [Data Race Detector](https://go.dev/doc/articles/race_detector) | race的使用与动态检测边界 | 第12、13章，只覆盖实际运行路径 |
| [Go Concurrency Patterns: Pipelines and cancellation](https://go.dev/blog/pipelines) | channel关闭、上游退出与下游提前离开 | 第2至4章生命周期设计 |
| [Diagnostics](https://go.dev/doc/diagnostics) | profiling、tracing、运行时信息的用途 | 第14至16章工具选择 |
| [runtime/pprof@go1.24.3](https://pkg.go.dev/runtime/pprof@go1.24.3) | heap、allocs、block与mutex采样 | 第14、15章，不把alloc_space当存活堆 |

阅读标准库文档时，本书特别核对了五个容易误用的地方。CancelFunc需要调用以释放关联资源；Client.Do在非二百状态时也可能返回无错误响应；Body读取与关闭影响连接复用；MaxConnsPerHost包含建立中、活跃和空闲连接并可能导致等待；Server.Shutdown不等待任意后台任务或hijack连接。

文档给出机制契约，实验验证本工程组合机制后的行为。二者不能互相替代。一个API允许并发使用，不代表调用者修改的共享对象也安全；一个关闭函数返回，也不代表全部业务goroutine已结束。书中断言以明确工作负载和测试边界为准。

本书没有引用虚构生产故障，没有声称测量内核TCP实现，也没有运行跨集群容量实验。HTTP/2、TLS代理、真实数据库、外部幂等系统与容器编排的建议属于扩展设计，需要在实际部署路径重新验证。读者复用代码时，应先识别这些依赖边界。


# 附录B：复现实验与验收清单

## 独立运行入口

下载实验ZIP，解压到空目录，确保go.mod就在当前目录。仓库用户则进入lab。实验只依赖Go标准库；race需要支持的平台、C编译器和CGO。真实信号测试要求Unix语义，因此本书本机与CI分别覆盖macOS arm64、Linux amd64。

```sh
go version
go test -race -v ./...
go vet ./...
```

本机最终复核实际为Go 1.24.3 darwin/arm64。完整race输出包含两个SIGTERM测试和十二个服务行为测试，包级结果为cmd/service 3.075s、internal/service 2.009s。耗时是记录，不是读者机器上的固定断言。全部具体输出同时收录在实验包EXPERIMENTS中。

## 重复故障路径

```sh
go test -race ./internal/service -run 'Test(PoolQueueBound|PoolBlockedCancellationReleases|PoolConcurrentCloseAndSubmit|ConnectionExhaustionAndRecovery|FetcherBlockedBodyCancellationReleasesConnection|SlowStreamDeadlineReleasesHandler|DrainRequestsWaitsForHandlerAndGatesAdmission)$' -count=30 -timeout=60s
go test -race ./cmd/service -run TestSIGTERM -count=10 -timeout=60s
```

本机实际结果分别为8.224s与17.458s，均通过。第一组每轮覆盖队列、取消、并发关闭、连接耗尽恢复、阻塞读体取消、慢读取和handler排空；第二组每轮启动两个真实子进程场景。重复次数不是数学证明，测试本身仍有局部截止与清理。

| 实验 | 故障注入 | 验收不变量 |
| --- | --- | --- |
| 工作池容量 | 阻塞worker并填满队列 | 多余提交立即拒绝，释放后全部终结 |
| 调用者取消 | 接纳成功后取消请求context | 已接纳任务继续由池负责 |
| 任务取消 | 超过任务截止或关闭预算 | Work返回，active最终归零 |
| 连接耗尽 | 保留未读Body占住连接 | 后续请求截止失败，关闭后恢复 |
| 读取取消 | 上游发头后阻塞Body | 取消传到网络，下一请求恢复 |
| 慢客户端 | 真实TCP对端不读256MiB | 写截止触发，handler返回 |
| 正常SIGTERM | 在途请求和运行/排队任务 | 全部排空后进程正常退出 |
| 超时SIGTERM | 五秒请求和任务、80ms预算 | 请求与worker实际释放，进程非零退出 |

## 基准和profile

```sh
go test ./internal/service -run '^$' -bench BenchmarkPoolSubmit -benchmem -count=3
mkdir -p evidence
go test ./internal/service -run '^$' -bench BenchmarkPoolSubmit -benchtime=2s -benchmem -cpuprofile evidence/pool.cpu.pprof -memprofile evidence/pool.heap.pprof -o evidence/service.test
go tool pprof -top evidence/pool.cpu.pprof
go tool pprof -top -alloc_space evidence/pool.heap.pprof
```

三个本机基准分别为950.9、1218、1176 ns/op，均304 B/op和8 allocs/op。每个op最终成功接纳，包含满队列重试、worker、context与日志格式化，日志写入io.Discard。它不代表完整HTTP容量，也不支持生产p99结论。

实际alloc_space采样中context.WithDeadlineCause与time.newTimer占主要部分；CPU采样以运行时等待相关栈为主，不能据此决定业务CPU优化。在线管理端口采样与终结统计的摘要见第15章，原始二进制采样不分发，实验记录保留SHA256供本次证据识别。重采内容受时间与调度影响，不要求哈希相同。

## 按能力验收扩展

给工程增加数据库时，应补连接获取、查询取消与事务结果未知；增加持久任务时，应补领取竞争、崩溃恢复与业务幂等；增加WebSocket时，应补独立关闭通知和等待；增加HTTP/2或代理时，应补流取消与端到端传输。不要用现有测试的通过状态替这些新能力背书。

每次实验记录配置、输入、触发同步点、观察上限和恢复状态。只显示错误而不检查恢复，无法证明资源已经释放；只显示恢复后的成功，也无法证明故障期间守住了容量。两端证据共同构成一个完整实验。


# 附录C：术语与工程检查表

| 术语 | 本书中的准确含义 |
| --- | --- |
| 取消 | 通知执行者工作已无继续价值；需要协作响应，不强杀goroutine |
| 截止 | 一个绝对时间点；有效截止受父context更早时间限制 |
| 资源所有权 | 对停止使用、关闭和等待最终完成承担责任 |
| 接纳 | 工作进入服务负责的范围；本工程仅内存接纳 |
| 排空 | 不再接纳，让已经承担的工作继续走向终结 |
| 强制清理 | 预算到期后关闭连接、发取消并有限等待合作退出 |
| 终结 | 成功、失败或取消等不再继续的状态，不等于业务成功 |
| 背压 | 下游能力通过等待或拒绝影响上游；仍需限制等待资源 |
| 过载 | 到达需求超过可用处理预算，应该受控拒绝或退化 |
| 数据竞争 | 未建立适当同步的冲突内存访问 |
| 逻辑竞态 | 时序影响业务语义，可能完全没有数据竞争 |
| 累计分配 | 某时间范围产生过的对象字节，包括已回收部分 |
| 存活堆 | 采样所反映仍保留的对象，不等同进程RSS |
| 结果未知 | 调用失败但无法判断远端副作用是否已发生 |

代码评审时，优先回答五组问题。第一组是执行：新goroutine由谁取消、谁等待、错误交给谁？第二组是资源：Body、连接、文件、计时器与缓冲区谁拥有，跨函数后责任是否变化？第三组是容量：运行、排队、输入和输出分别在哪里限额？第四组是关闭：接纳与关闭是否原子排序，是否有共享总截止？第五组是证据：测试是否真的触发阻塞并确认恢复，还是仅检查表面状态码？

诊断时先保留版本、配置和现象，再找对应阶段。客户端超时后看服务工作是否退出；CPU低但延迟高时看等待栈；连接等待时看响应体生命周期；部署卡住时看关闭责任与剩余预算。避免在没有证据时同时修改多个限制，否则即使现象消失，也很难知道哪一个动作有效。

扩展时坚持同一原则：新依赖带来新责任，新队列带来新等待，新缓存带来新保留，新协议带来新关闭边界。可靠性来自这些关系保持可解释和可验证，而不是某个库名、语言特性或一次工具通过。


# 附录：随书实验完整代码

以下文件在构建时从随书实验与测试目录读取，与下载区实验包同源。它们是本书教学实现；实际验证范围以实验章节和验证记录为准。

## lab/EXPERIMENTS.md

~~~~~~text
# 实测记录

采集日期：2026-10-21，约 10:23—10:28 UTC。环境：`go version go1.24.3 darwin/arm64`，Apple M4，`CGO_ENABLED=1`。基准后缀 `-10` 表示当次 GOMAXPROCS=10。以下是本次实际输出，不是性能目标；读者机器上的数值会不同。

## 正确性与故障重复

```text
$ go test -race ./...
ok  example.com/go-service-engineering/lab/cmd/service       2.981s
ok  example.com/go-service-engineering/lab/internal/service  1.901s

$ go vet ./...
[无输出，退出状态 0]

$ go test -race ./internal/service -run 'Test(PoolQueueBound|PoolBlockedCancellationReleases|PoolConcurrentCloseAndSubmit|ConnectionExhaustionAndRecovery|FetcherBlockedBodyCancellationReleasesConnection|SlowStreamDeadlineReleasesHandler|DrainRequestsWaitsForHandlerAndGatesAdmission)$' -count=30 -timeout=60s
ok  example.com/go-service-engineering/lab/internal/service  8.224s
```

每轮涵盖：固定上限、满队列拒绝、阻塞任务到期释放、并发 Submit/Close、真实 TCP 连接占满后超时及恢复、读响应体阻塞时取消释放、完全不读响应的慢客户端写截止、handler 排空入口和等待。共 30 轮，测试本身的等待也有上限。

`cmd/service` 的两个 SIGTERM 测试分别检查：3 秒预算内，一个 150ms 在途 HTTP 请求、350ms 运行任务和 150ms 排队任务全部排空；80ms 预算内，一个 5s 请求和 5s 任务被取消，handler 和 worker 均释放，进程非零退出。重复执行命令：

```sh
go test -race ./cmd/service -run TestSIGTERM -count=10 -timeout=60s
```

最终输出：

```text
ok  example.com/go-service-engineering/lab/cmd/service  17.458s
```

## 基准

```text
$ go test ./internal/service -run '^$' -bench BenchmarkPoolSubmit -benchmem -count=3
goos: darwin
goarch: arm64
pkg: example.com/go-service-engineering/lab/internal/service
cpu: Apple M4
BenchmarkPoolSubmit-10      1279885       950.9 ns/op     304 B/op     8 allocs/op
BenchmarkPoolSubmit-10      1000000        1218 ns/op     304 B/op     8 allocs/op
BenchmarkPoolSubmit-10       910029        1176 ns/op     304 B/op     8 allocs/op
PASS
ok  example.com/go-service-engineering/lab/internal/service  3.830s
```

每个 op 都成功接受一个空任务；满队列重试、四个 worker、每任务 timeout context 和同步日志格式化都包含在测量中。日志实际写入 `io.Discard`，不包含终端或磁盘 I/O。基准结束后等待所有已接受任务退出。三次波动不能推出容量保证、HTTP p99 或线上性能。

## 实际 profile 摘要

```text
$ go test ./internal/service -run '^$' -bench BenchmarkPoolSubmit -benchtime=2s -benchmem -cpuprofile profiles/pool.cpu.pprof -memprofile profiles/pool.heap.pprof -o /tmp/go-service-engineering-profile.test
BenchmarkPoolSubmit-10      2343188       974.5 ns/op     305 B/op     8 allocs/op
PASS
ok  example.com/go-service-engineering/lab/internal/service  2.745s
```

本次采集的 `pool.cpu.pprof` 的 `go tool pprof -top -nodecount=12` 摘录：

```text
Duration: 2.41s, Total samples = 8.56s (355.91%)
flat     flat%    function
4.09s    47.78%   runtime.usleep
1.71s    19.98%   runtime.pthread_cond_wait
0.49s     5.72%   runtime.pthread_cond_signal
0.40s     4.67%   runtime.madvise
0.11s     1.29%   runtime.walltime
0.09s     1.05%   internal/sync.(*Mutex).Unlock
```

多核样本总量可以超过墙钟时长。这份 Darwin 样本主要落在 runtime/系统等待栈；不能据此把 `usleep` 当作业务 CPU 热点或给出锁优化结论。应结合更长采样、调用栈、block/mutex profile 和业务负载再判断。

本次采集的 `pool.heap.pprof` 的 `-alloc_space`：

```text
Showing nodes accounting for 663.06MB, 98.71% of 671.74MB total
flat        flat%    cum        function
344.03MB    51.21%   598.56MB   context.WithDeadlineCause
254.53MB    37.89%   254.53MB   time.newTimer
38MB         5.66%   651.06MB   service.(*Pool).run
13.50MB      2.01%   15MB       service.(*Pool).Submit
13MB         1.94%   13.50MB    fmt.Sprintf
```

这里是约 234 万个任务累积分配的采样量，绝不是 671.74MB 存活堆或泄漏证据。每任务 deadline context/timer 是此次累计分配的主要来源。

在线 profile 从实际编译的服务、真实 HTTP 管理端口采集：先提交两个 `delay=5s` 任务并等待 active=2，再提交八个任务；请求 `/debug/pprof/goroutine?debug=2` 和 `/debug/pprof/heap?gc=1`，分别保存为 `profiles/live.goroutine.txt`、`profiles/live.heap.pprof`。

```text
LIVE STATS {accepted:10 started:2 completed:0 failed:0 cancelled:0 active:2 queued:8}
SIGTERM, SHUTDOWN_TIMEOUT=100ms
shutdown complete {accepted:10 started:10 completed:10 failed:10 cancelled:10 active:0 queued:0}
process exit: 1, error: context deadline exceeded
```

取消后的 `started=10` 表示十个队列项目均被 worker 处理到完成状态，不代表被取消的八个排队业务函数执行过。goroutine 文本可直接定位两个 worker 的 `service.wait`/`select` 栈；它是单时刻栈快照。最终在线 heap 样本约 2051 KiB，主要为 runtime 与压缩编码初始化分配；小规模业务对象未必被默认采样率抽中。读取方法与重采命令见 README。

发布包包含摘要和 `profiles/live.goroutine.txt`；二进制采样保存在本次构建的本地 evidence，不随代码包发布。以下 SHA256 标识本次二进制采样，因时间戳和运行调度不同，重采不要求散列相同：

| 文件 | SHA256 |
| --- | --- |
| `pool.cpu.pprof` | `3efac2e4630bebfe7f81352fc2b2e3d17d6e343a602a8f4115853709d9023121` |
| `pool.heap.pprof` | `2deb44a62a2ab8b85aa6ef6af5c6b0c5163ff2d0787322ccc174144794266132` |
| `live.heap.pprof` | `5202267c274e0f830baf32e0328f0a210d94af6acac2845e74f37a84080b6f18` |

CI 应重新采样并验证 pprof 可以解析，而不检查固定百分比：

```sh
mkdir -p evidence
go test ./internal/service -run '^$' -bench BenchmarkPoolSubmit -benchtime=2s -benchmem -cpuprofile evidence/ci.cpu.pprof -memprofile evidence/ci.heap.pprof -o evidence/service.test
go tool pprof -top evidence/ci.cpu.pprof
go tool pprof -top -alloc_space evidence/ci.heap.pprof
```
~~~~~~

## lab/LICENSE

~~~~~~text
MIT License

Copyright (c) 2026 Go Service Engineering Lab contributors

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
~~~~~~

## lab/README.md

~~~~~~text
# Go 服务工程实验

《Go 服务工程：并发、资源管理与故障诊断》的独立实验工程。只依赖 Go 标准库，实测 Go 1.24.3；在 macOS/Linux 上解压本目录后直接执行：

```sh
go test -race ./...
```

Race 检测需要支持的平台、C 编译器和 `CGO_ENABLED=1`。普通测试可执行 `go test ./...`。进程集成测试发送真正的 SIGTERM，因此需要 Unix 信号语义。

## 启动与请求

```sh
go build -o /tmp/go-service-lab ./cmd/service
/tmp/go-service-lab
```

在另一个终端执行：

```sh
curl -i -X POST 'http://127.0.0.1:8080/tasks?delay=250ms'
curl 'http://127.0.0.1:8080/stats'
curl 'http://127.0.0.1:8080/work?delay=250ms'
curl -o /dev/null 'http://127.0.0.1:8080/stream?bytes=1048576'
curl -i -X POST 'http://127.0.0.1:8080/tasks?url=https%3A%2F%2Fexample.com'
```

`POST /tasks` 返回 202 和任务 ID；这是进入内存队列的确认，不是任务完成或持久化的确认。队列已满或已经关闭时返回 503 和 `Retry-After: 1`。ID 允许有空号。`GET /work` 在当前请求中等待；`GET /stream` 写指定字节数，默认 64 MiB，最大 256 MiB。`GET /healthz` 检查 HTTP 存活。`/stats` 是多个原子计数器的近似同时快照，运行中不保证跨字段等式；停稳后的计数可用于核对接收、完成和释放边界。

| 环境变量 | 默认值 | 含义 |
| --- | --- | --- |
| `LISTEN_ADDR` | `127.0.0.1:8080` | 业务 HTTP 监听地址；`:0` 分配临时端口 |
| `ADMIN_ADDR` | `127.0.0.1:6060` | 独立 pprof 监听地址 |
| `WORKERS` | `2` | 固定后台 worker 数 |
| `QUEUE_CAPACITY` | `8` | 等待队列上限，不含正在执行的任务 |
| `TASK_TIMEOUT` | `5s` | worker 开始执行后每个任务的超时 |
| `STREAM_WRITE_TIMEOUT` | `250ms` | 整段流式响应的绝对写截止 |
| `SHUTDOWN_TIMEOUT` | `5s` | HTTP 请求排空和后台任务排空共享的预算 |

将 `WORKERS=1 QUEUE_CAPACITY=1` 设置后并发提交多个 `delay=5s` 任务，可观察过载拒绝。实验的 URL 参数允许访问调用者提供的 HTTP(S) 地址；服务和 pprof 默认只监听本机。本工程不包含身份认证、URL 出站策略和磁盘队列。

## 所有权与退出约定

- `Pool.Submit(ctx, Work)` 中的 `ctx` 只控制入队。任务一旦接受，就由 Pool 的根 context 和每任务截止管理。任务闭包只保存参数，不保存请求、ResponseWriter 或请求 context。
- Pool 用固定 worker 和有界 channel 控制并发；入队时使用 `select/default`，不等待队列空位。并发关闭与入队由同一个锁保护。`Close` 停止接收并排空队列，`Shutdown(ctx)` 在预算用尽后发出取消，`Done()` 表示 worker 已经实际返回。
- 任务超时从执行开始计算，不包含排队时间。被强制取消的排队任务会被 worker 取出并记为取消完成，不会调用业务函数。`Work` 必须响应 context 并释放自己的资源；Go 无法强杀一个忽略取消的 goroutine。任务 panic 会传播并终止进程。
- SIGTERM/SIGINT 先停止 HTTP 监听并等待现有 handler，再关闭任务入口并排空已接受任务。共享预算到期后关闭 HTTP 连接、取消任务，然后额外等待最多 1 秒合作清理。`App.DrainRequests(ctx)` 显式等待 handler 返回，因为 `http.Server.Close` 本身不等待 handler；它先关闭 handler 入口，再等待 WaitGroup，避免零计数时 Add 与 Wait 的竞态。超出预算以非零状态退出；正常排空为零。
- Fetcher 的 Client/Transport 全程共享。所有响应体只有一个关闭者；小响应包括错误状态都读到 EOF 再 Close，以复用连接。响应最多消费 1 MiB+1 字节，超限关闭响应体并放弃该连接的复用。连接数、拨号、TLS 握手、响应头等待和任务总时长分别有界。
- `/stream` 使用 `http.ResponseController.SetWriteDeadline` 为整个流设绝对截止。HTTP/1.1 响应设置 `Connection: close`，让截止继续覆盖 net/http 的最后一次缓冲刷新；不在 handler 返回前清除截止。普通路由继续使用 keep-alive。这里的时间限制是整段流上限，不是每次成功写入后的空闲计时。

## 可重复故障实验

```sh
go test -race ./internal/service -run 'Test(PoolQueueBound|PoolBlockedCancellationReleases|PoolConcurrentCloseAndSubmit|ConnectionExhaustionAndRecovery|FetcherBlockedBodyCancellationReleasesConnection|SlowStreamDeadlineReleasesHandler|DrainRequestsWaitsForHandlerAndGatesAdmission)$' -count=30 -timeout=60s
go test -race ./cmd/service -run TestSIGTERM -count=10 -timeout=60s
go test ./internal/service -run '^$' -bench BenchmarkPoolSubmit -benchmem -count=3
```

所有故障测试有自己的等待上限。连接耗尽测试使用真实 TCP：故意占住一个未读响应体，让后续请求等待连接至 context 超时，然后关闭响应体并验证恢复。另一个测试让响应体读取阻塞，验证取消传播到服务端并恢复连接槽。慢客户端测试发送请求后完全不读 256 MiB 响应，验证写截止使 handler 返回。子进程测试直接运行生产 `main` 和 signal handler，检查在途请求、运行任务、排队任务，以及截止后请求/任务取消后的实际释放。

基准的每个 op 都最终成功入队；队列满时 `runtime.Gosched()` 重试的成本也计算在内。它测量包含 worker/context/日志格式化的池吞吐，不代表 HTTP 服务吞吐或单次 Submit 的延迟。结果、机器信息及采样摘要见 [EXPERIMENTS.md](EXPERIMENTS.md)。

## pprof

运行服务时可保存在线采样：

```sh
mkdir -p profiles
curl -o profiles/live.cpu.pprof 'http://127.0.0.1:6060/debug/pprof/profile?seconds=10'
curl -o profiles/live.heap.pprof 'http://127.0.0.1:6060/debug/pprof/heap?gc=1'
curl -o profiles/live.goroutine.txt 'http://127.0.0.1:6060/debug/pprof/goroutine?debug=2'
go tool pprof -top -inuse_space profiles/live.heap.pprof
```

采 CPU 时在另一个终端运行负载；空闲服务的 profile 可能几乎没有业务样本。代码包附带可读 goroutine 快照、profile 摘要和本次采样的 SHA256，二进制 profile 由以下命令重新生成：

```sh
go test ./internal/service -run '^$' -bench BenchmarkPoolSubmit -benchtime=2s -benchmem -cpuprofile profiles/pool.cpu.pprof -memprofile profiles/pool.heap.pprof -o /tmp/go-service-profile.test
go tool pprof -top profiles/pool.cpu.pprof
go tool pprof -top -alloc_space profiles/pool.heap.pprof
```

`alloc_space` 是累计分配采样，`inuse_space` 是采样时仍存活的堆，不能混用。采样受平台、时长和采样率影响；小负载的 heap profile 可能只显示 runtime，不能据此断言业务没有分配。

源代码及随附文档按 [MIT License](LICENSE) 发布。
~~~~~~

## lab/cmd/service/main.go

~~~~~~go
package main

import (
	"context"
	"errors"
	"fmt"
	"log/slog"
	"net"
	"net/http"
	_ "net/http/pprof"
	"os"
	"os/signal"
	"strconv"
	"syscall"
	"time"

	"example.com/go-service-engineering/lab/internal/service"
)

func env(key, fallback string) string {
	if value := os.Getenv(key); value != "" {
		return value
	}
	return fallback
}

func main() {
	logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
	ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
	defer stop()
	if err := run(ctx, logger); err != nil {
		logger.Error("service stopped", "error", err)
		os.Exit(1)
	}
}

func run(ctx context.Context, logger *slog.Logger) error {
	cfg := service.DefaultConfig()
	cfg.Logger = logger
	var err error
	if cfg.Workers, err = strconv.Atoi(env("WORKERS", "2")); err != nil {
		return fmt.Errorf("WORKERS: %w", err)
	}
	if cfg.QueueCapacity, err = strconv.Atoi(env("QUEUE_CAPACITY", "8")); err != nil {
		return fmt.Errorf("QUEUE_CAPACITY: %w", err)
	}
	if cfg.TaskTimeout, err = time.ParseDuration(env("TASK_TIMEOUT", "5s")); err != nil {
		return fmt.Errorf("TASK_TIMEOUT: %w", err)
	}
	if cfg.StreamWriteTimeout, err = time.ParseDuration(env("STREAM_WRITE_TIMEOUT", "250ms")); err != nil {
		return fmt.Errorf("STREAM_WRITE_TIMEOUT: %w", err)
	}
	grace, err := time.ParseDuration(env("SHUTDOWN_TIMEOUT", "5s"))
	if err != nil || grace <= 0 {
		return errors.New("SHUTDOWN_TIMEOUT must be a positive duration")
	}
	app, err := service.New(cfg)
	if err != nil {
		return err
	}
	defer app.Fetcher.Close()
	defer func() {
		cleanup, cancel := context.WithCancel(context.Background())
		cancel()
		_ = app.Pool.Shutdown(cleanup)
	}()
	listener, err := net.Listen("tcp", env("LISTEN_ADDR", "127.0.0.1:8080"))
	if err != nil {
		return err
	}
	defer listener.Close()
	adminListener, err := net.Listen("tcp", env("ADMIN_ADDR", "127.0.0.1:6060"))
	if err != nil {
		return err
	}
	defer adminListener.Close()
	server := &http.Server{Handler: app.Handler(), ReadHeaderTimeout: 2 * time.Second, ReadTimeout: 5 * time.Second, IdleTimeout: 30 * time.Second, MaxHeaderBytes: 16 << 10}
	admin := &http.Server{Handler: http.DefaultServeMux, ReadHeaderTimeout: 2 * time.Second, IdleTimeout: 30 * time.Second}
	defer server.Close()
	defer admin.Close()
	serveErrors := make(chan error, 2)
	go func() { serveErrors <- server.Serve(listener) }()
	go func() { serveErrors <- admin.Serve(adminListener) }()
	logger.Info("listening", "address", listener.Addr().String(), "admin", adminListener.Addr().String())
	select {
	case <-ctx.Done():
	case err := <-serveErrors:
		if !errors.Is(err, http.ErrServerClosed) {
			return err
		}
	}
	logger.Info("shutdown started")
	shutdown, cancel := context.WithTimeout(context.Background(), grace)
	defer cancel()
	// Shutdown first stops admission at the listener and waits for request
	// handlers. Only then close task admission and drain accepted background work.
	httpErr := server.Shutdown(shutdown)
	if httpErr != nil {
		_ = server.Close()
	}
	_ = admin.Close()
	poolErr := app.Pool.Shutdown(shutdown)
	// Closing sockets cancels request contexts, but Server.Close does not wait
	// for their handlers. Explicitly wait for cooperative cleanup as well.
	cleanup, cleanupCancel := context.WithTimeout(context.Background(), time.Second)
	defer cleanupCancel()
	requestErr := app.DrainRequests(cleanup)
	select {
	case <-app.Pool.Done():
	case <-cleanup.Done():
		return errors.New("workers ignored cancellation for one second")
	}
	logger.Info("shutdown complete", "stats", app.Pool.Stats(), "http_error", httpErr, "pool_error", poolErr, "request_error", requestErr)
	return errors.Join(httpErr, poolErr, requestErr)
}
~~~~~~

## lab/cmd/service/main_test.go

~~~~~~go
package main

import (
	"bufio"
	"bytes"
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"os"
	"os/exec"
	"strings"
	"sync"
	"syscall"
	"testing"
	"time"
)

// The helper is a real child process running the production main and signal
// handler. It also runs under the race runtime when invoked by go test -race.
func TestProcessHelper(t *testing.T) {
	if os.Getenv("GO_SERVICE_PROCESS_HELPER") != "1" {
		return
	}
	main()
	os.Exit(0)
}

type process struct {
	cmd    *exec.Cmd
	events chan map[string]any
	done   chan error
	mu     sync.Mutex
	logs   bytes.Buffer
}

func startProcess(t *testing.T, grace string) (*process, string) {
	t.Helper()
	cmd := exec.Command(os.Args[0], "-test.run=^TestProcessHelper$")
	cmd.Env = append(os.Environ(), "GO_SERVICE_PROCESS_HELPER=1", "LISTEN_ADDR=127.0.0.1:0", "ADMIN_ADDR=127.0.0.1:0", "WORKERS=1", "QUEUE_CAPACITY=2", "TASK_TIMEOUT=10s", "SHUTDOWN_TIMEOUT="+grace)
	stdout, err := cmd.StdoutPipe()
	if err != nil {
		t.Fatal(err)
	}
	cmd.Stderr = cmd.Stdout
	p := &process{cmd: cmd, events: make(chan map[string]any, 128), done: make(chan error, 1)}
	if err := cmd.Start(); err != nil {
		t.Fatal(err)
	}
	go func() {
		scanner := bufio.NewScanner(stdout)
		for scanner.Scan() {
			p.mu.Lock()
			_, _ = fmt.Fprintln(&p.logs, scanner.Text())
			p.mu.Unlock()
			var event map[string]any
			if json.Unmarshal(scanner.Bytes(), &event) == nil {
				p.events <- event
			}
		}
		p.done <- cmd.Wait()
	}()
	t.Cleanup(func() { _ = cmd.Process.Kill() })
	event := p.waitEvent(t, "listening")
	return p, "http://" + event["address"].(string)
}

func (p *process) output() string { p.mu.Lock(); defer p.mu.Unlock(); return p.logs.String() }

func (p *process) waitEvent(t *testing.T, message string) map[string]any {
	t.Helper()
	timer := time.NewTimer(5 * time.Second)
	defer timer.Stop()
	for {
		select {
		case event := <-p.events:
			if event["msg"] == message {
				return event
			}
		case <-timer.C:
			t.Fatalf("missing event %q; logs:\n%s", message, p.output())
			return nil
		}
	}
}

func postTask(t *testing.T, url string) {
	t.Helper()
	client := &http.Client{Timeout: 2 * time.Second}
	resp, err := client.Post(url, "", nil)
	if err != nil {
		t.Fatal(err)
	}
	defer resp.Body.Close()
	_, _ = io.Copy(io.Discard, resp.Body)
	if resp.StatusCode != http.StatusAccepted {
		t.Fatalf("task status: %d", resp.StatusCode)
	}
}

func TestSIGTERMDrainsRequestAndAcceptedTasks(t *testing.T) {
	if testing.Short() {
		t.Skip("real process integration test")
	}
	p, url := startProcess(t, "3s")
	postTask(t, url+"/tasks?delay=350ms")
	p.waitEvent(t, "task started")
	postTask(t, url+"/tasks?delay=150ms") // queued, also must drain
	requestDone := make(chan error, 1)
	go func() {
		client := &http.Client{Timeout: 3 * time.Second}
		resp, err := client.Get(url + "/work?delay=150ms")
		if err == nil {
			_, err = io.Copy(io.Discard, resp.Body)
			_ = resp.Body.Close()
			if resp.StatusCode != http.StatusOK {
				err = fmt.Errorf("request status %d", resp.StatusCode)
			}
		}
		requestDone <- err
	}()
	p.waitEvent(t, "request started")
	if err := p.cmd.Process.Signal(syscall.SIGTERM); err != nil {
		t.Fatal(err)
	}
	p.waitEvent(t, "shutdown started")
	select {
	case err := <-requestDone:
		if err != nil {
			t.Fatalf("in-flight request lost: %v", err)
		}
	case <-time.After(4 * time.Second):
		t.Fatal("request did not finish")
	}
	event := p.waitEvent(t, "shutdown complete")
	stats := event["stats"].(map[string]any)
	if stats["accepted"] != float64(2) || stats["completed"] != float64(2) || stats["failed"] != float64(0) || stats["active"] != float64(0) {
		t.Fatalf("task boundary violated: %v", stats)
	}
	select {
	case err := <-p.done:
		if err != nil {
			t.Fatalf("process: %v\n%s", err, p.output())
		}
	case <-time.After(4 * time.Second):
		t.Fatal("process did not exit")
	}
	// Listener must be gone after shutdown, not merely return a health error.
	client := &http.Client{Timeout: 200 * time.Millisecond}
	if resp, err := client.Get(url + "/healthz"); err == nil {
		resp.Body.Close()
		t.Fatal("listener still accepts after exit")
	}
	logs := p.output()
	if strings.Index(logs, "request finished") > strings.Index(logs, "shutdown complete") {
		t.Fatal("shutdown returned before handler completion")
	}
}

func TestSIGTERMDeadlineCancelsBlockedTask(t *testing.T) {
	if testing.Short() {
		t.Skip("real process integration test")
	}
	p, url := startProcess(t, "80ms")
	postTask(t, url+"/tasks?delay=5s")
	p.waitEvent(t, "task started")
	requestDone := make(chan error, 1)
	go func() {
		client := &http.Client{Timeout: 3 * time.Second}
		resp, err := client.Get(url + "/work?delay=5s")
		if err == nil {
			_, err = io.Copy(io.Discard, resp.Body)
			_ = resp.Body.Close()
		}
		requestDone <- err
	}()
	p.waitEvent(t, "request started")
	if err := p.cmd.Process.Signal(syscall.SIGTERM); err != nil {
		t.Fatal(err)
	}
	event := p.waitEvent(t, "shutdown complete")
	stats := event["stats"].(map[string]any)
	if stats["cancelled"] != float64(1) || stats["completed"] != float64(1) || stats["active"] != float64(0) {
		t.Fatalf("cancelled task did not release: %v", stats)
	}
	select {
	case err := <-requestDone:
		if err == nil {
			t.Fatal("deadline-expired request unexpectedly succeeded")
		}
	case <-time.After(3 * time.Second):
		t.Fatal("closed request did not return")
	}
	logs := p.output()
	if !strings.Contains(logs, `"msg":"request finished","path":"/work","error":"context canceled"`) {
		t.Fatalf("request cleanup was not observed before shutdown completion:\n%s", logs)
	}
	select {
	case err := <-p.done:
		if err == nil {
			t.Fatal("deadline must produce nonzero exit status")
		}
	case <-time.After(4 * time.Second):
		t.Fatal("process did not exit after cancellation")
	}
}
~~~~~~

## lab/go.mod

~~~~~~text
module example.com/go-service-engineering/lab

go 1.24.3
~~~~~~

## lab/internal/service/app.go

~~~~~~go
package service

import (
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"log/slog"
	"net/http"
	"net/url"
	"strconv"
	"sync"
	"time"
)

type Config struct {
	Workers            int
	QueueCapacity      int
	TaskTimeout        time.Duration
	StreamWriteTimeout time.Duration
	Logger             *slog.Logger
}

func DefaultConfig() Config {
	return Config{Workers: 2, QueueCapacity: 8, TaskTimeout: 5 * time.Second, StreamWriteTimeout: 250 * time.Millisecond}
}

type App struct {
	Pool           *Pool
	Fetcher        *Fetcher
	cfg            Config
	log            *slog.Logger
	requestMu      sync.Mutex
	requestsClosed bool
	requests       sync.WaitGroup
}

func New(cfg Config) (*App, error) {
	if cfg.StreamWriteTimeout <= 0 {
		return nil, fmt.Errorf("stream write timeout must be positive")
	}
	if cfg.Logger == nil {
		cfg.Logger = slog.Default()
	}
	pool, err := NewPool(cfg.Workers, cfg.QueueCapacity, cfg.TaskTimeout, cfg.Logger)
	if err != nil {
		return nil, err
	}
	return &App{Pool: pool, Fetcher: NewFetcher(2), cfg: cfg, log: cfg.Logger}, nil
}

func (a *App) Handler() http.Handler {
	mux := http.NewServeMux()
	mux.HandleFunc("POST /tasks", a.tasks)
	mux.HandleFunc("GET /work", a.work)
	mux.HandleFunc("GET /stream", a.stream)
	mux.HandleFunc("GET /stats", func(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, a.Pool.Stats()) })
	mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) })
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		a.requestMu.Lock()
		if a.requestsClosed {
			a.requestMu.Unlock()
			http.Error(w, "requests draining", http.StatusServiceUnavailable)
			return
		}
		a.requests.Add(1)
		a.requestMu.Unlock()
		defer a.requests.Done()
		mux.ServeHTTP(w, r)
	})
}

// DrainRequests gates new handlers and waits for existing handlers to return.
// Call it after stopping the listener. The gate prevents Add racing with Wait
// when the previous request count is zero. It does not itself cancel requests.
func (a *App) DrainRequests(ctx context.Context) error {
	a.requestMu.Lock()
	a.requestsClosed = true
	a.requestMu.Unlock()
	done := make(chan struct{})
	go func() { a.requests.Wait(); close(done) }()
	select {
	case <-done:
		return nil
	case <-ctx.Done():
		return ctx.Err()
	}
}

func duration(r *http.Request) (time.Duration, error) {
	s := r.URL.Query().Get("delay")
	if s == "" {
		return 100 * time.Millisecond, nil
	}
	d, err := time.ParseDuration(s)
	if err != nil || d < 0 || d > 30*time.Second {
		return 0, errors.New("delay must be between 0 and 30s")
	}
	return d, nil
}

func wait(ctx context.Context, d time.Duration) error {
	timer := time.NewTimer(d)
	defer timer.Stop()
	select {
	case <-timer.C:
		return nil
	case <-ctx.Done():
		return ctx.Err()
	}
}

func (a *App) tasks(w http.ResponseWriter, r *http.Request) {
	d, err := duration(r)
	if err != nil {
		http.Error(w, err.Error(), http.StatusBadRequest)
		return
	}
	target := r.URL.Query().Get("url")
	if target != "" {
		u, parseErr := url.Parse(target)
		if parseErr != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
			http.Error(w, "url must be an absolute http(s) URL", http.StatusBadRequest)
			return
		}
	}
	// Capture values only. Do not retain r, ResponseWriter, or r.Context().
	id, err := a.Pool.Submit(r.Context(), func(ctx context.Context) error {
		if target != "" {
			_, err := a.Fetcher.Fetch(ctx, target)
			return err
		}
		return wait(ctx, d)
	})
	if err != nil {
		status := http.StatusServiceUnavailable
		if errors.Is(err, context.Canceled) {
			status = http.StatusRequestTimeout
		}
		w.Header().Set("Retry-After", "1")
		http.Error(w, err.Error(), status)
		return
	}
	writeJSON(w, http.StatusAccepted, map[string]uint64{"id": id})
}

func (a *App) work(w http.ResponseWriter, r *http.Request) {
	d, err := duration(r)
	if err != nil {
		http.Error(w, err.Error(), http.StatusBadRequest)
		return
	}
	a.log.Info("request started", "path", "/work")
	err = wait(r.Context(), d)
	a.log.Info("request finished", "path", "/work", "error", err)
	if err != nil {
		return
	}
	writeJSON(w, http.StatusOK, map[string]string{"status": "done"})
}

func (a *App) stream(w http.ResponseWriter, r *http.Request) {
	n := int64(64 << 20)
	if raw := r.URL.Query().Get("bytes"); raw != "" {
		value, err := strconv.ParseInt(raw, 10, 64)
		if err != nil || value < 1 || value > 256<<20 {
			http.Error(w, "bytes must be 1..268435456", http.StatusBadRequest)
			return
		}
		n = value
	}
	controller := http.NewResponseController(w)
	// One absolute deadline bounds the entire stream, including blocked writes.
	if err := controller.SetWriteDeadline(time.Now().Add(a.cfg.StreamWriteTimeout)); err != nil {
		http.Error(w, "write deadlines unavailable", http.StatusInternalServerError)
		return
	}
	// Keep the deadline active through net/http's final buffered flush. This
	// HTTP/1.1 demo closes streaming connections rather than reusing one with a
	// stale deadline. Other endpoints retain ordinary keep-alive behavior.
	w.Header().Set("Connection", "close")
	w.Header().Set("Content-Type", "application/octet-stream")
	chunk := make([]byte, 32<<10)
	var sent int64
	for sent < n {
		if err := r.Context().Err(); err != nil {
			a.log.Info("stream finished", "bytes", sent, "error", err)
			return
		}
		size := min(int64(len(chunk)), n-sent)
		written, err := w.Write(chunk[:size])
		sent += int64(written)
		if err != nil {
			a.log.Info("stream finished", "bytes", sent, "error", err)
			return
		}
	}
	a.log.Info("stream finished", "bytes", sent)
}

func writeJSON(w http.ResponseWriter, status int, value any) {
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(status)
	_ = json.NewEncoder(w).Encode(value)
}
~~~~~~

## lab/internal/service/app_test.go

~~~~~~go
package service

import (
	"context"
	"errors"
	"fmt"
	"io"
	"log/slog"
	"net"
	"net/http"
	"net/http/httptest"
	"strings"
	"sync"
	"testing"
	"time"
)

func app(t *testing.T, cfg Config) *App {
	t.Helper()
	a, err := New(cfg)
	if err != nil {
		t.Fatal(err)
	}
	t.Cleanup(func() {
		ctx, cancel := context.WithTimeout(context.Background(), time.Second)
		defer cancel()
		_ = a.Pool.Shutdown(ctx)
		receive(t, a.Pool.Done())
		a.Fetcher.Close()
	})
	return a
}

func eventually(t *testing.T, condition func() bool) {
	t.Helper()
	deadline := time.NewTimer(3 * time.Second)
	defer deadline.Stop()
	tick := time.NewTicker(time.Millisecond)
	defer tick.Stop()
	for !condition() {
		select {
		case <-tick.C:
		case <-deadline.C:
			t.Fatal("condition did not become true")
		}
	}
}

func TestHTTPOverloadAndTaskOwnership(t *testing.T) {
	cfg := DefaultConfig()
	cfg.Workers, cfg.QueueCapacity, cfg.Logger = 1, 1, quiet()
	a := app(t, cfg)
	srv := httptest.NewServer(a.Handler())
	defer srv.Close()
	ctx, cancel := context.WithCancel(context.Background())
	req, _ := http.NewRequestWithContext(ctx, http.MethodPost, srv.URL+"/tasks?delay=150ms", nil)
	resp, err := srv.Client().Do(req)
	if err != nil {
		t.Fatal(err)
	}
	_, _ = io.Copy(io.Discard, resp.Body)
	_ = resp.Body.Close()
	if resp.StatusCode != http.StatusAccepted {
		t.Fatalf("first task: %d", resp.StatusCode)
	}
	cancel() // Accepted task is owned by the pool, not this now-ended request.
	eventually(t, func() bool { return a.Pool.Stats().Active == 1 })
	for _, want := range []int{http.StatusAccepted, http.StatusServiceUnavailable} {
		resp, err := srv.Client().Post(srv.URL+"/tasks?delay=150ms", "", nil)
		if err != nil {
			t.Fatal(err)
		}
		_, _ = io.Copy(io.Discard, resp.Body)
		_ = resp.Body.Close()
		if resp.StatusCode != want {
			t.Fatalf("got %d, want %d", resp.StatusCode, want)
		}
		if want == http.StatusServiceUnavailable && resp.Header.Get("Retry-After") != "1" {
			t.Fatal("missing retry hint")
		}
	}
	a.Pool.Close()
	receive(t, a.Pool.Done())
	if s := a.Pool.Stats(); s.Completed != 2 || s.Failed != 0 {
		t.Fatalf("request cancellation leaked into task: %+v", s)
	}
}

type lockedBuffer struct {
	mu   sync.Mutex
	text strings.Builder
}

func (b *lockedBuffer) Write(p []byte) (int, error) {
	b.mu.Lock()
	defer b.mu.Unlock()
	return b.text.Write(p)
}
func (b *lockedBuffer) String() string { b.mu.Lock(); defer b.mu.Unlock(); return b.text.String() }

func TestSlowStreamDeadlineReleasesHandler(t *testing.T) {
	var logs lockedBuffer
	cfg := DefaultConfig()
	cfg.StreamWriteTimeout = 80 * time.Millisecond
	cfg.Logger = slog.New(slog.NewTextHandler(&logs, nil))
	a := app(t, cfg)
	finished := make(chan struct{})
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		a.Handler().ServeHTTP(w, r)
		close(finished)
	}))
	defer srv.Close()
	conn, err := net.DialTimeout("tcp", srv.Listener.Addr().String(), time.Second)
	if err != nil {
		t.Fatal(err)
	}
	defer conn.Close()
	if tcp, ok := conn.(*net.TCPConn); ok {
		_ = tcp.SetReadBuffer(1024)
	}
	// A real peer sends its request and never reads the 256 MiB response.
	if _, err := fmt.Fprint(conn, "GET /stream?bytes=268435456 HTTP/1.1\r\nHost: localhost\r\n\r\n"); err != nil {
		t.Fatal(err)
	}
	receive(t, finished)
	if text := logs.String(); !strings.Contains(text, "stream finished") || !strings.Contains(text, "i/o timeout") {
		t.Fatalf("stream did not hit its write deadline: %s", text)
	}
}

func TestHTTPInvalidInput(t *testing.T) {
	cfg := DefaultConfig()
	cfg.Logger = quiet()
	a := app(t, cfg)
	for _, path := range []string{"/tasks?delay=-1s", "/tasks?url=file:///etc/passwd", "/stream?bytes=999999999"} {
		method := http.MethodPost
		if strings.HasPrefix(path, "/stream") {
			method = http.MethodGet
		}
		rr := httptest.NewRecorder()
		a.Handler().ServeHTTP(rr, httptest.NewRequest(method, path, nil))
		if rr.Code != http.StatusBadRequest {
			t.Errorf("%s: %d", path, rr.Code)
		}
	}
}

func TestDrainRequestsWaitsForHandlerAndGatesAdmission(t *testing.T) {
	var logs lockedBuffer
	cfg := DefaultConfig()
	cfg.Logger = slog.New(slog.NewTextHandler(&logs, nil))
	a := app(t, cfg)
	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()
	finished := make(chan struct{})
	go func() {
		a.Handler().ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/work?delay=5s", nil).WithContext(ctx))
		close(finished)
	}()
	eventually(t, func() bool { return strings.Contains(logs.String(), "request started") })
	short, stop := context.WithTimeout(context.Background(), 20*time.Millisecond)
	defer stop()
	if err := a.DrainRequests(short); !errors.Is(err, context.DeadlineExceeded) {
		t.Fatalf("drain returned while handler still owned resources: %v", err)
	}
	cancel()
	receive(t, finished)
	long, end := context.WithTimeout(context.Background(), time.Second)
	defer end()
	if err := a.DrainRequests(long); err != nil {
		t.Fatal(err)
	}
	rr := httptest.NewRecorder()
	a.Handler().ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/healthz", nil))
	if rr.Code != http.StatusServiceUnavailable {
		t.Fatalf("new handler admitted during drain: %d", rr.Code)
	}
}
~~~~~~

## lab/internal/service/client.go

~~~~~~go
package service

import (
	"context"
	"fmt"
	"io"
	"net"
	"net/http"
	"time"
)

// Fetcher reuses one transport. Every response body has exactly one owner,
// including error-status bodies; reading EOF permits HTTP/1.1 connection reuse.
type Fetcher struct {
	Client    *http.Client
	Transport *http.Transport
}

func NewFetcher(maxConns int) *Fetcher {
	tr := &http.Transport{
		Proxy:                 http.ProxyFromEnvironment,
		DialContext:           (&net.Dialer{Timeout: 2 * time.Second, KeepAlive: 30 * time.Second}).DialContext,
		MaxConnsPerHost:       maxConns,
		MaxIdleConns:          32,
		MaxIdleConnsPerHost:   maxConns,
		IdleConnTimeout:       30 * time.Second,
		TLSHandshakeTimeout:   2 * time.Second,
		ResponseHeaderTimeout: 2 * time.Second,
		ForceAttemptHTTP2:     true,
	}
	return &Fetcher{Client: &http.Client{Transport: tr}, Transport: tr}
}

// Fetch limits response bytes. An oversized response is closed before EOF,
// intentionally sacrificing reuse rather than consuming unbounded bandwidth.
func (f *Fetcher) Fetch(ctx context.Context, url string) (int64, error) {
	req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
	if err != nil {
		return 0, err
	}
	resp, err := f.Client.Do(req)
	if err != nil {
		return 0, err
	}
	defer resp.Body.Close()
	const maxBody = 1 << 20
	n, err := io.Copy(io.Discard, io.LimitReader(resp.Body, maxBody+1))
	if err != nil {
		return n, err
	}
	if n > maxBody {
		return n, fmt.Errorf("response exceeds %d bytes", maxBody)
	}
	if resp.StatusCode < 200 || resp.StatusCode > 299 {
		return n, fmt.Errorf("upstream status %d", resp.StatusCode)
	}
	return n, nil
}

func (f *Fetcher) Close() { f.Transport.CloseIdleConnections() }
~~~~~~

## lab/internal/service/client_test.go

~~~~~~go
package service

import (
	"context"
	"errors"
	"io"
	"net"
	"net/http"
	"net/http/httptest"
	"sync/atomic"
	"testing"
	"time"
)

func TestConnectionExhaustionAndRecovery(t *testing.T) {
	released := make(chan struct{})
	var okRequests atomic.Int64
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if r.URL.Path == "/hold" {
			w.WriteHeader(http.StatusOK)
			w.(http.Flusher).Flush()
			<-r.Context().Done()
			close(released)
			return
		}
		okRequests.Add(1)
		_, _ = io.WriteString(w, "recovered")
	}))
	defer srv.Close()
	f := NewFetcher(1)
	defer f.Close()
	resp, err := f.Client.Get(srv.URL + "/hold")
	if err != nil {
		t.Fatal(err)
	}
	defer resp.Body.Close()
	ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
	defer cancel()
	if _, err := f.Fetch(ctx, srv.URL+"/ok"); !errors.Is(err, context.DeadlineExceeded) {
		t.Fatalf("waiting for occupied connection: %v", err)
	}
	if got := okRequests.Load(); got != 0 {
		t.Fatalf("request bypassed connection bound: %d", got)
	}
	_ = resp.Body.Close()
	receive(t, released)
	recovery, stop := context.WithTimeout(context.Background(), time.Second)
	defer stop()
	if n, err := f.Fetch(recovery, srv.URL+"/ok"); err != nil || n != 9 {
		t.Fatalf("recovery: bytes=%d error=%v", n, err)
	}
}

func TestFetcherReusesConnectionIncludingErrorStatus(t *testing.T) {
	var connections atomic.Int64
	srv := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if r.URL.Path == "/error" {
			w.WriteHeader(http.StatusBadGateway)
		}
		_, _ = io.WriteString(w, "small body")
	}))
	srv.Config.ConnState = func(_ net.Conn, state http.ConnState) {
		if state == http.StateNew {
			connections.Add(1)
		}
	}
	srv.Start()
	defer srv.Close()
	f := NewFetcher(1)
	defer f.Close()
	ctx, cancel := context.WithTimeout(context.Background(), time.Second)
	defer cancel()
	if _, err := f.Fetch(ctx, srv.URL+"/error"); err == nil {
		t.Fatal("expected status error")
	}
	for range 3 {
		if _, err := f.Fetch(ctx, srv.URL); err != nil {
			t.Fatal(err)
		}
	}
	if n := connections.Load(); n != 1 {
		t.Fatalf("body was not drained/reused: connections=%d", n)
	}
}

func TestFetcherBlockedBodyCancellationReleasesConnection(t *testing.T) {
	released := make(chan struct{})
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if r.URL.Path == "/blocked" {
			w.WriteHeader(http.StatusOK)
			w.(http.Flusher).Flush()
			<-r.Context().Done()
			close(released)
			return
		}
		_, _ = io.WriteString(w, "ok")
	}))
	defer srv.Close()
	f := NewFetcher(1)
	defer f.Close()
	ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
	defer cancel()
	if _, err := f.Fetch(ctx, srv.URL+"/blocked"); !errors.Is(err, context.DeadlineExceeded) {
		t.Fatalf("blocked body: %v", err)
	}
	receive(t, released)
	recovery, stop := context.WithTimeout(context.Background(), time.Second)
	defer stop()
	if _, err := f.Fetch(recovery, srv.URL); err != nil {
		t.Fatalf("connection slot not recovered: %v", err)
	}
}
~~~~~~

## lab/internal/service/pool.go

~~~~~~go
// Package service demonstrates bounded concurrency and explicit task ownership.
package service

import (
	"context"
	"errors"
	"fmt"
	"log/slog"
	"sync"
	"sync/atomic"
	"time"
)

var (
	ErrFull   = errors.New("task queue full")
	ErrClosed = errors.New("task pool closed")
)

type Work func(context.Context) error

type task struct {
	id   uint64
	work Work
}

type Stats struct {
	Accepted  uint64 `json:"accepted"`
	Started   uint64 `json:"started"`
	Completed uint64 `json:"completed"`
	Failed    uint64 `json:"failed"`
	Cancelled uint64 `json:"cancelled"`
	Active    int64  `json:"active"`
	Queued    int    `json:"queued"`
}

// Pool owns every accepted task. A caller's context controls admission only;
// after admission, only the pool lifetime and per-task deadline cancel the task.
type Pool struct {
	mu        sync.Mutex
	closed    bool
	queue     chan task
	ctx       context.Context
	cancel    context.CancelFunc
	done      chan struct{}
	timeout   time.Duration
	log       *slog.Logger
	next      atomic.Uint64
	accepted  atomic.Uint64
	started   atomic.Uint64
	completed atomic.Uint64
	failed    atomic.Uint64
	cancelled atomic.Uint64
	active    atomic.Int64
}

func NewPool(workers, capacity int, timeout time.Duration, logger *slog.Logger) (*Pool, error) {
	if workers < 1 || capacity < 1 || timeout <= 0 {
		return nil, fmt.Errorf("workers, capacity, and timeout must be positive")
	}
	if logger == nil {
		logger = slog.Default()
	}
	ctx, cancel := context.WithCancel(context.Background())
	p := &Pool{queue: make(chan task, capacity), ctx: ctx, cancel: cancel, done: make(chan struct{}), timeout: timeout, log: logger}
	var wg sync.WaitGroup
	wg.Add(workers)
	for range workers {
		go func() {
			defer wg.Done()
			for t := range p.queue {
				p.run(t)
			}
		}()
	}
	go func() { wg.Wait(); cancel(); close(p.done) }()
	return p, nil
}

func (p *Pool) run(t task) {
	ctx, cancel := context.WithTimeout(p.ctx, p.timeout)
	defer cancel()
	p.active.Add(1)
	p.started.Add(1)
	p.log.Info("task started", "id", t.id)
	var err error
	if err = ctx.Err(); err == nil {
		err = t.work(ctx)
	}
	if err != nil {
		p.failed.Add(1)
		if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
			p.cancelled.Add(1)
		}
	}
	p.completed.Add(1)
	p.active.Add(-1)
	p.log.Info("task finished", "id", t.id, "error", err)
}

// Submit never waits for queue space. The queue bound excludes running tasks.
func (p *Pool) Submit(ctx context.Context, work Work) (uint64, error) {
	if work == nil {
		return 0, errors.New("nil work")
	}
	p.mu.Lock()
	defer p.mu.Unlock()
	if err := ctx.Err(); err != nil {
		return 0, err
	}
	if p.closed {
		return 0, ErrClosed
	}
	id := p.next.Add(1)
	select {
	case p.queue <- task{id: id, work: work}:
		p.accepted.Add(1)
		p.log.Info("task accepted", "id", id)
		return id, nil
	default:
		return 0, ErrFull
	}
}

// Close stops admission and lets workers drain the queue. It is idempotent.
func (p *Pool) Close() {
	p.mu.Lock()
	defer p.mu.Unlock()
	if !p.closed {
		p.closed = true
		close(p.queue)
	}
}

// Shutdown drains until the deadline, then signals cancellation. It cannot
// force a Work function that ignores context to stop. Done reports actual exit.
func (p *Pool) Shutdown(ctx context.Context) error {
	p.Close()
	select {
	case <-p.done:
		return nil
	case <-ctx.Done():
		p.cancel()
		return ctx.Err()
	}
}

func (p *Pool) Done() <-chan struct{} { return p.done }

func (p *Pool) Stats() Stats {
	return Stats{Accepted: p.accepted.Load(), Started: p.started.Load(), Completed: p.completed.Load(), Failed: p.failed.Load(), Cancelled: p.cancelled.Load(), Active: p.active.Load(), Queued: len(p.queue)}
}
~~~~~~

## lab/internal/service/pool_test.go

~~~~~~go
package service

import (
	"context"
	"errors"
	"io"
	"log/slog"
	"runtime"
	"sync"
	"testing"
	"time"
)

func quiet() *slog.Logger { return slog.New(slog.NewTextHandler(io.Discard, nil)) }

func receive(t *testing.T, ch <-chan struct{}) {
	t.Helper()
	select {
	case <-ch:
	case <-time.After(3 * time.Second):
		t.Fatal("timed out waiting for completion")
	}
}

func pool(t *testing.T, workers, capacity int, timeout time.Duration) *Pool {
	t.Helper()
	p, err := NewPool(workers, capacity, timeout, quiet())
	if err != nil {
		t.Fatal(err)
	}
	t.Cleanup(func() {
		ctx, cancel := context.WithTimeout(context.Background(), time.Second)
		defer cancel()
		_ = p.Shutdown(ctx)
		receive(t, p.Done())
	})
	return p
}

func TestPoolQueueBound(t *testing.T) {
	p := pool(t, 1, 2, time.Second)
	started, release := make(chan struct{}), make(chan struct{})
	_, err := p.Submit(context.Background(), func(ctx context.Context) error {
		close(started)
		select {
		case <-release:
			return nil
		case <-ctx.Done():
			return ctx.Err()
		}
	})
	if err != nil {
		t.Fatal(err)
	}
	receive(t, started)
	for range 2 {
		if _, err := p.Submit(context.Background(), func(context.Context) error { return nil }); err != nil {
			t.Fatal(err)
		}
	}
	if _, err := p.Submit(context.Background(), func(context.Context) error { return nil }); !errors.Is(err, ErrFull) {
		t.Fatalf("got %v, want ErrFull", err)
	}
	if s := p.Stats(); s.Queued != 2 || s.Active != 1 || s.Accepted != 3 {
		t.Fatalf("unexpected stats: %+v", s)
	}
	close(release)
	p.Close()
	receive(t, p.Done())
	if s := p.Stats(); s.Completed != 3 || s.Active != 0 || s.Queued != 0 {
		t.Fatalf("not drained: %+v", s)
	}
}

func TestAcceptedTaskOutlivesCaller(t *testing.T) {
	p := pool(t, 1, 1, time.Second)
	caller, cancel := context.WithCancel(context.Background())
	started, release, finished := make(chan struct{}), make(chan struct{}), make(chan struct{})
	_, err := p.Submit(caller, func(ctx context.Context) error {
		close(started)
		select {
		case <-release:
			close(finished)
			return nil
		case <-ctx.Done():
			return ctx.Err()
		}
	})
	if err != nil {
		t.Fatal(err)
	}
	receive(t, started)
	cancel()
	close(release)
	receive(t, finished)
	if _, err := p.Submit(caller, func(context.Context) error { return nil }); !errors.Is(err, context.Canceled) {
		t.Fatalf("cancelled admission: %v", err)
	}
}

func TestPoolBlockedCancellationReleases(t *testing.T) {
	p := pool(t, 1, 2, 5*time.Second)
	started, released := make(chan struct{}), make(chan struct{})
	_, err := p.Submit(context.Background(), func(ctx context.Context) error { close(started); <-ctx.Done(); close(released); return ctx.Err() })
	if err != nil {
		t.Fatal(err)
	}
	receive(t, started)
	// A queued function must never execute after forced cancellation.
	if _, err := p.Submit(context.Background(), func(context.Context) error { t.Error("cancelled queued work executed"); return nil }); err != nil {
		t.Fatal(err)
	}
	ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond)
	defer cancel()
	if err := p.Shutdown(ctx); !errors.Is(err, context.DeadlineExceeded) {
		t.Fatalf("shutdown: %v", err)
	}
	receive(t, released)
	receive(t, p.Done())
	if s := p.Stats(); s.Completed != 2 || s.Cancelled != 2 || s.Active != 0 {
		t.Fatalf("resources not released: %+v", s)
	}
}

func TestPoolTaskDeadline(t *testing.T) {
	p := pool(t, 1, 1, 20*time.Millisecond)
	_, err := p.Submit(context.Background(), func(ctx context.Context) error { <-ctx.Done(); return ctx.Err() })
	if err != nil {
		t.Fatal(err)
	}
	p.Close()
	receive(t, p.Done())
	if s := p.Stats(); s.Cancelled != 1 {
		t.Fatalf("deadline not observed: %+v", s)
	}
}

func TestPoolConcurrentCloseAndSubmit(t *testing.T) {
	p := pool(t, 4, 8, time.Second)
	var wg sync.WaitGroup
	for range 16 {
		wg.Add(1)
		go func() {
			defer wg.Done()
			for range 100 {
				_, err := p.Submit(context.Background(), func(context.Context) error { return nil })
				if err != nil && !errors.Is(err, ErrFull) && !errors.Is(err, ErrClosed) {
					t.Errorf("submit: %v", err)
				}
			}
		}()
	}
	p.Close()
	wg.Wait()
	receive(t, p.Done())
	if s := p.Stats(); s.Completed != s.Accepted {
		t.Fatalf("accepted tasks lost: %+v", s)
	}
}

func BenchmarkPoolSubmit(b *testing.B) {
	p, _ := NewPool(4, 128, time.Second, quiet())
	defer func() { p.Close(); <-p.Done() }()
	b.ReportAllocs()
	for b.Loop() {
		for {
			_, err := p.Submit(context.Background(), func(context.Context) error { return nil })
			if err == nil {
				break
			}
			if !errors.Is(err, ErrFull) {
				b.Fatal(err)
			}
			runtime.Gosched()
		}
	}
}
~~~~~~

## lab/profiles/live.goroutine.txt

~~~~~~text
goroutine 49 [running]:
runtime/pprof.writeGoroutineStacks({0x104a93460, 0x140000e6380})
	/Users/aias/.local/share/go1.24.3/src/runtime/pprof/pprof.go:764 +0x6c
runtime/pprof.writeGoroutine({0x104a93460?, 0x140000e6380?}, 0x140000fa848?)
	/Users/aias/.local/share/go1.24.3/src/runtime/pprof/pprof.go:753 +0x2c
runtime/pprof.(*Profile).WriteTo(0x104cbf2f0?, {0x104a93460?, 0x140000e6380?}, 0xc?)
	/Users/aias/.local/share/go1.24.3/src/runtime/pprof/pprof.go:377 +0x14c
net/http/pprof.handler.ServeHTTP({0x14000202011, 0x9}, {0x104a95a68, 0x140000e6380}, 0x140000d8640)
	/Users/aias/.local/share/go1.24.3/src/net/http/pprof/pprof.go:272 +0x444
net/http/pprof.Index({0x104a95a68, 0x140000e6380}, 0x140000d8640?)
	/Users/aias/.local/share/go1.24.3/src/net/http/pprof/pprof.go:389 +0xc8
net/http.HandlerFunc.ServeHTTP(0x104ccbdc0?, {0x104a95a68?, 0x140000e6380?}, 0x1048be9b4?)
	/Users/aias/.local/share/go1.24.3/src/net/http/server.go:2294 +0x38
net/http.(*ServeMux).ServeHTTP(0x0?, {0x104a95a68, 0x140000e6380}, 0x140000d8640)
	/Users/aias/.local/share/go1.24.3/src/net/http/server.go:2822 +0x1b4
net/http.serverHandler.ServeHTTP({0x140000acf90?}, {0x104a95a68?, 0x140000e6380?}, 0x1?)
	/Users/aias/.local/share/go1.24.3/src/net/http/server.go:3301 +0xbc
net/http.(*conn).serve(0x140000aa750, {0x104a95d50, 0x14000101110})
	/Users/aias/.local/share/go1.24.3/src/net/http/server.go:2102 +0x52c
created by net/http.(*Server).Serve in goroutine 23
	/Users/aias/.local/share/go1.24.3/src/net/http/server.go:3454 +0x3d8

goroutine 1 [select]:
main.run({0x104a95ff0, 0x140001063c0}, 0x140000102f0)
	/Volumes/data/go-service-engineering-book/lab/cmd/service/main.go:85 +0x870
main.main()
	/Volumes/data/go-service-engineering-book/lab/cmd/service/main.go:31 +0x1e4

goroutine 17 [syscall]:
os/signal.signal_recv()
	/Users/aias/.local/share/go1.24.3/src/runtime/sigqueue.go:149 +0x2c
os/signal.loop()
	/Users/aias/.local/share/go1.24.3/src/os/signal/signal_unix.go:23 +0x1c
created by os/signal.Notify.func1.1 in goroutine 1
	/Users/aias/.local/share/go1.24.3/src/os/signal/signal.go:152 +0x28

goroutine 18 [select]:
os/signal.NotifyContext.func1()
	/Users/aias/.local/share/go1.24.3/src/os/signal/signal.go:289 +0x68
created by os/signal.NotifyContext in goroutine 1
	/Users/aias/.local/share/go1.24.3/src/os/signal/signal.go:288 +0x148

goroutine 19 [select]:
example.com/go-service-engineering/lab/internal/service.wait({0x104a95df8, 0x14000188070}, 0x1?)
	/Volumes/data/go-service-engineering-book/lab/internal/service/app.go:105 +0xa0
example.com/go-service-engineering/lab/internal/service.(*App).tasks.func1({0x104a95df8?, 0x14000188070?})
	/Volumes/data/go-service-engineering-book/lab/internal/service/app.go:133 +0x60
example.com/go-service-engineering/lab/internal/service.(*Pool).run(0x1400009a100, {0x0?, 0x140000ac510?})
	/Volumes/data/go-service-engineering-book/lab/internal/service/pool.go:87 +0x174
example.com/go-service-engineering/lab/internal/service.NewPool.func1()
	/Volumes/data/go-service-engineering-book/lab/internal/service/pool.go:71 +0x70
created by example.com/go-service-engineering/lab/internal/service.NewPool in goroutine 1
	/Volumes/data/go-service-engineering-book/lab/internal/service/pool.go:68 +0x184

goroutine 20 [select]:
example.com/go-service-engineering/lab/internal/service.wait({0x104a95df8, 0x1400009e150}, 0x1?)
	/Volumes/data/go-service-engineering-book/lab/internal/service/app.go:105 +0xa0
example.com/go-service-engineering/lab/internal/service.(*App).tasks.func1({0x104a95df8?, 0x1400009e150?})
	/Volumes/data/go-service-engineering-book/lab/internal/service/app.go:133 +0x60
example.com/go-service-engineering/lab/internal/service.(*Pool).run(0x1400009a100, {0x0?, 0x14000184300?})
	/Volumes/data/go-service-engineering-book/lab/internal/service/pool.go:87 +0x174
example.com/go-service-engineering/lab/internal/service.NewPool.func1()
	/Volumes/data/go-service-engineering-book/lab/internal/service/pool.go:71 +0x70
created by example.com/go-service-engineering/lab/internal/service.NewPool in goroutine 1
	/Volumes/data/go-service-engineering-book/lab/internal/service/pool.go:68 +0x184

goroutine 21 [sync.WaitGroup.Wait]:
sync.runtime_SemacquireWaitGroup(0x0?)
	/Users/aias/.local/share/go1.24.3/src/runtime/sema.go:110 +0x2c
sync.(*WaitGroup).Wait(0x140000a2000)
	/Users/aias/.local/share/go1.24.3/src/sync/waitgroup.go:118 +0x70
example.com/go-service-engineering/lab/internal/service.NewPool.func2()
	/Volumes/data/go-service-engineering-book/lab/internal/service/pool.go:75 +0x30
created by example.com/go-service-engineering/lab/internal/service.NewPool in goroutine 1
	/Volumes/data/go-service-engineering-book/lab/internal/service/pool.go:75 +0x240

goroutine 22 [IO wait]:
internal/poll.runtime_pollWait(0x105124ef8, 0x72)
	/Users/aias/.local/share/go1.24.3/src/runtime/netpoll.go:351 +0xa0
internal/poll.(*pollDesc).wait(0x1400009a180?, 0x104762be8?, 0x0)
	/Users/aias/.local/share/go1.24.3/src/internal/poll/fd_poll_runtime.go:84 +0x28
internal/poll.(*pollDesc).waitRead(...)
	/Users/aias/.local/share/go1.24.3/src/internal/poll/fd_poll_runtime.go:89
internal/poll.(*FD).Accept(0x1400009a180)
	/Users/aias/.local/share/go1.24.3/src/internal/poll/fd_unix.go:620 +0x24c
net.(*netFD).accept(0x1400009a180)
	/Users/aias/.local/share/go1.24.3/src/net/fd_unix.go:172 +0x28
net.(*TCPListener).accept(0x140000bc000)
	/Users/aias/.local/share/go1.24.3/src/net/tcpsock_posix.go:159 +0x24
net.(*TCPListener).Accept(0x140000bc000)
	/Users/aias/.local/share/go1.24.3/src/net/tcpsock.go:380 +0x2c
net/http.(*Server).Serve(0x140000c8000, {0x104a958e8, 0x140000bc000})
	/Users/aias/.local/share/go1.24.3/src/net/http/server.go:3424 +0x290
main.run.func2()
	/Volumes/data/go-service-engineering-book/lab/cmd/service/main.go:82 +0x30
created by main.run in goroutine 1
	/Volumes/data/go-service-engineering-book/lab/cmd/service/main.go:82 +0x6b4

goroutine 23 [IO wait]:
internal/poll.runtime_pollWait(0x105124de0, 0x72)
	/Users/aias/.local/share/go1.24.3/src/runtime/netpoll.go:351 +0xa0
internal/poll.(*pollDesc).wait(0x1400009a200?, 0x104762be8?, 0x0)
	/Users/aias/.local/share/go1.24.3/src/internal/poll/fd_poll_runtime.go:84 +0x28
internal/poll.(*pollDesc).waitRead(...)
	/Users/aias/.local/share/go1.24.3/src/internal/poll/fd_poll_runtime.go:89
internal/poll.(*FD).Accept(0x1400009a200)
	/Users/aias/.local/share/go1.24.3/src/internal/poll/fd_unix.go:620 +0x24c
net.(*netFD).accept(0x1400009a200)
	/Users/aias/.local/share/go1.24.3/src/net/fd_unix.go:172 +0x28
net.(*TCPListener).accept(0x140000bc040)
	/Users/aias/.local/share/go1.24.3/src/net/tcpsock_posix.go:159 +0x24
net.(*TCPListener).Accept(0x140000bc040)
	/Users/aias/.local/share/go1.24.3/src/net/tcpsock.go:380 +0x2c
net/http.(*Server).Serve(0x140000c8100, {0x104a958e8, 0x140000bc040})
	/Users/aias/.local/share/go1.24.3/src/net/http/server.go:3424 +0x290
main.run.func3()
	/Volumes/data/go-service-engineering-book/lab/cmd/service/main.go:83 +0x30
created by main.run in goroutine 1
	/Volumes/data/go-service-engineering-book/lab/cmd/service/main.go:83 +0x71c

goroutine 50 [IO wait]:
internal/poll.runtime_pollWait(0x105124cc8, 0x72)
	/Users/aias/.local/share/go1.24.3/src/runtime/netpoll.go:351 +0xa0
internal/poll.(*pollDesc).wait(0x1400009a580?, 0x140000acfa1?, 0x0)
	/Users/aias/.local/share/go1.24.3/src/internal/poll/fd_poll_runtime.go:84 +0x28
internal/poll.(*pollDesc).waitRead(...)
	/Users/aias/.local/share/go1.24.3/src/internal/poll/fd_poll_runtime.go:89
internal/poll.(*FD).Read(0x1400009a580, {0x140000acfa1, 0x1, 0x1})
	/Users/aias/.local/share/go1.24.3/src/internal/poll/fd_unix.go:165 +0x1fc
net.(*netFD).Read(0x1400009a580, {0x140000acfa1?, 0x1400004bf58?, 0x1048bec74?})
	/Users/aias/.local/share/go1.24.3/src/net/fd_posix.go:55 +0x28
net.(*conn).Read(0x140000c6070, {0x140000acfa1?, 0x0?, 0x0?})
	/Users/aias/.local/share/go1.24.3/src/net/net.go:194 +0x34
net/http.(*connReader).backgroundRead(0x140000acf90)
	/Users/aias/.local/share/go1.24.3/src/net/http/server.go:690 +0x40
created by net/http.(*connReader).startBackgroundRead in goroutine 49
	/Users/aias/.local/share/go1.24.3/src/net/http/server.go:686 +0xc4
~~~~~~

