05.08 并发组合:从有限群扇出到按会话串行
返回第五卷 · 有限队列前置:05.03 · 内存模型前置:05.04 · 调度前置:05.05 · GC 前置:05.07
05.08 并发组合:从有限群扇出到按会话串行
DeepTutor 初稿经技术与教学审阅后的静态课程。群、接收目标、工作者、队列和时刻均为纸上教学模型;没有运行 Go、IM 服务、负载或站点。本章只设计进程内并发与资源边界,不声称 OpenIM 的实际扇出方式,也不把工作队列出队视为数据库持久化或设备交付。
一、同一条群消息,为什么“并发上限”还不够?
虚构会话 c-g 的一条消息经过发送者身份与成员规则检查后,产生给六个目标 A、B、C、D、E、F 的教学投递尝试。如果给每个目标立即 go 一个任务,六个看似不多;当目标变成 10000 个时,“每个任务都很轻”并不能保证 goroutine、缓冲和待发数据有界。05.03 已讲容量与取消,05.07 又说明积压对象仍可达、GC 不会替应用清队列。现在要把并发执行、等待空间和结果汇聚一起设计。
第一遍先用两个固定工作者和容量为三个任务的等待队列,不涉及重试或离线补拉。每个目标任务有三种不同状态:正在处理、已接纳并排队、仍在生产者手中未接纳。它们对调用方的承诺不同;把三者都叫“已发送”会丢失关键失败边界。
| 第一遍 | 第二遍 | 交付物 |
|---|---|---|
| worker、队列、接纳与背压、fan-out/fan-in | 信号量、流水线取消、按 key 串行、singleflight | A–F 状态表、取消/关闭责任表、业务保证对照 |
本章承接 S3 的权限与当前确认点:即使一条消息已被教学服务受理,六个目标的在线尝试仍各有独立结果;进程内排队不等于 S5 的持久任务、跨节点幂等或设备展示确认。
二、A–F 的状态表:2 个在做、3 个在等,第 6 个在哪里?
假定目标按 A→F 被逐个枚举,t0 时两个 worker 同时取 A、B,C/D/E 依次进入容量 3 的队列,F 还没被接纳:
| 时刻 | worker 1 | worker 2 | 等待队列,容量 3 | 生产者 |
|---|---|---|---|---|
| t0 | A 处理中 | B 处理中 | [C,D,E],已满 | 持有 F,等待空间或按规则拒绝 |
| t1(假定 A 完成、worker 1 取 C) | C 处理中 | B 处理中 | [D,E,F] | F 得到空槽后才算接纳 |
已接纳而未完成的任务上限是 2 个处理中 + 3 个排队 = 5 个。但这不等于“全进程只占五个任务的内存”:生产者在 t0 还持有 F,结果汇聚器可能保存已完成的 A,输入来源也可能预先物化了全部成员。如果先造一个含 10000 个完整目标任务的切片,再逐个塞进容量 3 的 channel,队列虽小,输入切片仍可占大量内存。应按需遍历目标并在接纳点施加背压或明确拒绝。
处理顺序也不是投递完成顺序:B 可能比 A 先完成;t1 只是指定了一种可发生的纸上交错。若业务要求会话序号或同一设备消息顺序,不能从两个 worker 的取任务顺序推断,后面按 key 串行再讨论。
三、工作池、信号量与限额放置:先占席位再创建任务
工作池可预先维持固定数量的 worker,从有界输入队列取任务。一个信号量则给资源设可同时持有的令牌数:执行前取得,结束时归还;golang.org/x/sync/semaphore 的 Acquire 可接收 Context,在取消时放弃等待。errgroup.Group.SetLimit 可限制该组活跃 goroutine 数,其 Go 在满额时阻塞调用方,TryGo 在不能开始时返回 false。不同工具限制的是不同位置,不能因为名字里有“limit”就认定全链路都有限。
有边界的思路:逐个取目标 → 先等待许可/队列空位 → 再启动或接纳任务
仍可能膨胀:先为 10000 个目标各启动 G → 每个 G 内部再等容量 2 的许可后者确实限制了同时做实际工作的数量,却已经创建 10000 个等待 G,它们的栈、上下文与引用仍占资源。取得许可前也可能已经生成完整大任务对象。若 TryGo 返回 false,必须把“未启动”交给拒绝、稍后重试或背压政策,不可默默当作成功。令牌在所有成功/失败/取消路径都要归还;上游 Context 结束也不自动撤销已产生的网络副作用。
若不同任务的成本不相等,可用加权令牌代表不同预算,但权重如何估计、等待是否公平都要单独说明。对初学者,先让工作数、排队数、单任务最大字节数三个界限同时可复核,再考虑更复杂的权重。
四、fan-out 与 fan-in:六个结果怎样回到一次请求
fan-out 把一个来源分给多个处理者;本章从一个群消息派生六个目标尝试。fan-in 收集每个目标的处理结果,结果至少应含目标 ID、成功/失败/未知状态与错误身份。若只按“第一个返回的结果是 A”推断,B 先完成就会错位。完成顺序受网络、排队和调度影响,与 c-g 消息 seq 或输入 A→F 的次序不同。
| 结果类别 | 能说什么 | 不能直接说什么 |
|---|---|---|
| 已完成本地发送尝试 | 本次对应处理函数给出结果 | 对端应用已解析、设备已展示 |
| 明确失败 | 此目标按记录的条件失败 | 其他目标也失败或主消息未受理 |
| 超时/取消后的未知 | 本地没有完整终局证据 | 一定没产生网络/下游副作用 |
| 尚未接纳 | 工作尚未进入队列/worker | 已经投递给设备 |
若 B 设备很慢,“等六个都成功才回答”可能让一次请求无限拖延;要设截止时间、部分结果语义与后续恢复责任。Context 取消不能让已经完成的 A 尝试被撤回,也不能保证 B 未产生副作用。S5 才讨论持久任务、重试、离线补拉和多端确认;本章的 fan-in 仅为进程内结果汇聚。
五、流水线:下游退出,上游为什么可能卡死?
可以把群处理拆为生成目标 → 编码每目标数据 → 尝试写连接 → 汇聚结果。每一段有输入、输出和有限处理者;只限制最后一段的 worker,不限制上游编码队列,仍可能让内存堆积。若结果消费者因请求取消提前退出,而上游仍阻塞在发送到它不再读取的 channel,goroutine 及其引用可能长期滞留。Go 官方 pipeline 教材用这种“下游提前停止”作为取消传播的核心反例。
一条可审阅的停止合同应回答:谁取消共同 Context?各阶段等待发送/接收时是否也监听取消?谁关闭哪个输出 channel?多个 worker 共享输出时,不能任意一个 worker 结束就关闭;应由协调者等所有发送者结束,再关闭输出。正常结束可排空结果;错误中止时则可能有已处理、处理中、已排队和未接纳四类工作,各自的状态要对账。
| 阶段 | 输入上限 | 输出/关闭责任 | 取消后的待办 |
|---|---|---|---|
| 目标生成 | 按需枚举,受下游背压 | 仅唯一生产者在结束发送后关自己的输出 | 停止继续枚举,报告未接纳目标 |
| 编码 worker | 固定 worker 数与有界队列 | 协调者等全部编码发送者结束后关结果通道 | 已编/半编与错误分别记录 |
| 连接写入 | 每设备有界输出/期限 | 连接所有者处理关闭与资源释放 | 已写前缀、待写后缀和未知结果分开 |
“关闭 channel”只宣布不再发送新值,不等于排队值已处理;“取消 Context”也不会自动清空缓存或回滚已发字节。05.03 的关闭/取消规则在多阶段组合里仍要逐段适用。
六、按会话 key 串行:同一个 c-a 有序,不等于全局有序
若 c-a 的两条消息需要按 seq=1、2 依次处理,可把同一会话 ID 映到一个本进程内串行执行路径;另一会话 c-b 可用另一条路径并行。这样保证的是这一个本地调度器按其接纳顺序处理 c-a 的任务,前提是入口没有并发竞态改乱次序、队列有上限、异常和取消有定义。它不是数据库提交顺序的替代,也不跨服务节点自动成立。
c-a:seq1 → seq2 (同 key 本地串行)
c-b:seq1 (可与 c-a 并行)热点 c-a 会把同 key 的任务堆在一起;应有每 key 容量、过载反馈和闲置 key 的清理规则。若消息被不同节点接纳或重试到别处,需要更高层的会话序号分配、事务或恢复协议,不能仅凭一个进程中的 map[conversationID]queue 保证跨节点全序。也不能把“本地串行执行完”误认为设备按 seq 展示完毕。
七、singleflight:合并同键在途读,不能代替消息幂等
另一个并发模式针对重复的只读计算:多个请求同时查同一 c-a 历史页,可用 singleflight.Group.Do(key, fn) 使同一组内同时在途的同键调用只执行一次,其他调用等待并共享结果。它是同键在途函数调用合并,不是持久缓存;原调用结束后的下一次请求可再执行,也不跨进程协调,更不保证消息发送幂等。
缓存/合并的 key 必须反映结果的可见范围。若两个用户对 c-a 的历史有不同授权,不能只用 conversation_id=c-a 合并并把一个用户的结果交给另一个;每位调用者仍需经过对象授权,key 可包含主体/权限范围、游标、页大小和与权限变更相关的版本,或只合并不会泄露的底层公共数据。具体授权检查的竞争边界仍由 09.07 与数据库规则定义。
| 同键同时请求 | singleflight 能做 | 它不做 |
|---|---|---|
| 同一授权范围、同一游标的历史只读查询 | 共享一次在途函数结果 | 持久缓存、过期管理 |
两个不同权限主体查 c-a | 需先设计安全 key/结果范围 | 自动替所有调用者做授权 |
重复发送 m-a | 不应靠合并读调用解决 | 持久幂等、事务与设备确认 |
因此,模式选择跟需求走:限并发处理群目标用工作池/信号量;保同一会话局部顺序用按 key 串行;减少同键重复读用 singleflight。把这三者都称“去重队列”会掩盖不同保证。
八、交付组合评审与分层练习
交付 A–F 的 t0/t1 状态表,标出已接纳上限 5 与生产者仍持有 F;画出“先启动 10000 G 再取许可”的反例;写明 fan-in 的目标 ID、结果状态和截止时间;为每条流水线通道标出唯一关闭协调者和取消路径;最后列出 c-a 本地按 key 串行与同键历史只读合并的安全边界。所有表均为纸上设计,非运行结果。
分层练习:先答,再展开反馈
1. t0 哪两个目标正在处理?
A、B,占用两个 worker。
2. t0 哪三个目标已接纳排队?
C、D、E,等待队列正好满 3。
3. t0 的 F 已被 worker 或队列接纳了吗?
没有。生产者仍持有 F,须等待空位或按规则拒绝。
4. 本模型已接纳未完成任务上限是多少?
2 个处理中加 3 个排队,共 5 个;不等于全进程所有任务内存。
5. t1 A 完成、C 出队后,F 可以到哪里?
新空出的等待槽;此时 B/C 处理,D/E/F 排队。
6. 先为 10000 目标启动 G,再在 G 里等许可,限制了什么?
只限制同时做实际工作的数量;已经创建了大量等待 G。
7. `errgroup.SetLimit` 的 Go 调用满额时会怎样?
按官方 API 语义阻塞调用方直到可加入活跃 G;上游已物化数据仍需另限。
8. `TryGo` 返回 false 可当作任务成功吗?
不能。它表示本次没有启动任务,需处理未接纳结果。
9. 只限制 channel 为 3,先建 10000 个完整任务切片会怎样?
channel 有界但输入切片仍可能占大量内存,应该按需枚举或限源。
10. fan-out 与 fan-in 分别做什么?
前者拆分一个来源到多个处理者;后者汇聚多个目标结果。
11. B 比 A 先完成时,可把第一个结果当 A 吗?
不能。结果要带 recipient ID,完成次序可不同。
12. 一个目标超时就证明它一定没有网络副作用吗?
不能。超时后结果可能未知,需保留状态与后续核对责任。
13. 下游结果消费者先退出,上游仍发送可能发生什么?
上游可能卡在 channel 发送并保留 goroutine/对象引用;要传播取消。
14. 多 worker 共用输出通道,任一 worker 退出都可关闭吗?
不能。由协调者等全部发送者结束后关闭。
15. `cancel()` 会自动清空队列并撤回已写字节吗?
不会。各阶段要按合同处理已接纳、处理中与未知副作用。
16. 同一 `c-a` 按 key 串行可让 `c-b` 并行吗?
可以。不同 key 可各走自己的本地执行路径。
17. 本进程 `c-a` 串行就能保证跨节点数据库提交顺序吗?
不能。跨节点还需更高层序号、事务与恢复协议。
18. 热门 key 无容量限制会怎样?
该 key 的队列可能积压,必须定义上限、过载与清理。
19. singleflight 主要合并什么?
同一 Group 中同键同时在途的函数调用与结果。
20. singleflight 是持久缓存或跨进程幂等吗?
都不是;完成后可再次执行,也不跨进程保存业务结果。
21. 两个权限不同的用户查 c-a,可只以 c-a 作共享 key 吗?
不能直接这么做。应先定义授权及安全的结果/键范围。
22. 一次群消息被服务受理,等于六设备都已展示吗?
不等于。目标尝试、设备应用确认和展示各有独立状态。
来源与下一步
- Go 官方 Pipeline 与取消教程:fan-out/fan-in、下游提前退出和取消传播。
- Go
errgroup与semaphore:活跃任务限制、取得/释放许可与 Context 边界。 - Go
singleflight与context:同键在途调用合并与取消信号的 API 语义。
按学习路线,下一章05.09 并发验证把这些纸上组合放进可控制调度、取消和资源泄漏的并发验证设计。离开本章前,应能同时说清活跃数、排队数、尚未接纳数、已发生副作用与未知结果,并为每个数量给出明确上限或记录方法。