05.03 channel 与取消:让 IM 发送队列能等待也能停止
返回第五卷 · 任务前置:05.01 · 同步前置:05.02
05.03 channel 与取消:让 IM 发送队列能等待也能停止
DeepTutor 初稿经技术与教学审阅后的静态课程。队列、Go 代码、取消时间线与输出均为有条件的纸上推演;本次没有运行 Go、并发测试、网络服务或站点构建。
一个慢接收方让任务停在哪里
05.01 已让每个 goroutine 有启动者、等待者和资源所有者;05.02 用锁保护单会话内存状态。现在考虑未来教学 IM 的另一条问题:u-a 连续产生待发送的 m-a、m-b、m-c,u-b 的处理端很慢。若应用为每个待发送项无限新建任务或无限堆积内存,CPU 可能不高,未完成任务和缓冲却持续增加。队列满时生产者怎么办?客户端断开时怎样让等待中的任务停止?谁有权宣布“不再发送”?
本章用容量为 2 的虚构内存队列回答这些问题,先学 channel,再学 select 和 context。c-a/u-a/u-b/m-a 都是教学身份;当前仓库仍只有本地 CLI 静态课程,没有在线消息发送服务。这里的“进入 channel”只表示本进程内的一次交付,绝不等于服务端持久化、B 设备收到或用户已读。
| 第一遍 | 第二遍 | 个人练习成果 |
|---|---|---|
| 发送/接收、缓冲与关闭 | select、取消、期限、背压和停止合同 | 两张队列时间线与一份任务收尾表 |
先修 05.01 的任务生命周期、05.02 的共享状态所有权与 03.04 的“等待不等于用 CPU”。05.08 将深入有界工作池与过载策略,04.08 讲网络短读短写与 deadline,S5 才讨论可靠消息、重复和离线恢复。
一、用 channel 表示一次进程内交接
channel 是 Go 中带元素类型的通信通道。make(chan Message) 是无缓冲通道:一次发送和一次接收需要配对,发送者可能等接收者准备好。make(chan Message, 2) 有容量为 2 的缓冲:在缓冲未满且没有其他阻碍时,发送者可先把两个值交给通道;缓冲满时,后续发送要等接收者取走值,或走其他停止路径。
type Message struct{ ID string }
direct := make(chan Message) // 无缓冲。
queue := make(chan Message, 2) // 最多暂存两个 Message 值。
queue <- Message{ID: "m-a"} // 发送。
got := <-queue // 接收一个值。
_ = got无缓冲交接完成,只说明对应发送与接收在通道操作上配对,不说明接收者已处理业务消息。有缓冲发送返回,也仅说明值已进入通道或被接收者拿走;后续处理可能失败。channel 可以传 Message 值,但如果结构体里含 []byte、map、指针等可变引用,传值仍可能共享底层数据,必须像 05.02 一样明确所有权,不能以“经过 channel”自动宣称没有数据竞争。
方向类型帮助接口表达谁能做什么:chan<- Message 允许发送,<-chan Message 允许接收;完整 chan Message 可在持有者范围内双向使用。方向限制能降低误用,但谁负责关闭、何时可停止仍需明确的协议。
容量 2 的纸上时间线
先固定假设:只有一个生产者,消费者直到 t=2 才开始取值;缓冲初始为空,没有取消。
| 时刻 | 生产者动作 | 缓冲状态 | 该动作能说明什么 |
|---|---|---|---|
t=0 | 发送 m-a | [m-a] | 值进入本进程内通道 |
t=1 | 发送 m-b | [m-a,m-b] | 缓冲达到容量 2 |
t=1+ | 尝试发送 m-c | 仍 [m-a,m-b] | 生产者等待空间,不应报告已入队 |
t=2 | 消费者取走 m-a | [m-b] | m-c 有机会入队 |
t=2+ | 等待中的发送完成 | [m-b,m-c] | m-c 进入队列,尚未被业务处理 |
实际调度的精确时刻不确定,表格只是按题设构造的因果次序。若消费者在前两次发送时就已等待,值可能直接交给它,缓冲不必按表格先填满。len(queue) 是某一瞬间的排队值数,cap(queue) 是容量;不能先看 len<cap,再假设并发场景下随后发送绝不会等待,因为别的生产者可以先占位置。
二、满、空、关闭与零值要分别解释
空的开放通道上接收可能等待生产者;满的开放缓冲通道上发送可能等待消费者。关闭则是一个独立事件:close(queue) 表示不会再往此通道发送新值,并不删除已缓冲的值。接收方可先依次取完缓冲;之后使用 m, ok := <-queue 会得到 Message 的零值和 ok=false。for m := range queue 会在关闭且缓冲排空后结束。
queue := make(chan Message, 2)
queue <- Message{ID: "m-a"}
queue <- Message{ID: "m-b"}
close(queue)
first, ok1 := <-queue // m-a, true。
second, ok2 := <-queue // m-b, true。
zero, ok3 := <-queue // Message{}, false。
_, _, _, _, _, _ = first, ok1, second, ok2, zero, ok3这一段是静态预期,没有运行。ok=false 与“收到一条值为零值的合法消息”不同;只读一个值而不看 ok,可能误把关闭当业务空消息。向已关闭通道发送或再次关闭会 panic;关闭 nil 通道也会 panic;对 nil 通道直接发送或接收会一直阻塞。不要用这些行为做业务错误协议。
示例末行的六个 _ 只是让这些用于纸上推演的局部变量在 Go 片段中有明确去处;真实调用方应检查收到的值与 ok,不能把它们全部丢弃。
通常由负责发送的一侧或协调所有发送者的主管关闭通道,因为只有它知道以后不会再发。接收者若擅自关闭多个生产者仍会使用的队列,其他发送者可能在稍后发送时 panic。多生产者场景应先停止新任务并确认全部生产者结束,再由唯一协调者关闭;这一条是所有权规则,不是 channel 运行时自动替你执行的逻辑。
关闭不等于取消。 关闭主要宣布“没有新值”;取消主要请求“这批工作应停止”。一个正常关闭后的消费者可以排空已有缓冲;一个收到取消的消费者则可能立即退出,缓冲内仍有未处理的值。本章不能用 close(queue) 假装已经送达全部消息,也不能用 cancel() 假装队列自动清空、连接自动关闭。
三、select 让等待空间与停止请求并列
若队列满了,直接 queue <- m 可以一直等。让生产者还可响应停止信号,可把两种等待放进一个 select:
func Offer(ctx context.Context, out chan<- Message, m Message) error {
select {
case out <- m:
return nil
case <-ctx.Done():
return ctx.Err()
}
}这段代码所在文件需导入 context,Message 沿用本章教学类型。out 满且消费者未取值时,发送 case 暂不可进行;若 ctx.Done() 随后关闭,取消 case 可使任务返回。Offer 返回 nil 只说明本进程内发送到 channel 成功,不是 B 收到。ctx.Err() 则让上层知道为什么没有按当前路径交付;上层仍需决定重试、丢弃或告知调用者。
重要边界:若发送和取消在进入 select 时都已就绪,Go 会从可执行 case 中选择一个,不能承诺“取消一定优先”。因此 Offer 即使看到已取消的上下文,也可能在同一刻把 m 放入仍有空间的队列。若业务严格要求“取消后绝不入队”,要设计更强的状态同步与确认合同,不能只给 select case 排个书写顺序。
select 的 default 可让操作立即走不等待分支,例如满队列直接返回 ErrQueueFull。这是一个明确的过载政策,而不是“免费提高吞吐”:调用者需要知道消息未入队。若在无限循环里只写 select { case ...: ...; default: },可能造成忙轮询持续占用 CPU;等待事件或用有界、可取消的阻塞方式通常更合适。
接收侧也可等待输入或取消:
func Consume(ctx context.Context, in <-chan Message, handle func(context.Context, Message) error) error {
for {
select {
case <-ctx.Done():
return ctx.Err()
case m, ok := <-in:
if !ok {
return nil
}
if err := handle(ctx, m); err != nil {
return err
}
}
}
}Consume 只保证在这几个等待点观察取消;如果 handle 自己长时间阻塞又不理会传入的 ctx,外层 select 不会中途强制停止它。若输入与取消同时就绪,也可能再处理一条消息后才返回。handle 的成功定义需由应用另定,它返回 nil 并不自动证明持久化或设备送达。
上述两个函数还要求通道在仍有发送者时保持开放。若另一个 goroutine 与 Offer 并发地关闭 out,向已关闭通道发送可能 panic;ctx 同时取消也不能把错误所有权规则变为安全。通道何时关闭必须由发送方主管协调,不能让接收者见到“暂时没消息”就直接关闭。
四、context 传递协作式取消与期限
Go 的 context.Context 用于在调用链和相关任务之间传递取消信号、期限与请求范围的信息。常见写法从父上下文派生一个子上下文,并拿到对应 cancel 函数:
ctx, cancel := context.WithTimeout(parent, 2*time.Second)
defer cancel()
err := Offer(ctx, queue, Message{ID: "m-a"})
_ = err // 真正调用方应按业务合同处理错误。这是一段静态片段;所在文件需导入 context、time,parent 和 queue 由外层任务提供。WithTimeout 在给定期限后让子上下文取消;提前调用 cancel() 也可结束这份子工作,并释放关联计时资源。即使函数正常提前完成,也应调用返回的 cancel,不能把计时资源一直留到超时自动到来。父上下文取消会传播到由它派生的子上下文,子上下文取消不反向取消父任务。
收到取消时,<-ctx.Done() 可以变为可进行;ctx.Err() 可说明这是显式取消还是期限到达等原因。取消是请求,不是强制杀掉 goroutine 的机制。 任务需要在会阻塞或反复工作的地方检查 ctx,并让下层 I/O 或处理函数有机会响应。若一个任务忽略 ctx,或被一个不支持取消的操作长期阻塞,仅调用 cancel() 不保证它立即结束。主管还要等待其实际退出、记录剩余资源状态;cancel 也不代替 Close 文件/连接或收集业务错误。
Context 不应用来在所有函数间偷偷装一整条 m-a 业务消息、共享可变队列或替代明确的参数。业务正文、队列值和错误结果仍按对应接口传递;ctx 表示当前工作范围能否继续、何时应停止。用户点击“取消发送”、HTTP 请求被中止和服务要退出,可能需要不同的取消源与业务承诺;不能把它们一律等同于“消息肯定未保存”。
五、正常关闭与取消退出:两条时间线不要拼成一条
正常收尾模型:主管宣布不再接受新的发送任务;等待已登记生产者结束;唯一拥有关闭权的主管调用 close(queue);消费者继续取出已经入缓冲的 m-a、m-b,直到关闭且排空后 ok=false,再退出。这个模型只说明内存队列的值被消费函数拿到了;handle 是否真正把每条消息发送到网络或保存成功,要看它返回的错误与业务确认。
不再产生新任务 → 等所有发送者结束 → close(queue)
→ 消费者取完已缓冲值 → 最后一次接收 ok=false → 消费者返回取消退出模型:假设队列已满,m-c 的生产者在 Offer 等空间,消费者可能正在处理 m-a;此时 A 断开连接,主管取消共同的上下文。等待发送的一方有机会从 ctx.Done() 返回;消费者若在 select 等输入,可观察取消并返回,但它若在不支持取消的 handle 中仍可能不能及时退出。队列里可能留下 m-b 或 m-c,其业务结果必须标为未处理或未知,不能因为“后来关闭了队列”就记成已送达。
A 断开 → 主管 cancel()
→ 各任务在可响应的等待点观察取消
→ 主管等待实际退出并收集错误/未处理范围
→ 在确认没有生产者会再发送后,唯一所有者才能决定关闭队列这两条时间线并非总能合并成同一保证:选择“尽量排空”需要允许消费者继续处理并有完成期限;选择“立即取消”可能留下缓冲。产品必须说清对用户显示的状态、是否允许重试及消息身份如何避免重复。S5 会定义离线、补拉与幂等;本章不能从 close 或 cancel 得出完整可靠性结论。
六、满队列时必须有业务政策
容量为 2 只限定当前缓冲可暂存的值数,不会自动让上游输入停止,也不替服务定义超时与内存预算。未来 IM 发送路径至少有三种可讨论的政策:
| 队列满时选择 | 对生产者的可观察结果 | 代价与后续责任 |
|---|---|---|
| 阻塞等空间,并接受取消/期限 | 直到成功入队或返回取消/超时 | 上游请求时间变长,可能保留任务资源 |
| 立即拒绝 | 明确的“容量已满”错误 | 调用方决定重试、退避或告知用户 |
| 明确允许丢弃某类事件 | 返回“已丢弃”并计数 | 只适用于产品允许丢的事件,不能伪报消息成功 |
本课程尚未选择在线 IM 的最终政策。普通聊天消息不能默默丢掉却显示“发送成功”;心跳或重复状态提示是否可丢弃,要有独立规则。无限增加 channel 容量只会把过载推迟为内存问题,不能提升下游处理能力。队列长度、入队等待、拒绝数、消费者处理时间各说明不同阶段,后续 05.08 与 11 卷才做实测和容量设计。
若 select 用 default 实现“立即拒绝”,应把该返回值纳入调用方合同;若选择阻塞,要给取消和期限可达路径。若产品要求严格“取消后绝不入队”,前述两个同时就绪的 select case 不满足,需要额外的受控状态转移。这是业务保证强于语言原语默认选择的例子。
七、把连接关闭、错误和确认点分开
04.03 的 WebSocket 握手 101 只代表协议切换,05.01 的 go 只代表任务启动,05.02 的锁只保护本进程内存状态,05.03 的 channel 入队只代表进程内交接。它们都不自动提供“服务端已保存”“B 设备已收到”或“u-b 已读”。若未来一条连接有读任务、写任务和队列主管,设计稿至少回答:
- 谁是队列唯一的关闭协调者?多个生产者如何停止并被等待?
- 读任务发现客户端断开时,由谁调用
cancel?写任务是否会响应? - 消费者
handle返回错误时,谁接收并决定剩余消息怎么处理? - 连接何时
Close,是否还有任务可能用它写数据? - 对
m-a,哪一个确认点才允许向u-a显示“已受理”?
如果主管只调用 cancel() 而不等任务,资源可能仍被持有;如果只调用 close(queue),正在进行的 I/O 不一定停止;如果直接关闭连接,业务可能处在“写了一部分但无确认”的未知状态。把这三种状态记录清楚,再到 04.05、04.08 和 S5 设计网络关闭、短写与恢复。
八、分层练习与个人验证路线
先对容量 2 的 m-a/m-b/m-c 画队列状态,再分别模拟正常关闭和取消退出。实际 Go 运行与测试由学习者以后在自己的隔离环境记录;本章只给静态预期,不启动网络服务、不执行代码。
先回答,再看反馈
1. 无缓冲 channel 的发送何时可完成?
需要有接收操作配对;配对不等于接收者已处理业务。
2. 容量 2、无消费者时,前两次发送可怎样?
可进入缓冲;第三次发送会等待空间,除非另有取消或非阻塞政策。
3. `cap(queue)==2` 表示最多只能有两个 goroutine 吗?
不是。它只限定该通道缓冲容量,不限制等待的发送者数。
4. `len(queue)<cap(queue)` 就保证随后发送不等待吗?
不能。并发生产者可在你检查后抢先占用空间。
5. 发送 `Message` 值后,内部 `[]byte` 必然不共享吗?
不必然。值拷贝仍可共享切片底层数据,需要所有权规则。
6. `chan<- Message` 限制了什么?
接收该参数的代码只能通过它发送,不能用它接收。
7. 关闭有缓冲通道会清空已经发送的值吗?
不会。接收者先取完缓冲,之后才得到零值和 ok=false。
8. `m, ok := <-queue` 的 `ok=false` 表示什么?
通道已关闭且缓冲已排空;不表示收到合法的零值业务消息。
9. 向已关闭通道发送会返回普通 error 吗?
不会按普通发送返回错误;会发生 panic,故须由所有者协调关闭。
10. 多个生产者还在工作时,消费者可以随意 close 吗?
不应如此。它不知道是否还有后续发送,可能使发送者 panic。
11. 关闭通道等于取消正在处理的消息吗?
不等于。关闭宣布不再发送新值,已缓冲/处理中的业务状态另定。
12. `Offer` 的发送 case 返回 nil 证明 B 已收到吗?
不能。它只证明值进入本进程通道或被消费者接收。
13. 队列有空间且 ctx 同时已取消,`select` 必选取消吗?
不保证。两个 case 同时可执行时可能选择发送。
14. 带 `default` 的无限循环为什么可能有害?
它可能忙轮询,持续用 CPU 而不等待事件。
15. `WithTimeout` 返回的 cancel 在提前完成后还要调用吗?
应调用,以释放关联的计时和父子引用资源。
16. 父 context 取消会传播到派生子 context 吗?
会。子取消不会反向取消父任务。
17. `cancel()` 会强制终止不理会 ctx 的 `handle` 吗?
不会。取消是协作信号,下层需检查或使用支持取消的操作。
18. 正常关闭后 `for range queue` 何时退出?
关闭且已缓冲值全部取完之后退出。
19. 取消时缓冲里仍有 `m-b`,能登记为已送达吗?
不能。它可能尚未被消费者处理,业务结果应记录为未处理或未知。
20. 队列满时可以默默丢掉普通聊天消息并显示成功吗?
不符合本课程的可验证合同。若允许丢弃必须明示类型、结果和计数。
21. `close(queue)` 会自动关闭 WebSocket 连接吗?
不会。通道和连接是不同资源,主管需分别管理。
22. Ping/Pong 回来或 `Offer` 成功能证明 `u-b` 已读吗?
不能。协议存活、进程内入队与用户已读是不同确认点。
来源与下一步
- Go 语言规范:channel、select、close:通信、同时就绪选择与关闭语义。
- Go
context包与官方 Context 介绍:父子取消、期限和CancelFunc责任。 - Go 官方博客:Pipelines and cancellation:多任务停止与资源回收的进一步阅读。
完成 S2 这段网络/并发入门后,先读09.01 需求与应用边界,再按学习路线补应用安全与 Go 网络库,后续回到 04 卷深化可靠传输。进入后续章节前,请能分辨“入队”“通道关闭”“取消请求”“任务实际退出”“B 设备收到”五个状态。