目录 · 第 11 / 16 章
ActorPart IV · 流式 Markdown 解析器

11CommonMark 官方策略里的并行缝隙

规范附录 A:块级串行、行内可并行——Actor 拓扑的切缝规范已画好;语法子集与偏差表。

mdparser/block.go:feedLine

上一章说清楚了预算:AI 时代的渲染要的是首字时延,不是吞吐量。这一章要回答一个更扎实的问题——「边流边解析、块串行行内并行」这套野心,是我们自己发明出来的架构奇技,还是本就写在规范里,只是从来没人真的把它当回事?答案在 CommonMark 规范附录 A 里:两阶段解析策略,第一阶段必须串行,第二阶段可以并行,规范原文写得清清楚楚。Actor 拓扑该在哪一刀切开,不是我们拍脑袋定的,是规范替我们画好的。这一刀为什么必须切在这里、不能切在别处,才是本章真正要交代清楚的事。

附录 A:规范已经画好的两阶段

CommonMark 规范附录《A parsing strategy》几乎是为 Actor 化写的说明书。它把解析拆成两个阶段。

第一阶段只做一件事——逐行消费输入,只构建块结构,文本挂到块上但先不解析。一行文本进来,它对块树的影响只有三种可能:关闭若干个已经打开的块、在最深的打开块下面新开一个子块、把这行文本追加到最深的打开块上。判断完这三种可能之一,这一行的使命就完成了,可以扔掉——输入天然可以按流消费。这正是 mdparserfeedLine 在做的事:喂一行,吐一批块级事件,状态往前挪一格。

// feedLine 喂入一行(不含换行符),返回本行触发的块级事件。
//
// 返回的切片复用同一块底层数组:调用方须在下一次 feedLine/closeAll 前消费完
// (流水线各处都是拿到即遍历,满足此约定)——由此免去每行一次事件切片分配。
func (s *BlockState) feedLine(line string) []BlockEvent {
s.events = s.events[:0]
// 围栏代码优先:容器前缀仍成立时,整行要么是代码要么是闭合围栏。
if s.leaf != nil && s.leaf.kind == KindCodeBlock {
rest, matched := s.matchPrefix(line)
if matched == len(s.stack) {
if s.isClosingFence(rest) {
s.closeLeaf()
} else {
s.leaf.lines = append(s.leaf.lines, stripIndent(rest, s.leaf.fenceIndent))
}
return s.events
}
s.closeLeaf()
s.closeContainersFrom(matched)
s.continueLine(rest)
return s.events
}
rest, matched := s.matchPrefix(line)
if matched < len(s.stack) {
s.closeLeaf()
s.closeContainersFrom(matched)
}
s.continueLine(rest)
return s.events
}

这三种可能为什么必须按顺序判断,不能拆给几个线程各判几行?答案不在规范的文字里,在 mdparser 自己的实现里就能看见。feedLine 判断一行归宿的第一步是 matchPrefix——自底向上走一遍容器栈,看这一行能延续到栈的第几层;栈没被走穿的部分原样闭合,剩下的残句再交给 continueLine 去决定开不开新容器、算不算新叶子。问题就在这一步:matchPrefix 读的是当前这份容器栈——而这份栈,正是上一行处理完之后留下的产物。给第 5 行判断结果,前提是第 4 行已经把栈改成了它该有的样子;给第 4 行判断结果,前提是第 3 行……这是一条首尾相扣、没有缝隙可插的依赖链,和括号匹配是同一种结构:你不能对着一串括号从中间任挑一段并行去数嵌套深度,除非你已经知道走到这里时栈是几层。CommonMark 的块语法把「当前处于第几层引用块、第几层列表项」全部编码进了这个可变的栈,而栈的下一个状态严格是上一个状态的函数——这就是为什么第一阶段的「逐行顺序处理」不是规范挑剔,是块语法本身的形状决定的。

