Skip to content

test(timing): 时序注入补上"竞争者占着 CPU"这条轴 - #31

Merged
modusensus merged 3 commits into
mainfrom
test/worker-cpu-share-axis
Sep 21, 2026
Merged

modusensus merged 3 commits into
mainfrom
test/worker-cpu-share-axis

Conversation

@modusensus

@modusensus modusensus commented Sep 21, 2026

Copy link
Copy Markdown
Collaborator

守卫此前只有两条轴,而它们说的是同一件事:这个线程晚了。两条都只花墙钟——拉长等待
的那条把 worker 的 sleep/wait 变长,起不来的那条把线程体的第一行推后。而睡觉的线程
不占任何人的 CPU
,所以"这段预算里该算完多少活"这类断言(测试考的是 worker 算得多快,
不是"它什么时候跑到")从两条轴下面直接走过去,两条都不红。

PONTE_TEST_THREAD_CPU 补的就是这里:工作线程每次 sleep/wait 额外烧一段真的 CPU
(忙等,不释放 GIL)。

为什么不是一条 hog 线程

那是第一直觉,也是我上一轮实测后否决的做法,记录留在 tests/_injection.py 里:
一条 100% 忙等的 Python 线程把 pytest 的收集阶段从 0.43s 变成 30s(两条 68s)——收集
阶段就是成千上万次极小的 GIL 重新获取,每次最多赔上 sys.getswitchinterval()(5ms);
而它连本机制存在理由的那条测试都没弄红(那些 worker 在睡觉,睡觉会释放 GIL)。拿到的是
墙钟代价,不是覆盖。

按工作线程自己的活动成比例地烧,两个毛病一起消失:代价只和 worker 的 sleep/wait 次数
有关(收集阶段、测试主体、注入关闭时全是零),竞争也正好落在"号称在抢 CPU"的那两个线程
之间。

数字

套件里能被注入触及的 worker sleep/wait31 + 9 = 40 次(实测数出来的,不是估的)。
所以 CI 腿取 PONTE_TEST_THREAD_CPU=0.05 只值约 2 秒,而 0.05s 是默认 GIL 切换间隔的
十倍——每次烧 CPU 都确定会被抢占至少一次,要的是真竞争,不是一段更长的计算。

自检里那段按机器标定的 CPU 工作量(3 个竞争者、每档重复 2 次取最快):

配置 本机(3 轮) 相比基线
关闭注入 0.292 ~ 0.304s 1.00
只拉长等待(0.15s) 0.282 ~ 0.307s 0.98
烧 CPU(0.10s) 0.757 ~ 0.865s 2.59

关键是中间那一行:拉长等待对"能算多少活"几乎没有影响(0.98),这正是第三条轴存在的
理由。

CI 把它自己的判据打了回来(已修)

第一版判据取 1.8,结果 16 条腿里只有 Python 3.13 (ubuntu-latest) 红了:
quiet=0.146s delayed=0.131s starved=0.261s —— 比值 1.79,比判据差 1%。也就是说
那条轴是有效的(那台机器上主线程慢了 79%),卡住的是判据本身。

两个原因都在探针里:预算写成固定循环次数(1M),而快机器上 1M 只要 0.146s,装不下几个
竞争周期(那台机器约 1.4 个,本机约 2.9 个);且单次测量就是判据,而外部负载只会把时间
拖长。改成按时长标定 + 每档取最快一次之后,判据降到 1.3(实测最坏值的一半以下)。
这次修正作为独立提交保留,让这段考古留在历史里。

守卫自证(都做了反向对照)

  • 机制:用 time.thread_time() 断言这些 CPU 花在工作线程那次等待上,主线程那次
    一毫秒都不多花(类别差别,不随机器快慢漂移)。把 _burn 临时改成空实现:轴开着时必须
    红(已复现),轴关着时必须绿(已复现)——不是"永远红"那种假守卫。
  • 独有覆盖:上面那张表,来自子进程探针。同一个反向对照下比值落到 0.88,稳稳在
    判据之下——放宽容度不等于失去灵敏度。它在任何配置下都跑(子进程自带开关),
    所以验证的是机制本身,而不是"CI 那一步恰好开着"。

边界(写在 tests/_injection.py 里,不藏着)

它模拟的是"同进程里有别的线程占着 CPU"——GIL 竞争本来就是这件事。够不到从不
sleep/wait 的纯计算 worker,也够不到 OS 层面的慢(卡住的文件系统、冷 CPU)。这条腿绿
仍然不等于"已经没有时序假设了"。

验证

420 测试(新增 2)、覆盖率达到 fail_under、ruff / mypy(本机与 --platform linux)/
smoke 全过;按 CI 那条腿原样跑(三条轴同开、不加 -qset -o pipefail)三行横幅都在、
三条 grep 自检通过;env-edges 的本地近似(C locale + 半时区 + 弃用告警当错误)也全过。
CHANGELOG 没动:纯测试基础设施,与 #29 一致(那条腿本身也没进 CHANGELOG)。

modusensus and others added 2 commits September 21, 2026 12:39
前两条轴说的都是"线程晚了",而且都只花墙钟:拉长等待的线程在睡觉,不占谁的 CPU。
于是"这段预算里该算完多少活"这一类断言——即测试考的是工作线程**算得多快**,而不是
"它什么时候跑到"——从两条轴下面直接走过去,两条都不红。机器真的被别的活占住时不是
这样:CPU 被占,想用 CPU 的人就得等 GIL。

