取消按钮为何失效:控制通道与所有权
一句话总结
记录停止意图,与唤醒在内核中沉睡的循环,是两回事。先记录状态,再通过控制套接字通知,然后由拥有套接字的循环来做清理。
为什么需要它
诊断界面上加了一个取消按钮。另一个线程写入了 stop=True,但循环仍在 select 中等待。用户觉得按钮没有反应,于是按了很多次。内存里的值虽然已经改变,但如果循环无法执行下一行,就没有机会看到那个值。把 select 的等待时间改得很短,响应会变快,但即使处于空闲状态,也要反复地被唤醒。
这时需要的是“状态和通知”两个部分。状态保存“已请求取消”这一事实,通知则让循环有机会去读取这一事实。不能仅凭收到了通知,就判断取消已经处理完毕或所有资源都已回收。把请求、观察、清理完成分开来思考,UI 与后端的边界也会变得清晰。
工作原理
threading.Event 保存一个标志,并通知正在用 Event.wait 等待的线程。它并不是会自动向正在等待套接字就绪的 selector 中放入事件的装置。实验把 socketpair 的读端注册到 selector 中,把写端留给请求方。这样就能在同一次等待中同时观察数据连接的完成和控制通知。
python3 /opt/fixtures/reactor/wakeup_probe.py
实验会准备一个进入 select(2) 的线程,并设置 Event。在短暂的观察期间,线程不会结束。接着向控制套接字发送一个字节 Q,就会产生读就绪,循环被唤醒,并确认已经设置好的 Event。这个观察时间并不是生产环境中保证的延迟,而是用来构造反例的条件:仅靠状态变化,套接字等待是不会被唤醒的。
必须先写状态
如果先发出通知、之后才设置 Event,循环可能在这期间被唤醒。如果它判断为“还没有停止”并重新睡去,之后就可能不再有新的通知。因此 request_stop 的顺序是先 Event.set,再 writer.send。控制套接字的两端都要设为非阻塞。如果 UI 一侧的取消请求要一直停到发送缓冲区空出来,控制通道就会成为新的瓶颈。
如果缓冲区已满而出现 BlockingIOError,就说明已经有可读的数据了。停止状态保存在 Event 中,所以不会无休止地重试同样的通知。这是一种允许多个请求合并为一次唤醒的设计。它不同于把一个字节与一个用户请求一一对应的命令队列。如果是像订单、取消 ID 那样必须处理每一条消息的通道,就需要另外的队列和帧格式。
drain 最多只读取 4096 字节一次。如果为了清空通知而无限制地读取,那么大量发出通知的线程就可能让数据处理陷入饥饿。剩下的通知留到下一轮处理。表示空状态的 EAGAIN 与表示通道结束的 EOF 也不一样。EAGAIN 表示现在没有可读的内容,EOF 表示控制连接的对端已经关闭。实验把意外的 EOF 当作 ConnectionError 处理,并回收全部资源。
区分通知次数与处理完成
即使用户按了三次取消,终止处理一次也就足够了。在响应界面上显示取消完成时,要确认的不是发送了多少字节,而是 run 是否已经结束。反过来,如果等待完成通知的线程先关闭了控制 writer,就会产生 EOF,从而进入与正常取消不同的异常路径。如果画出一条简短的时间线,标明停止按钮、请求线程和循环之间以什么顺序移交所有权,就比较容易发现这种竞争。
谁来 close
请求线程不会擅自关闭数据套接字。循环必须同时管理这些套接字的注册状态和就绪队列。取消请求方只使用 Event 和控制 writer,循环则把未完成的 Dial 改为 cancelled,然后执行取消注册和 close。遵守 selectors 文档 中先取消注册、再关闭的顺序。
这次的 API 中,一旦调用 run,就会把有效输入的套接字和 Control 交给它,在返回或抛出异常之后不再使用。如果在执行之前就拒绝了无效参数,所有权仍然属于调用方。run 结束之后,不允许再次调用 request_stop。与真实界面对接时,需要一份生命周期约定:共享完成状态,并且在请求方结束之前不释放通道。
在现场相遇的样子
如果运维工具收到了终止请求却没有停下,用户就会强制终止进程。在需要保存数据的服务器上,还需要拒绝新请求、排空进行中的请求,以及在超时后强制终止。这次实现的是连接诊断工具的即时取消。不能说成实现了服务器完整的 graceful shutdown。
下一项检查要做什么
在接下来的测验中,要判断状态与通知的顺序。在之后的综合实验中,创建 Control 之后,要从另一个线程唤醒已经进入真实内核等待的循环。请区分两种错误答案:只修改停止标志的,以及把控制套接字保持为阻塞的。最终判定的依据不是“按下了按钮”的日志,而是未完成结果的取消分类,以及所有 FD 的回收。