旧版本上几百 KB 的输入要解析好几秒
主线位置:三处都在解析器里,两处在块主线的第一遍(
scan_containers和段落里的脚注探测),一处在行内主线的分隔符栈。pulldown-cmark 怎么系统地防超线性(下界、上限、dos-fuzzer)见设计取舍;本页只讲 0.13.5 补上的三处,以及用旧版本时该怎么办。
flowchart TB
in["不可信的 Markdown"] --> v{"版本"}
v -->|0.13.4 及更早| q["三类输入平方增长"]
v -->|0.13.5| l["线性"]
q --> s["1 MB 要几秒到几十秒"]
s --> up["cargo update 到 0.13.5"]
up --> l
l --> lim["仍然限制输入大小"]
实验 crate:exp-pulldown-cmark/pitb-quad(依赖 =0.13.5)和 pitb-quad134(依赖 =0.13.4),同一份 main.rs,只计 Parser::new_ext(..).count(),不生成 HTML。M2 Max、macOS 27、rustc 1.99.0,release。0.13.5 每项 5 次取最小;0.13.4 每项 3 次取最小,1 MB 的两项只跑 1 次。运行前后 uptime 的负载是 18.3 和 14.4(同机有其他实验),绝对时间偏慢,两个版本之间的倍数不受影响。原始输出在 pitb-quad/out.txt,脚本是 pitb-quad/run.sh。
现象
0.13.5 是从 branch_0.13 发的补丁版本,摘进了 main 上的 8 个 PR,其中 3 个修平方复杂度(怎么读这一卷)。三类输入都不需要什么特殊构造,只是某种结构重复很多次:
- 嵌套列表后接空行(PR #1148):一行里很多层列表标记,后面跟同样多的空行;放在引用块里(空行写成
>)也一样。不需要任何扩展。 - 只能开的
*和只能闭的_交替(PR #1155):一长行里反复出现这对分隔符。不需要任何扩展。 - 开了脚注时,很多行以
[^开头(PR #1136):要ENABLE_FOOTNOTES。
同一批输入在两个版本上的解析时间:
| 输入 | 字节 | 0.13.4 | 0.13.5 | 倍数 |
|---|---|---|---|---|
| 嵌套列表 + 空行 | 32,768 | 295.7 ms | 0.78 ms | 378 |
| 65,537 | 1,196.1 ms | 1.38 ms | 870 | |
| 131,072 | 4,932.9 ms | 3.09 ms | 1,599 | |
| 引用块里的同上 | 32,772 | 174.5 ms | 0.54 ms | 323 |
| 65,540 | 695.1 ms | 1.06 ms | 655 | |
| 131,076 | 2,758.1 ms | 2.32 ms | 1,189 | |
* 与 _ 交替 | 65,535 | 98.5 ms | 1.62 ms | 61 |
| 262,143 | 1,430.1 ms | 6.66 ms | 215 | |
| 1,048,575 | 25,807.5 ms | 27.2 ms | 947 | |
行首 [^,开脚注 | 65,535 | 16.5 ms | 1.65 ms | 10 |
| 262,140 | 235.1 ms | 6.43 ms | 37 | |
| 1,048,575 | 6,795.9 ms | 37.1 ms | 183 |
原始输出的前几行(每对里先是 0.13.4、后是 0.13.5):
2:02 up 3 days, 7:26, 8 users, load averages: 18.33 18.25 12.13
lists bytes= 32768 events= 43689 min_of_3= 295.743 ms
lists bytes= 32768 events= 43689 min_of_5= 0.783 ms
lists bytes= 65537 events= 87381 min_of_3= 1196.095 ms
lists bytes= 65537 events= 87381 min_of_5= 1.375 ms
lists bytes= 131072 events= 174761 min_of_3= 4932.905 ms
lists bytes= 131072 events= 174761 min_of_5= 3.085 ms
...
underscore bytes= 1048575 events=1048576 min_of_1= 25807.482 ms
underscore bytes= 1048575 events=1048576 min_of_5= 27.243 ms
...
- 0.13.4 上,长度翻倍,前两类时间约乘 4;长度乘 4,后两类时间乘 14 到 29。都是平方的样子。
- 0.13.5 上,长度翻倍,时间约翻倍。
- 两个版本的事件数完全相同,修复没有改变解析结果。
一个 128 KB 的嵌套列表在 0.13.4 上要 5 秒,1 MB 的 * / _ 行要 26 秒。解析是同步的 CPU 计算,期间这个线程什么也做不了。
原因
三处的机制分别在前面讲过,这里只列出是哪一步重复做了:
| PR | 哪里 | 0.13.4 每次重复做什么 | 0.13.5 怎么改 | 详见 |
|---|---|---|---|---|
| #1148 | 块主线 scan_containers | 每个空行都沿 spine 把所有打开的列表走一遍,N 层 × N 行 | 列表上记 LAST_LINE_EMPTY(ListFlags),连续第二个空行直接返回 | 容器块 |
| #1155 | 行内主线 InlineStack::set_lowerbound | 只能闭的 _ 没找到开分隔符时只抬高一个下界,下一个 _ 取到的仍是 0,整栈重扫 | 两个下界一起抬高,和 * 一致 | 强调与分隔符栈 |
| #1136 | 块主线 scan_paragraph_interrupt_no_table | 段落里每个 [^ 行都对剩余的整份输入做一次 UTF-8 校验 | 参数从 &[u8] 换成 &str,直接用文档本身,不再校验 | 链接与图片 |
三个 PR 都先合进 main,再由 a126502、6da8248、cf4ee5f 三个「cherry-pick pull request #NNNN commits into 0.13 branch」提交摘进 0.13 分支,首次发布于 0.13.5。每个 PR 还把对应的输入加进了 dos-fuzzer 的回归列表:用 0.13.4 跑那份列表,*_ 一项被判为超线性,退出码 1(设计取舍)。
更早的版本本卷没有测(离线缓存里只有 0.13.4 和 0.13.5)。从代码看:#1136 修的那段探测来自 GFM 风格脚注(PR #654),#1155 修的那个按长度分桶的下界来自 PR #748,两者都首次发布在 0.10.0;#1148 修的空行处理在 0.13.4 里就是逐层走 spine,引入时间本卷没有考证。
修法
升级到 0.13.5。 它和 0.13.4 同属 0.13,Cargo.toml 写的是 "0.13" 或 "0.13.4"(默认的 ^ 要求)时,cargo update 就会选到它:
$ cargo update --offline -p pulldown-cmark
Locking 1 package to highest compatible version
Updating pulldown-cmark v0.13.4 -> v0.13.5
(pitb-upgrade 里先用 --precise 0.13.4 锁回旧版再更新;--offline 只看本机缓存,联网时会选当时最新的 0.13.x。)两个版本之间 src/ 只改了 firstpass.rs 和 parse.rs,没有新增或删除公开项;另外 5 个 PR 修了两处 panic 和两处解析结果(怎么读这一卷列出了 8 个 PR 各改了什么),升级前可以对照一下自己用到的扩展。如果依赖写的是 =0.13.4 这样的精确版本,要先改 Cargo.toml。
不要只靠升级。 这三个是已知的、已修的;dos-fuzzer 只能找到它能拼出来的模式。解析不可信输入时:
- 限制输入大小。上面最坏的一项,0.13.4 在 32 KB 时是 0.3 秒、128 KB 时是 5 秒;平方增长下,上限压低一半,最坏时间降到四分之一。
- 解析放在不会挡住别人的地方。在异步服务里,大文档的解析放进专门的阻塞线程池,而不是直接在处理请求的任务里跑。
怎么检查
- 看锁定的版本:
cargo tree -i pulldown-cmark --depth 0,或在Cargo.lock里找name = "pulldown-cmark"。低于 0.13.5 并且会解析外来 Markdown(评论、上传的文档、第三方 README),就该升级。间接依赖也算:cargo tree -i会列出是谁把它带进来的。 - 在 CI 里放一道门: 照本页的做法,为这三类结构各生成 64 KB 和 256 KB 两个输入,断言两者的时间比不超过某个倍数。本页的数据里,从 64 KB 到 256 KB,0.13.4 的两类输入是 14.5 倍和 14.2 倍,0.13.5 是 4.1 倍和 3.9 倍,阈值取 8 能分开两者。门要反向验证一次:把依赖临时换回 0.13.4,确认它会红。上游的
dos-fuzzer --regressions做的就是这件事,覆盖 31 个已知模式,跑一遍不到一秒(设计取舍)。
小结
- 0.13.5 摘进了三个超线性修复:深嵌套列表后的空行(#1148)、只能闭的
_的下界(#1155)、开脚注时每行[^的 UTF-8 校验(#1136)。前两个不需要任何扩展。 - 实测同一批输入,0.13.4 平方增长:128 KB 的嵌套列表 4.9 秒,1 MB 的
*/_行 25.8 秒;0.13.5 线性,都在 40 毫秒以内,事件数相同。 - 解析不可信 Markdown 的程序升级到 0.13.5(
cargo update -p pulldown-cmark),同时限制输入大小、别在请求处理的任务里直接解析大文档。 - 用
cargo tree -i pulldown-cmark看版本;CI 里用两种长度的时间比做门,并用旧版本验证它会红。