05.01 并发任务与生命周期:让 Go 的 IM 处理工作有明确归属
返回第五卷 · 系统前置:03.04 CPU 调度 · 网络前置:04.03
05.01 并发任务与生命周期:让 Go 的 IM 处理工作有明确归属
DeepTutor 初稿经技术与教学审阅后的静态课程。Go 代码、输出次序、任务图和故障均为教学推演;本次没有运行 Go 示例、测试、站点构建或 IM 服务。
本章要解决的不是“怎样开更多 goroutine”
03.02 已区分进程和线程,03.04 已区分正在运行、可运行和等待;04.03 为未来教学 IM 设计了 HTTP 历史查询与 WebSocket 双向消息通道。若多个客户端几乎同时发起任务,Go 程序怎样安排它们而不让一个等待操作挡住所有工作?一个处理任务启动以后,谁知道它何时完成、失败了什么、占用的连接由谁关闭? 这才是本章的入口。
当前课程仓库仍只有本地 imhistory 教材项目,没有启动在线 IM 服务。c-a/u-a/u-b/m-a 都是虚构身份;下文的连接、查询和投递是将来个人练习的需求模型,不是对 OpenIM 或某个运行中系统的实现事实。先学语言机制,再学小任务的生命周期,最后迁移到业务。
| 第一遍 | 第二遍 | 个人实践成果 |
|---|---|---|
并发/并行、go 语句、完成等待 | 错误、资源所有权、有界任务 | 一张“谁启动、谁等待、谁关闭”的任务树 |
只需要已学的函数、切片、错误和 03.04 的调度状态。05.02 才系统讲 Mutex、WaitGroup 与共享状态保护;05.03 讲 channel、context、截止时间和取消;05.04 深入内存模型。这里为了给初学者一个可闭合的最小示例,先使用 WaitGroup 的 Add/Done/Wait 三个操作,其更一般的使用规则留到下一章。
一、并发是任务结构,并行是同一时刻执行
并发是把多个任务安排为可以交错推进的结构:A 等 I/O 时 B 可以继续,A 和 B 不必按“完整做完 A 才开始 B”写成一个长流程。并行是多个执行单位在同一时刻真正用不同 CPU 资源做事。单核也能有并发:A 运行一段,进入等待,B 接着运行;多核有可能并行,但也可能因锁、I/O 或任务依赖而做不到。
单核交错:A──等 I/O────────A
B──────B
多核可能并行:核心 1 A──────
核心 2 B──────03.04 的“可运行、运行、等待”仍适用。连接数不等于 goroutine 数,goroutine 数不等于操作系统线程数,也不等于 CPU 核数。一个未来网关即使保有很多连接,它们可大部分时间等待消息;一次群聊扇出也可让少量连接产生很多待处理任务。实际 Go 调度模型在 05.05 讲,不要先把任何具体项目画成“每连接必有一个线程”。
并发的价值不是无条件加速。若要计算两段 len(body),启动任务和协调结果可能比顺序计算更费事;若任务主要在等待独立 I/O,交错推进可能提高资源利用。判断前先写出工作量、等待点和共享状态,不能只数有多少个 go 关键字。
二、go 语句只启动任务,不替调用者等待
Go 的 go f(args) 会启动一个新的 goroutine 执行函数调用;参数表达式在执行 go 语句的 goroutine 中求值,新的 goroutine 再执行被调用的函数。启动者随后继续,不会因 go 自动等待它完成。被调用函数即便返回了值,也不会把这个返回值直接交给启动者变量。
// 教学片段:只说明启动关系,不承诺输出先后。
go recordID("m-a")
recordID("m-b")你不能仅凭源码先写 go recordID("m-a"),就断言 m-a 会先处理或先打印。它可能稍后才获调度、先等待文件或网络,m-b 也可能先完成。输出顺序、消息顺序和业务确认都要由额外合同建立,源码行序不等于两个 goroutine 的完成顺序。
把参数显式传入任务能让每次任务的输入更清楚:
for _, id := range []string{"m-a", "m-b"} {
go func(id string) {
recordID(id)
}(id)
}此片段只演示每个任务得到它自己的 id 参数,recordID 是尚未在此定义的示意函数;它没有等待两个任务完成。课程不把打印或写文件的某个顺序当成已观察结果。time.Sleep 也不是完成协议:睡 10 ms 只说明调用方自己等待了这段墙上时间,不证明子任务结束、没有错误或资源已释放。
如果 main 返回,或进程调用 os.Exit,其余 goroutine 不会得到“必须先完成工作”的保证;未执行的清理也不能靠进程结束自动替代。尤其不能把 go sendMessage(m) 后立刻返回“发送成功”当成客户端、服务端或设备送达证据。启动只是把任务安排出去,成功要等到定义明确的完成和确认点。
三、把生命周期写成父任务的责任
一个可管理的任务至少有五件事:谁启动、什么时候算完成、错误交给谁、资源由谁关闭、遇到停止请求怎样结束。把它们填进任务树,便不容易把后台任务变成“无人看管”的 goroutine:
请求/主管任务(拥有一次操作的结果)
├── 子任务 A:处理 m-a → 完成或返回错误
└── 子任务 B:处理 m-b → 完成或返回错误
主管:等待已启动任务 → 收集结果 → 按业务合同给调用者回答最小的完成计数可用 sync.WaitGroup:启动前 Add(1) 表示多一项待完成工作;子任务结束时 Done() 减一;主管在所有本批任务都登记后 Wait(),等待计数归零。defer wg.Done() 可让子任务按正常函数返回路径完成计数责任。下面是结构片段,具体函数在第四节给出:
var wg sync.WaitGroup
for _, id := range []string{"m-a", "m-b"} {
wg.Add(1) // 先登记,再启动。
go func(id string) {
defer wg.Done()
_ = id // 后续做本批任务的独立工作。
}(id)
}
wg.Wait() // 本批登记的任务都结束后才能继续。WaitGroup 回答的是“登记的任务是否都调用了 Done”,不携带业务错误、结果、取消信号或已读状态。若 goroutine 在 Wait() 之后仍持有另一段异步任务,或把 Done 提前调用,主管看到的完成点也不等于真实业务完成。初次使用时把所有新增任务在 Wait 开始前登记,避免计数仍为零时主管已返回、后来才启动未纳入等待的新工作;更复杂的动态任务树需在 05.02–05.03 明确规则。
等待本批任务完成也不会自动处理异常路径:处理任务可能长期卡在读取、写入或锁上,使 Wait() 一直不返回。05.03 将为任务引入截止时间和取消;现在至少要在设计稿上写出“主管愿意等多久、超时之后谁发停止请求、谁最终收尾”,不能用无限 Wait 冒充优雅退出。
四、一段完整的静态 Go 示例:只处理固定小批量
下面给出一个可放在教学 history 包中的完整函数片段。它只计算字符串 UTF-8 字节长度,沿用 01.04 的 len(string) 规则。每个 goroutine 写不同下标,父任务在返回切片前等待全部完成:
package history
import "sync"
func BodyByteLengths(bodies []string) []int {
lengths := make([]int, len(bodies))
var wg sync.WaitGroup
for i, body := range bodies {
wg.Add(1)
go func(i int, body string) {
defer wg.Done()
lengths[i] = len(body)
}(i, body)
}
wg.Wait()
return lengths
}用虚构输入 []string{"你好", "a", "你好呀"} 纸上预测应得 [6, 1, 9],但三个计算先后完成顺序不作保证。结果位置由固定索引决定,不依赖完成顺序。每项写入的是不同 lengths[i],不会在本例里同时改同一个元素;父任务 Wait() 返回后才读取/返回结果。这只在输入没有被别的任务同时修改、每个下标只写一次、工作函数不暗中共享可变状态等条件下成立。
这个计算极便宜。为三个 len 启动三个 goroutine,通常没有性能上的必要;本例的目的只是把启动、索引归属、完成等待和返回放在同一条可检查路径。若 bodies 来自无上限网络输入,把每项都直接 go 出去会制造无界任务数,本函数不能不加约束就当作高并发服务方案。
删掉 wg.Wait() 后,函数可在子任务写入之前返回,调用者可能看到仍为 0 的元素;把所有任务都改写同一个 lengths[0] 则引入共享写入问题。对比之下,本例的正确性依赖“每项独立位置”和“返回前等待”,而不是“运行时应该很快”。
五、完成、错误和资源所有权是三条不同的线
WaitGroup 的计数归零只告诉主管:本批登记的 goroutine 已结束。它不返回哪个任务成功、哪个失败。go f() 即使调用的函数返回 error,启动者也不会自动收到;若只是子任务里 return err,错误便没有上交给需要作业务决定的一方。
固定小批量时,可以为每个输入准备一个独立的错误槽。下面的 check 必须允许并发调用,且不得在背后无保护地修改共享状态;函数仅展示“按输入位置收集结果”的合同:
// 与上一节放在同一个 history 包;此处仍使用 import "sync"。
func CheckAll(inputs []string, check func(string) error) []error {
errs := make([]error, len(inputs))
var wg sync.WaitGroup
for i, input := range inputs {
wg.Add(1)
go func(i int, input string) {
defer wg.Done()
errs[i] = check(input)
}(i, input)
}
wg.Wait()
return errs
}即使第二条检查先结束,它仍写 errs[1],所以返回切片按输入次序定位错误,而不是按完成次序排列。主管在 Wait 以后才读取。调用者还须决定:是全部错误一起报告、只取第一个错误、是否允许部分成功?CheckAll 不会因第一个错误自动停掉其他检查,也没有超时/取消能力;这些业务与资源策略不能从函数签名里凭空推断。
连接资源有另一条所有权线。假想在线 IM 服务接受一个连接后,可把它交给某个处理任务;应约定是接纳者仍负责关闭,还是处理任务接手并在退出路径关闭,不能两边都以为对方会收尾。未来若一个连接拆成读任务和写任务,还需有共同的主管决定:一边失败后怎样通知另一边、谁停止接收、何时让写入结束、哪个错误成为最终结果。defer conn.Close() 只解决该函数正常返回时的局部关闭,不能替代跨任务的停止协议,也不能保证最后一条消息已送达。
| 问题 | 任务设计必须写出的答案 | 如果缺失会怎样 |
|---|---|---|
| 谁启动? | 请求处理者或连接主管 | 无人知道后台工作范围 |
| 谁等完成? | 有明确的等待点或监督者 | 请求已回应,工作还在跑 |
| 谁收错误? | 返回值、结果槽或后续明确通道 | 失败被误报为成功 |
| 谁关资源? | 唯一清晰的连接/文件所有者 | 泄漏或过早关闭 |
| 谁能停止? | 后续取消与退出规则 | 客户端离开仍无限等待 |
一个任务可以有“业务已受理”和“后续投递还在进行”两个阶段,但必须让用户界面、日志和调用方知道哪个阶段已完成。把一个没有监督者的 go deliver(m-a) 当作“已送达”会直接跨越 04.01–04.03 已强调的证据边界。
六、共享状态、阻塞与任务数量也要有上限
第四节的两个函数刻意把输出分到不同切片下标,且返回前等待。如果改成两个 goroutine 同时修改一张 map[conversationID]State,或同时读写一份会话成员表,可能出现数据竞争;即便用锁保护了单次读写,若“检查成员→保存消息”这组操作的业务不变量没有整体保护,还可能有业务竞争。05.02 会教 Mutex 与保护范围,05.04 才严谨解释 happens-before;本章不能靠“每个 goroutine 很快”证明共享数据安全。
等待不代表资源消失。 一个任务等网络、等锁或等结果时通常不持续占满 CPU,但它仍可能持有 goroutine 栈、引用的消息缓冲、连接或计数槽。03.04 的“CPU 低不代表容量充足”在并发任务这里又出现:若每次重试都新建一个 goroutine 而旧任务还在等,待完成任务可能越积越多,内存和连接先被耗尽。
无界假想场景:每秒来 100 个请求,每个都启动任务,任务平均卡住 60 秒。
仅按 100 × 60 粗算,就可能同时留有约 6000 个未结束任务。
这还没有计每任务占用的内存、连接与调度开销。这个算式有明确假设:到达率稳定、任务停留时间接近 60 秒、没有提前结束和容量限制。它只是需求预算入口,不是任何真实网关的实测 goroutine 数。后续 05.08 会讲有界工作池、队列和背压;现在先为每种输入规定最多启动多少任务、超额时是排队、拒绝还是延后,并说明排队空间也需要上限。
任务可能因读取、写入、锁或定时器等待而停住;父任务的 Wait() 因此也可能卡住。05.03 会引入 context、channel 与取消传播;在这之前设计图至少要画出“如果客户端 A 断开,谁知道、谁通知、谁等待退出”的路径。操作系统调度器不会自动把业务级取消意图发给你创建的每个 goroutine。
七、把生命周期规则迁移到 IM 需求
以下是未来教学 IM 的三种需求情景,不表示已运行或已验证的服务实现:
| 情景 | 启动与完成点 | 风险反例 | 下一章需补的机制 |
|---|---|---|---|
| 并行准备两个小批量消息结果 | 固定两项,主管等待后形成答复 | 未等完就返回缺项 | 05.02 的同步与正确等待范围 |
| HTTP 历史请求读到文件或远端数据 | 请求任务在响应前确认查询结果 | 请求已结束,子任务仍写响应 | 05.03 的取消与超时 |
| WebSocket 连接有读写/推送工作 | 连接主管定义关闭与错误汇总 | 读端退出、写端仍持有连接或丢错误 | 05.02 共享状态、05.03 关闭传播 |
对 m-a 的一次未来发送,至少把状态分成“任务已启动”“服务端已完成校验/接受”“必要存储完成”“目标设备收到”“用户已读”。go 语句只能支持第一层内部安排,WaitGroup 只能说明它等待的 Go 函数已结束;后四项要看函数做了什么和具体协议的独立确认。重连、重复投递和离线补拉留给 S5,不能用 goroutine 数量解决。
不采用无主后台任务的另一个原因是关闭责任。 如果进程要退出,主管应停止接收新工作,通知已有任务结束,等待合理期限并记录未完成项。这里只讲应有的生命周期合同;具体通道、context 和服务关闭顺序在 05.03 与 12 卷完成,不把一张静态任务树说成已经实现优雅退出。
八、分层练习与个人验证路线
建议先在纸上做四轮,再由学习者以后在自己的隔离目录独立实现并记录真实运行;本轮课程编写不执行 Go 或服务:
- 画出两个 goroutine 的状态时间线,分别标注创建、可运行、等待、完成;解释为什么源码顺序不能给出完成顺序。
- 用第四节的
BodyByteLengths对"你好"、"a"、"你好呀"预测[6,1,9],逐行指出结果下标由谁写、主管从哪里开始读。 - 把
Wait()暂时删掉,说明可能出现什么失败;再用第五节的CheckAll讨论一个子任务返回错误但其他任务仍在做的情况。 - 为未来 HTTP 查询或 WebSocket 连接填“启动/完成/错误/资源/停止”五格表,提出一个输入量和等待时间上限。等 05.02–05.03 的机制学全后,再把个人实测结果与理论预期分开保存。
分层练习:先回答,再核对反馈
1. 并发和并行分别回答什么?
并发说多个任务可交错推进;并行说不同执行单位能在同一时刻真正运行。
2. 单核还能有并发吗?
能。任务可轮流执行,并在一个等待时让另一个推进。
3. 两条连接一定等于两个 goroutine 吗?
不一定,取决于应用组织任务的方式;本课程未实现在线服务。
4. `go f()` 会等 `f` 完成才执行下一行吗?
不会。它安排新 goroutine 后,启动者继续执行。
5. `go` 后面的参数表达式在哪个 goroutine 中求值?
在执行 go 语句的 goroutine 中求值;被调用函数在新 goroutine 执行。
6. `go f()` 的 `error` 返回值会自动传给启动者吗?
不会。需要设计独立的结果收集路径。
7. 先写 `go recordID("m-a")` 能保证它先完成吗?
不能。任务调度、等待和执行时长都可能改变完成顺序。
8. `time.Sleep(10*time.Millisecond)` 能证明任务已结束吗?
不能。它只让调用者等待一段墙上时间,不接收完成与错误信号。
9. `main` 返回时其他 goroutine 一定做完吗?
不保证。进程结束会终止尚未完成的工作。
10. 本章为何在 `go` 之前调用 `wg.Add(1)`?
先登记待完成任务,再允许子任务开始并可能迅速调用 Done。
11. `defer wg.Done()` 负责什么?
该子任务按正常函数返回路径结束时把待完成计数减一。
12. `wg.Wait()` 返回就证明每条 IM 消息已送达吗?
不能。它只说明登记任务的函数已结束,送达要有业务确认。
13. `BodyByteLengths` 中 `你好` 的结果是多少?
按 UTF-8 字节数是 6,结果放在与输入相同的下标。
14. 该函数返回的长度顺序由完成顺序决定吗?
不是。每个任务写固定 lengths[i],返回前 Wait。
15. 把每项都写进 `lengths[0]` 仍安全吗?
不安全。多个任务会同时写同一位置,需要重新设计所有权或同步。
16. `CheckAll` 中某项失败,其他任务自动停止吗?
不会。它等待并收集全部结果,取消需另行设计。
17. 两个 goroutine 同时改共享 map,能靠“任务很快”避免竞争吗?
不能。需要 05.02 的共享状态保护与不变量设计。
18. 任务等待网络时 CPU 低,资源就可以无限增加吗?
不能。等待任务仍可能保留栈、缓冲、连接等资源。
19. 每秒 100 个任务各停留约 60 秒,纸上同时未完成量约多少?
在题设稳定条件下约 100×60=6000,不是实测结果。
20. 谁负责关闭已移交给连接处理任务的连接?
由明确的所有权合同决定;不能让接纳者和处理者都以为对方负责。
21. 请求已取消,子 goroutine 会自动知道并停止吗?
不会自动获得业务取消意图;05.03 要设计传播与响应路径。
22. `go deliver(m-a)` 之后能直接向 `u-a` 报“u-b 已读”吗?
不能。启动任务与服务接受、设备收到、用户已读各是不同证据。
来源与下一步
- Go 语言规范:
go语句:启动与参数求值的语言边界。 - Go
sync.WaitGroup:Add/Done/Wait的计数语义。 - Go 官方博客:Pipelines and cancellation:后续理解任务退出和取消的资料入口。
- Effective Go:goroutine:Go 并发任务的入门参考。
下一章05.02 同步原语将系统学习 WaitGroup 与 Mutex/RWMutex,保护共享会话表;05.03 再加入 channel 和 context。进入下一章前,请能为一个虚构 IM 任务写出“谁启动、谁等待、谁收错误、谁关资源、谁能停止”五项合同。