// continueLine 阶段二 + 三:先尽可能打开新容器,再做叶子归类。
func (s *BlockState) continueLine(rest string) {
// 阶段二:遍历容器规则连续打开("> - text" 这样的嵌套一行内建立)。
for {
if isBlankLine(rest) || isThematicBreakLine(rest) {
break // 分割线优先级高于列表标记
}
opened := false
for _, cr := range s.cfg.containerRules {
if r, ok := cr.Open(s, rest); ok {
rest = r
opened = true
break
}
}
if !opened {
break
}
}
// 悬空列表收尾:栈顶是 List 说明当前项已闭合。
if top := s.top(); top != nil && top.kind == KindList && !isBlankLine(rest) {
s.closeLeaf()
s.closeContainersFrom(len(s.stack) - 1)
}
// 阶段三:遍历叶子规则归类,段落兜底。
for _, lr := range s.cfg.leafRules {
if lr.Open(s, rest) {
return
}
}
s.cfg.paragraph.Open(s, rest)
}
// matchPrefix 阶段一:自底向上匹配容器栈前缀。
func (s *BlockState) matchPrefix(line string) (rest string, matched int) {
rest = line
for i := range s.stack {
r, ok := continueContainer(&s.stack[i], rest)
if !ok {
return rest, i
}
rest = r
matched = i + 1
}
return rest, matched
}

第二阶段做另一件事——把已经挂在段落、标题这些块上的原始文本,解析成行内结构(强调、链接、代码 span……)。这一步不看别的块一眼,只处理自己手里那坨文本。

这句「不看别的块一眼」不是修辞,是 mdparser 里可以验证的事实。一个叶子块闭合的那一刻,buildLeaf 把它此刻依赖的一切都冻结进一个值类型:原始文本、标题层级、围栏语言,以及此刻是否身处紧凑列表项(Tight 字段——从容器栈里现读现抄,烧录进 Leaf 就再也不必回头看栈一眼)。往后不管这个 Leaf 传到哪个 goroutine、被哪个 worker 领走,它都不再持有对那份可变容器栈的任何引用——两个已闭合的叶子之间不共享一个字节的可写状态,行内解析天然就是一堆互不相干的纯函数调用。这不是 Actor 架构从外面强加的性质,是规范那句「一个块的行内解析不影响任何其他块的行内解析」的字面兑现。连一向以单线程 pull 迭代器自居的 pulldown-cmark 内部都绕不开这个形状——它的 firstpass 先把块结构建完,行内解析消费的是这棵已经定稿的块树;只是它的架构里从来没有一个可以安全并行的缝,这份天然的并行性就一直被闲置到今天。

第一阶段 = 一个必须串行的、行驱动的状态机 → 一个有状态 Actor(DocActor)
第二阶段 = 天然并行的无状态纯计算 → 一池无状态 Actor(InlineWorker × N)
两阶段之间 = 「块闭合」事件 → 一条消息(leafJob{seq, leaf})

规范用一句话把这两件事焊在了一起——“the inline parsing of one block element does not affect the inline parsing of any other”,一个块的行内解析不影响任何其他块的行内解析(CommonMark Spec, Appendix A: A parsing strategy)。第一阶段必须按行序处理,第二阶段却可以并行,原因就是这句话:块与块的行内解析互不相干,谁先算完、谁算得快都不影响最终结果。这句话几乎就是一份 Actor 拓扑设计书。

flowchart LR
  subgraph s1["阶段一 · 块结构<br/>串行 · 可流式 · 行处理完即丢弃"]
    doc["DocActor<br/>(有状态)"]
  end
  subgraph s2["阶段二 · 行内解析<br/>无状态 · 可并行"]
    w1["InlineWorker"]
    w2["InlineWorker"]
    w3["InlineWorker × N"]
  end
  doc -->|"块闭合事件 leafJob(seq, leaf)"| w1
  doc -->|"leafJob(seq, leaf)"| w2
  doc -->|"leafJob(seq, leaf)"| w3

CommonMark 两阶段策略:串行块结构 → 并行行内解析

🔑 设计钥匙

