定时器 Stop 返回 false,不代表通道里没有东西

一、先写成三态四格,而不是两态
那篇讲语言细节的文章把一件很窄的事说透了:某语言标准库的定时器不是“开着”和“关了”两态,而是未到、已到期、已取消三态;而“已到期”和“已取消”各自还要再回答一个问题——通道里有没有一条待读的值。两个问题乘出四格,四格都填上,这个对象的语义才算定义清楚。只写两态的团队,后面每一处分支都在猜。
按来源页的描述,这四格是这样的:
| 状态 | 通道里有没有待读值 | 依据 |
|---|---|---|
| 尚未到期,也没有被停止 | 没有 | 到期时才会向通道发一个时间值 |
| 已到期 | 有;此时读取立即返回,不阻塞 | 到时即进入到期状态 |
| 到期之前停止成功 | 没有 | 停止返回真,表示取消成功且通道为空 |
| 已到期或已停止之后再停止 | 不确定,可能仍留着一条 | 停止返回假只说明“没停成功”,不说明通道是空的 |
第四格是整个语义里最容易读错的一格,因为返回值是假的场景有两种,而这两种在通道内容上并不一致。这也是为什么文章强调:停止与接收可以并发调用,但逻辑分支必须依据返回值本身的语义来走,不能凭“我调过停止了”去推断通道状态。
还有一格容易忽略:对象到期后进入终态,不可复用,此后停止方法一律返回假。不复用这件事不是保守,是必要——如果允许把同一个对象重新投用,上一轮那条迟到的值就会在新一轮里被当成触发信号,形成一种极难复现的错序:任务看起来被提前唤醒了,唤醒它的却是上一轮的残留。这类缺陷在日志里的表现是“偶发一次顺序不对”,几乎不会被归因到定时器上。
二、Stop 返回假要怎么读:两种情况都得处理
Stop 返回真是一个确定结论:到期之前取消成功,通道为空,后面不会有任何东西被投递。
Stop 返回假是一个不确定结论,它至少涵盖两种事实:已经到期了(通道里大概率有一条待读值),或者已经被停止过(通道本来就该是空的)。这两个分支的处理动作不一样,但都在“假”这一个返回值里。所以正确的读法不是“假就走清理分支”,而是“假之后先做一次残值判断”——问自己:这个对象我允许它被停止两次吗?如果允许,那第二次停止时通道是空的,非阻塞读会走到默认分支;如果不允许,那第二次停止意味着状态机已经乱了,要按错误处理。
这一段在实际代码里的形态很短,但它决定了后面所有“等不到的分支”。我们审这类代码时有一条固定问法:这段逻辑拿到假的返回值之后,做了什么假设?假设“没有残值”的地方,就是泄漏的入口。
三、残值必须显式清空:标准写法与错误写法
停止操作不会关闭通道,也不会替你消费那条残值。这是整篇文章最实在的一句。它的后果是一条链条:残值留在通道里,那个正阻塞在通道上等待触发的协程永远醒不来,也永远退不出;它持有的资源因而不释放。这就是“协程泄漏”在这类问题上的实际形态——不是内存瞬间涨上去,而是阻塞协程数一点点往上爬。
标准写法只有一次动作:用带默认分支的非阻塞读取清一遍。读到就丢弃,读不到说明通道本来是空的,两条路都安全结束。
错误写法是把清理写成一次带超时的阻塞读取。看起来更稳妥,实际是把清理逻辑变成了又一次等待:等待期间它仍然占着一个协程,而这一次等待的时间由你自己设定的超时决定,不由事件决定。清理路径必须是常数时间就能走完的路径,任何“等它一下”都违背这个要求。
另一种错误写法是根本不读,只在心里假定“停止之后一切归零”。来源里明确说了停止不关闭通道、也不消费残值,所以这个假定没有依据。这一层只保留一条可执行判据:谁创建了这个定时器对象,谁就负责在停止之后做一次非阻塞清读,读完才算这一轮结束。
四、一次性定时器的边界,和那个更深的问题
对比用的一次性定时器函数返回一个只读通道,无法停止。它不是“简化版”,而是适用面不同:简单的一次性超时用它没问题;一旦这个超时需要被取消,或者这个对象需要被复用,就必须改用可取消的那一类。判断标准很硬——只要存在“结果已经不需要了,但计算还在跑”的可能,一次性形态就不该选。
这就带出更深的一层:超时取消之后,原来的工作要不要停?很多系统只做到了“不再等结果”,没做到“释放资源”。取消通知没有沿调用链传下去,底层那次读取、那次远程调用照样跑完,只是没人收它的结果。单次看没有任何异常,长期跑下来积累的是并发在跑的孤儿任务,直到某个连接数或文件描述符把进程顶住。对应的做法是把取消信号贯穿到最里层的输入输出:每一层都能读到它,并在读写之前检查一次。这件事和定时器语义无关,但它是在讨论定时器取消时最容易被“取消已经返回了”糊过去的一段。
五、四种误用形态与各自的识别方法
- 把假返回值当成“已停止且无残值”。识别方法:搜索代码里对停止结果的处理分支,凡是“假则直接返回”的,问一句“这里通道里的东西归谁”,答不上来即命中。
- 在循环里重建同一个定时器。识别方法:看每一轮是否新建一个对象却只停止不读取;这种情况下每一轮都可能留下一条残值,阻塞协程数会按轮次增长。
- 用超时通道代替状态判断。识别方法:分支条件里只出现“读到了什么”,从不出现“对象处于哪个状态”,说明状态是靠事件倒推的;一旦两个来源共用同一分支就会错。
- 忘记区分首次超时与二次通知。识别方法:把日志里的触发计数和任务执行计数放在一起比,两者不等即命中,常见于重试与超时共用一个通道。
四种形态的共同点是它们都能通过功能测试,只在长时间运行后暴露。所以这一类代码的复查不能只看单次行为,要看计数关系。
六、测试怎么写,以及这套读法怎么推广
测试的最小设计是把间隔调到很短,并注入两次触发,然后断言三件事:取消之后没有任何动作发生;到期之后动作恰好执行一次;残值被清空。第三条需要测试代码在断言前自己做一次非阻塞读,并把“读到了东西”作为失败。并发场景再用带竞态检测的运行方式跑一遍,这一遍专门抓停止与接收同时发生时的分支错误。
推广到其他异步句柄时,这套读法变成四个固定问题,任何一个句柄都适用——Promise、异步任务、消息消费者都一样:
- 它能不能被取消。
- 取消之后它是否仍然可能完成。
- 完成值由谁负责消费。
- 未被消费的残留会不会泄漏。
四问答不上第 3 与第 4 个的封装,就是把状态判断推给了调用方。因此我们自己封装定时能力时有一条设计要求:在接口注释里把三态四格与残值归属写清楚,写明“停止不消费残值、调用方需做一次非阻塞清读”,不要让用户去猜。这一条写进注释的成本是一次,不写的成本是每一个调用点各猜一次。
上线之后要看的是三个观测点:阻塞在某类通道上的协程数、等待超时的次数、同一业务键的重复触发计数。第一个是这一类泄漏的直接读数,第二个用来判断超时是否在被当成流程控制使用,第三个用来抓第五节最后那条误用。洹拓在给客户做长跑服务的巡检时,把这三项放在同一张图上,因为它们要一起看才有意义——只有阻塞协程数在涨、而超时次数没变,才是残值这一类问题;两者一起涨,是下游变慢了。