给同一段 "One two one" 分别加纯 Python 算术循环和等待门闩,两种任务最终仍返回 {"one": 2, "two": 1}。在等待门闩的测试中,工作者已进入执行入口,主线程查询到 running,但工作者尚未完成;主线程释放事件之后,才读到 succeeded。两种执行过程都可能占用墙钟时间,却不是同一种资源压力。只把“这次花了多少毫秒”当作瓶颈结论,会把等待与计算混在一起。

第23 篇的受控检查点为超时定义了状态,第21 篇的 SQLite 仓储定义了提交规则。本篇只给既有顺序 app.run 和 SQLite 适配器加可选的同一工作负载钩子,不更改提交、查询和取消的业务结果;后续线程、进程和 asyncio 均需拿这套输入与计算路径对照。

两种成本不要合写成“执行慢”

tasklab.workloads.cpu_work(text, iterations=N) 在 Python 循环里逐字符更新校验和,输入可固定、迭代次数可控,返回的校验和只是负载观测值,不是任务的词频结果。run(..., workload=...) 在词频计算前调用钩子;不传参数时仍走以前的路径,回归测试断言两条路径输出一致。这里的 CPU 工作是纯 Python 操作,不涉及真正的文件等待或网络 I/O。

等待路径用 threading.Event:工作者在钩子中记录进入时间并设置 entered,等待主线程的 release;主线程只在收到 entered 后检查 running 与“尚未完成”,随后设置 release。wait(5) 和 join(5) 中的五秒是故障保护上限,正常路径不靠睡眠让线程碰巧停在某一行。如果保护上限真的触发,实验应失败,而不是默默报告成功。

perf_counter_ns 的原始时间戳只用于排序与计算本机这次调用的时间差:实录里 entered ≤ observed ≤ released ≤ ended,释放前读到 running。同一进程中 CPU 负载也记下两次测量范围:单独运行合成算术循环的耗时与附加它之后一次完整任务调用的耗时;后者并非“纯计算耗时”,还含词频和调用开销。本篇只有单次样本、一个输入和当前机器,不能凭这组值断言线程更快、async 更快或 GIL 无关。

1
2
3
cd examples/python-language
PYTHONPATH=src /usr/bin/python3.12 experiments/27_workloads.py
PYTHONPATH=src /usr/bin/python3.12 -m unittest discover -s tests -v

演示确实创建了一个受控线程以观察等待状态,但这不是第 29 篇的有界线程池,不能宣称已验证池内取消、shutdown 或并行加速;也还没跑进程与 asyncio。累计 74 项测试对照原有任务结果、运行中被事件阻塞的状态及负载故障的 failed 路径。原始时间戳、输入、环境和页面证据在 examples/python-language/evidence/27/20261007-workloads/。正式比较吞吐和内存要在 36 保留多次样本及测量范围。

预测题

事件尚未释放时,run 正处于等待钩子。调用方读取这个进程内任务的状态与结果,应该看到什么?能否推出 CPU 正在不断计算?

答案:测试读到 running,工作者尚未写成功结果,所以 result 还是 None;不能据此推出 CPU 正忙。控制事件说明它在等待 release,时间线证明的是停在检查点,不是资源利用率的测量。SQLite 版本在执行中持有写事务,其他连接的可见性要另测,不能用本章的进程内读取推广它。

变更题

不等 entered 就用 sleep(0.001) 猜测工作者已开始,再查询状态,为什么反例有时会通过、有时失败?怎样改回可断言的时序?

答案:调度、机器负载与解释器执行次序未被一毫秒睡眠固定,主线程可能先查询到 queued。用 entered 确认工作者确实进入钩子,再读 running;释放事件前检查 completed 尚未设置,之后 join 并确认没有遗留线程。CPU 模式还应在相同文本和迭代次数下与顺序基线比较结果,而非只比较一次耗时。

28 使用屏障固定两个工作者在“检查后、更新前”的交错,验证 GIL 不能替业务状态加锁。系列导航只指向已验收内容。

参考资料