Actor 拓扑要在哪里切一刀,不是我们发明的——规范附录 A 已经把「块结构串行、行内解析并行」写死在纸面上了,我们只是第一次真的把这道缝当回事,用了起来。但规范同一段还埋了一根引线:链接引用表要等第一阶段结束才凑齐,第二阶段却要用它。这根引线才是本章语法子集取舍的真正源头——不是我们任性地少实现了几个语法点,而是「块一闭合就能并行解析行内」这句承诺,一旦引入引用式链接就不再严格成立。

同一段里埋着的同步屏障

规范同一段还写了另一句话,容易被跳过,却是诚实设计绕不开的坑:第二阶段要用到的链接引用表(link reference map),只有在第一阶段结束时才完整——因为 [bar]: /url 这样的定义可以出现在文档的最后一行。也就是说,「块一闭合就能并行解析行内」这个说法,在支持引用式链接 [foo][bar] 的前提下并不严格成立:解析到文档中间的 [foo][bar] 时,还不知道 bar 到底指向哪里。

📝 注意

这里容易混淆两件不同的事。Assembler 要解决的是顺序问题——worker 算完的先后可能是乱的,靠 seq 重排回文档序即可兜住,下一章会细讲。链接引用表要解决的是语义问题——不是「谁先算完」,而是「算的那一刻压根不知道答案」:[bar] 指向哪里,在文档中段本来就是未知数,不是排序能修补的。顺序问题可以整个丢给运行时层解决;语义完整性必须在解析器设计阶段就做出取舍——这正是下面三条对策存在的原因。

对策不止一种:

对策做法代价
子集限制(本文选择)只支持内联式 [text](url),不支持引用式语法子集变小,流水线保持完全流式
暂定 + 补丁引用未定义时先按字面渲染,定义出现后向下游发「修订片段」消息下游要支持片段替换,事件协议变复杂
延迟该块含引用链接的块推迟到 EOF 再解析破坏「块闭合即定稿」的流式承诺

mdparser 选的是第一档——牺牲语法覆盖率,换回流水线的完全流式。这不是偷懒,是诚实的设计取舍:与其在事件协议里塞一个「修订片段」的口子,不如先把子集边界画清楚。

子集有多小,偏差表说清楚

mdparser 刻意实现一个小而完整的子集——每条规则都有黄金样例锁定(parser_test.go 里的 TestGoldenSequential),流式与批量两条路径的输出逐字节一致。

支持的部分:ATX 标题(含闭合序列)、段落、围栏代码(``` / ~~~、info string、EOF 未闭合自动收口)、引用块(可嵌套)、无序/有序列表(紧凑、可嵌套于引用块)、主题分割线(优先级高于列表标记)、行内代码、强调/加粗(简化 flanking)、内联链接(递归行内内容、括号配对)、反斜杠转义、HTML 实体转义——覆盖了日常写作和 LLM 输出里最常见的那层语法。

明确不支持的部分,逐条写进偏差表,而不是悄悄丢在某个 if 分支的注释里:

特性CommonMark 行为本实现原因
Setext 标题Title\n===<h1>按两个块处理需要回看已开段落,留作练习 1
惰性延续> a\nbb 延续引用块引用块提前闭合需要「无法开新块则延续段落」判定,练习 3
引用式链接[foo][bar] + 定义表不支持本章前文的同步屏障,练习 5
图片![alt](src)按字面与链接高度同构,练习 2
松散列表空行分隔的项包 <p>一律紧凑渲染松散判定需要跨行前瞻
缩进代码块 / HTML 块 / 硬换行 / Tab不支持控制子集规模
完整 flanking 规则乘 3 规则、_ 词内限制只看紧邻空白行内不是本文重点

偏差表里最值得多看一眼的是前两行,因为它们踩的是同一颗雷:回看。Setext 标题——单独一行 Title,要等下一行是 === 才升级成 <h1>——要求解析器在看到 === 的那一刻回头改写已经发出的段落,这和第一阶段「一行处理完就可以扔」的不变式正面冲突:如果那个段落已经闭合、当作定稿块发给了行内解析,再回头把它改判成标题,等于要撤回一条已经发出去的消息。惰性延续——> a 后面接一行没有 > 前缀的 b,规范要求 b 仍然算引用块段落的延续——同样要一种前瞻:matchPrefix 匹配失败不能立刻闭合容器,还得多问一句「这一行本来是不是可以延续一个已打开的段落」。这两处都不是实现偷懒,是「处理完即丢」这条流式承诺的直接代价——想要它们,就得给状态机留一层它刻意没留的回看窗口。

这种「Markdown 的语法本身就抗拒被塞进一个干净、前瞻有限的状态机」的直觉,并不是 mdparser 一家的经验。tree-sitter-markdown(自报数据,未独立核实)把 Markdown 拆成两套 tree-sitter 语法——块一套、行内一套,先跑块语法再跑行内语法,结构上与 CommonMark 附录 A 的两阶段遥相呼应。但项目维护者自己在文档里承认:把 Markdown 这种复杂格式硬塞进 tree-sitter 相当克制的语法规则里,输出仍然会留下不少不准确的地方,并明确建议不要在看重正确性的场景使用这个解析器——它的定位只是给 neovim、helix 这类编辑器提供语法高亮信息。连专门为这个问题写了一整套形式化语法的团队都拿不到完全准确的结果,这从侧面印证了本文偏差表存在的理由:Markdown 对一切「框架化」解析都不友好。能做的不是假装子集边界不存在,而是把它写在明处,再逐条决定哪一处真正值得手写例外去补。

本文的实验对象是架构,不是语法覆盖率。砍语法要砍得明明白白(上表就是账本),但架构性质——增量、并行、自愈、背压——必须完整。反过来的取舍(语法全、架构糊)就是第一章里那些渲染层补丁。

延伸阅读

  • CommonMark Spec, Appendix A: A parsing strategy——两阶段策略的源头:第一阶段构建块结构、天然可流式(行处理完即可丢弃);第二阶段解析行内、可并行,因为一个块的行内解析不影响任何其他块的行内解析(原句见上文引用);链接引用表要到第一阶段结束才完整,这正是上文同步屏障一节的出处。
  • pulldown-cmark ——设计文档《Block parsing》:firstpass 先建块结构,行内解析消费的是已经建好的块树;即便是单线程 pull 式接口,内部依然是先块后行内的两阶段。
  • tree-sitter-markdown(自报数据,未独立核实):两套 tree-sitter 语法分别处理块与行内,但维护者坦承 Markdown 的上下文敏感性与语法形式化能力之间存在摩擦,输出仍有不准确之处,并明确把它定位为编辑器语法高亮工具而非正确性敏感场景。

小结

  • CommonMark 规范附录 A 亲手写下了两阶段策略:第一阶段逐行串行、行处理完即可丢弃;第二阶段按块并行,因为一个块的行内解析不影响任何其他块的行内解析。这不只是规范纸面上的一句话——mdparsermatchPrefix/continueLine 依赖的可变容器栈解释了第一阶段为什么必须串行,闭合时冻结成值类型的 Leaf 解释了第二阶段为什么天然并行。Actor 拓扑的切缝不是我们发明的,是规范画好等我们来用的。
  • 同一段规范里还埋了一根引线:链接引用表要等第一阶段结束才完整,这与「块闭合即可并行」的承诺冲突。三条对策里,mdparser 选了最诚实也最克制的一条——缩小语法子集,换回流水线的完全流式。
  • 子集刻意做得小而完整,黄金样例锁定输出;偏差表把每一处砍掉的语法和理由都摆在明处——setext、惰性延续都在为「回看」买单,tree-sitter-markdown 的经验从旁佐证了这不是我们一家的偏执。
  • 缝已经画好,子集边界也已经钉死。下一章把这道缝真正焊成拓扑:DocActor 这颗串行心脏、InlineWorker 并行池、Assembler 重排序器,以及串起它们的消息协议。
源码

正在读取完整文件…