第三条轴就补这里:工作线程每次 sleep/wait 额外烧一段**真的** CPU(忙等,GIL 不释放),
于是 worker 自己的每一轮变慢,同时它握着 GIL 的时候别的线程要等
sys.getswitchinterval() 才拿得回来。

为什么不是一条 hog 线程(最直觉的做法):实测过,不用。一条 100% 忙等的 Python 线程
把 pytest 的**收集阶段**从 0.43s 变成 30s(两条 68s),因为收集阶段就是成千上万次极小的
GIL 重新获取,每次最多赔上 5ms;而它连本机制存在理由的那条测试都没能弄红(那些 worker
在睡觉,睡觉会释放 GIL)。也就是说 hog 拿到的是墙钟代价,不是覆盖。按工作线程自己的
活动成比例地烧,两个毛病一起消失:代价只和 worker 的 sleep/wait 次数有关(收集阶段、
测试主体、注入关闭时都是零),竞争也正好落在"号称在抢 CPU"的那两个线程之间。

守卫(tests/test_timing_guard.py)自证两件事,都做了反向对照:

- 机制:用 time.thread_time() 断言 CPU 花在**工作线程**那次等待上、主线程那次一毫秒
  都不多花(类别差别,不随机器快慢漂移)。把 _burn 临时改成空实现:轴开着时必须红
  (已复现),轴关着时必须绿(已复现)——所以它不是"永远红"那种假守卫。
- 独有覆盖:子进程里跑一段固定 CPU 工作量,比较三个基线(关闭 / 只拉长等待 / 烧 CPU)。
  实测 3 个竞争者 + 0.10s 时比值约 3.1、最坏一次 2.5,而"只拉长等待"与关闭几乎一样
  (约 1.0)——判据取 1.8,两边都留余量。把 _burn 禁掉后 starved=0.184s 对
  quiet=0.196s,这条自检当场红。这条自检在**任何**配置下都跑(子进程自带开关),
  所以它验证的是机制本身,而不是"CI 那一步恰好开着"。

如实说明边界:它模拟的是"同进程里有别的线程占着 CPU"(GIL 竞争本来就是这件事),
够不到从不 sleep/wait 的纯计算 worker,也够不到 OS 层面的慢(卡住的文件系统、冷 CPU)。

🤖 Generated with Codebuff
Co-Authored-By: Codebuff <noreply@codebuff.com>
那条腿此前只注入"等待被拉长"与"线程迟迟起不来",都是"线程晚了";这次把"有竞争者
在真的烧 CPU"也打开(PONTE_TEST_THREAD_CPU=0.05),于是断言"这段预算里该算完多少活"
的测试在那条腿上也会失败,而不是等到某台忙机器上偶发红。

0.05s 这个取值的理由:默认 GIL 切换间隔是 5ms,所以每次烧 CPU 都确定会被抢占至少一次
——要的是真的竞争,不是一段更长的计算。代价与工作线程自己的活动成比例,套件里能被它
触及的 worker sleep/wait 只有约 40 次(实测数出来的),合计约 2 秒,其余时间不烧。

文档同步说明三条轴的分工(README 中英、CONTRIBUTING 中英),包括为什么不是一条而是
三条:每条都够不到另两条,而"睡觉的线程不占 CPU"正是第三条存在的原因。

🤖 Generated with Codebuff
Co-Authored-By: Codebuff <noreply@codebuff.com>
Copilot AI lite review requested due to automatic review settings September 21, 2026 04:43

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

CI 抓到了:16 条腿里**只有** Python 3.13 (ubuntu-latest) 红了,报的是
quiet=0.146s delayed=0.131s starved=0.261s —— 比值 1.79,比判据 1.8 差了 1%。
也就是说这条轴有效(那台机器上主线程慢了 79%),是**我的判据**卡在了实测值上面。

两个原因,都在探针里:

- 预算写成了固定循环次数(1M),而快机器上 1M 只要 0.146s —— 装不下几个竞争周期
  (那台机器上约 1.4 个,本机约 2.9 个),效果被抹平。改成按机器标定的**时长**
  (PROBE_SECONDS=0.3,先跑一小段同样的循环估速度再定次数),于是"0.3 秒的活"在任何
  机器上都装得下同样多的竞争周期。本机标定结果:目标 0.3s,实测 0.292/0.282s,准。
- 单次测量就是判据,而外部负载只会把时间拖长。改成每档重复 2 次取最快的一次(最小
  值是一致估计,"机器正好忙"不会被算成"这条轴起了作用")。

判据从 1.8 降到 1.3,即**实测最坏值的一半以下**:本机 2.59(重复 3 轮:0.757~0.865
对 0.292~0.307,档内只有 ±4% 抖动),CI 那次 1.79,关掉注入约 1.0。这条自检自己也不能
变成"只有机器够快才通过"的断言,否则它就是在重犯它要防的错。

放宽容度不等于失去灵敏度,这一点有反向对照:把 _burn 临时改成空实现后,比值落到
0.88(quiet=0.253s delayed=0.303s starved=0.268s),稳稳落在 1.3 之下、当场红。

🤖 Generated with Codebuff
Co-Authored-By: Codebuff <noreply@codebuff.com>
Copilot AI review requested due to automatic review settings September 21, 2026 04:50

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@modusensus
modusensus merged commit 3b8b319 into main Sep 21, 2026
18 checks passed
@modusensus
modusensus deleted the test/worker-cpu-share-axis branch September 21, 2026 05:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants