自动分析器
4.1分析
分析器的任务就是将扫描器产生的Token流分析后生成分析树,然后交给联编器进行语义检查等。对于分析树的结构,.NET有CodeDom,但是我们在分析时并没有语义信息,无法对应CodeDom中的类。因此只能使用自定义的类。
之后最重要的问题就是使用什么分析方法。常见的就是LL和LR。LL分析法就是由上而下,遇到规则调用就嵌套函数调用,这就造成了对于左递归无法处理的问题:由于与规则对应的函数只有在匹配结束或不匹配时才返回,如果形成左递归则一直调用下去,既没有不配产生,也没有匹配结束,从而造成调用栈溢出。但LL在理解上更简单,实现也更容易。并且语法规则的书写也更接近于自然语言的语言习惯。LR分析法突破了左递归的限制,但由于对于复杂的语言其状态数偏多,造成空间占用较大。由于LALR(1)分析器构造工具YACC的存在,使得构造LALR(1)分析器更加容易,虽然其对于EBNF语法的要求比较多。仅这一点就与我们的初衷相背离——我们要降低门槛,从而要求对语法要求尽量地少。
我们要干的就是构造一个LL分析器,虽然可能对于左递归问题依然找不到解决方案,但它的简单性依然值得使用。首先,我们来分析一下我们要构造的LL分析器会遇到什么问题。先从简单的开始,空产生式。空产生式的问题在于,产生规则调用之后,由于一个待匹配的规则都没有或有但不匹配,这样,无效的消耗掉一个Token导致分析过程可能无法正常进行下去。不允许空产生式?我们的目标是尽量解决这些问题,使其对用户是透明的。空产生式展开?或许这会使整个路线图被打乱,而且或许用户的目的就是这样(原因有很多,比如看起来方便,语义更明确)。这样我们要解决它就需要一个能够将Token重新传回给其父级的方法。接下来是无用产生式,这个东西没有被任何规则调用,所以成为无用产生式。其副作用就是占用了空间。但是这是针对我们来说的,无用产生式的存在与否,对于使用这个工具来编写编译器的用户来说是透明的,也就是说我们可以先放着它,等待有机会再进行消除工作,而用户则根本感觉不到。第三个问题就是公共前缀。其它的分析其构造工具处于分析方法或速度的要求,不允许EBNF代码中出现拥有公共前缀的规则。因为公共前缀会使分支预测机制失去作用或产生错误结果。我们当然可以通过一个对用户透明的机制去处理这个问题,但是消除公共前缀就要改变原来的语法结构,而用户需要的就是完全按照语法而产生的分析树。而且这个问题对于对此有一定了解的用户而言,可以通过手工重写语法来回避此问题。于是我们的方案使用了允许公共前缀的方案。这也就意味着我们可能同时要处理好几个分支。编程人员很容易想到多线程,是的,使用多线程可以轻而易举的解决这个问题。但是由于分支预测的结果的多分支特性,如果其发生在非常常用的规则上,比如表达式,就将产生大量无用的线程。因为不管产生多少个分支,最终只能有一个匹配到结束,否则语法有二义性。这里我们采用了手动控制模拟线程执行的方式来处理,这样做的缺点就是太复杂,要处理的问题不仅仅是模拟并行执行,还要同步、调用、返回……由于时间问题,使用OS提供的真正线程的版本并没有进行试验。
4.2 VMT

下面来说一下具体实现中的一些问题。
首先,空产生式。当一个规则全部图元都是可选项时可能产生空产生式。对于最后一项为可选项的规则而言,最后一个Token并不匹配,但是并不能简单的将其抛弃,因为可能在父级,在匹配了这个规则之后可能还有其它的待匹配项,这个Token既然不匹配本级规则,应该交由父级处理。如果父级认为依然不匹配,那么由父级进行出错处理。如果规则中没有可选项,但然不存在空产生式问题。
至于无用产生式,由于永远都调用不到,所以除了空间,没有其它副作用。在后续的实现中我们将在C3编译期处理无用产生式,并将其剔除。
公共前缀。这是要解决的主要问题。公共前缀意味着同一个Token流到某处时可能有多个分支产生了合法匹配——因为他们都一样,这也就是为什么我们费大力气制造出VMT的原因。让多个分支并行运行,早晚有N-1个分支不匹配(当然,如果有两个分支返回,则意味着语法存在二义性),最后剩下一个匹配分支返回或一个都不匹配然后向父级报告错误。
其间,Token流中的每一个Token就以类似Windows中的消息传递一样,由上而下(通过Dispatch函数)最后传到线程的Fix函数那里进行匹配处理;而Fix产生的一些结果就又通过类似Windows中API调用的方式返回Process甚至GS。进行规则调用时,Fix函数产生一个本线程的副本,然后让其等待被调用的线程,而自己继续进行下面的匹配工作。在返回时,依然创建副本,让副本返回,自己继续等待(其实严格的说只要返回两个以上就算语法有二义性)。
4.3测试
为了简单,我在1.1版以前没有使用分支预测。在第一次调试通过后,我测试了一下它的速度。详细结果见程序测试报告1。下面是简要的结果(10行代码):
Parser run time:0.383102451451123
Scanner run time:0.0231484591495246
整个程序中扫描器运行0.023秒,分析器运行0.38秒。对于只有10行代码的程序来说这个速度是非常慢的,这样就需要进行分析预测。
分支预测,需要计算First集合及Follow集合。经过一段时间的开发,构造了Prediction这个命名空间及相应的类来处理分支预测的一些信息。而First集和Follow集的计算相关书籍上都有详细的算法说明,这里不再详述。
跟之前不同的地方就是由并行同步运行改为异步运行。虽然看起来很好,但我很快发现了问题:异步运行虽然实现起来简单但要想加入错误处理却很难确定错误出在那里,何时出错应该报告(因为按我的方案是多路执行,一路出错并不一定分析有错并加入最终的错误报告)。所以最终我还是决定放弃异步运行方式。
在重新起用了同步方式运行,并对程序进行了再一次的重写之后,经过测试,同样的代码,详细结果见分析器测试报告2,下面是简要结果:
Parser run time: 0.0462969182990491
Scanner run time:0.0115738657768816
结果分析:分析器运行时间只有0.0463秒,相对于1.1已经有了一个数量级的速度提升。如果它是线性增长的,则这样的速度是可以忍受的。可见分支预测还是非常必要的。
分析器由于只使用到扫描器的一个函数:GetToken,所以从1.0开始基本的架构就基本上不用什么改变,只是代码中命名空间的改变。从这里也可以强烈的感觉到一个优秀的设计对于后期的开发工作的重要性——从1.0版开始一直使用的VMT框架,基本上什么都不用改就可以配合一个全新的扫描器工作。代码的默认实现中并没有使用手工的内存池,因为经过测试,.NET的内存管理(内部内存池)要快得多。详细信息见后面的“速度和优化测试”。
初步调试成功之后发现非常慢,对于代码长度而言,时间复杂度接近于O(2^n)。由于手工的内存池管理更慢,所以这个问题肯定不是由于Process和Thread的频繁构造/析构造成的。之后,发现由于VMT并行执行,所以在线程Fork需要复制自身的子分析树,考虑是否由此引起。检查,子分析树缓冲使用Collection类,由于此类内部维护操作比较多,所以比较慢,经分析代码中使用的是ArrayList的功能,故改为ArrayList。再次测试,结果出乎意料,时间复杂度降为O(n),也就是说上面的结果完全是由于Collection类的使用造成的。遂将所有不必要使用Collection的地方全部改为其他的集合类,速度略有提升。
默认的内存管理和手动的内存池 先抛开我所设计的内存池的效率问题,手动控制由于使用了IL代码,效率上赶不上.NET运行环境的本机代码。同时说明内存池机制确实起作用,能使代码的运行效率提高。代码中有预编译选项,控制使用手工机制还是默认机制。 Grammar类的规则速查表 在Grammar类中使用一个HashTable来保存分析器的所有规则,这样由于分析器中对于规则的调用还是比较频繁的,查找规则就要快得多。经试验有7%左右的速度提升。 SymbolOperator的匹配预测 符号类型的操作符由于出现几率问题,大约有一半左右是不匹配状态,如果能直接跳过正则表达式的匹配将使时间缩短。经试验使扫描器有了10%的提升。 Thread的无效分支 由于VMT使用多路并行执行,不一定要求语法在分支预测上必须只有一个结果,所以导致Thread的无效分支。无效分支将使Thread将自己Clone一份,从而占用大量时间。经过计数测试,90%的Thread为无效分支。这是一个待优化的地方。后经过对线程的调用和返回进行了优化处理,降低了无效线程的产生,使得在测试状况下无效分支的数目降低到60%,也就是说平均一个规则有两个分支(运行时概率统计)。 GetChar(len as Integer) as String函数 来的实现是使用GetChar() as Char来实现,只要考虑的问题是行列计数。重新设计使用专有算法,计数换行符。速度略有提升。 经过优化后的结果见分析器测试报告3。 在调试过程中发现了几个有趣的问题(使用的语法文件为B-.txt): 1. MethodCallStmt被包含于SimpleStmt,产生2义性匹配,结果是MethodCallStmt被我注释掉了。 2. 在处理完全前缀时Terminal/Token型的元素必须放在Reference型的元素的后边。否则分支调用产生之前Terminal/Token型调用就已经把当前Token添加进缓冲区了,这样分支调用产生时已经多了一个Token在分支线程的缓冲区里。这个问题应该放进C3的语法说明文档里。 |
到现在为止的唯一问题也是急需要解决的问题就是无用线程的问题,无用线程的数量高于预期。我开始的时候预测应该在1:3~4(有用线程数:无用线程数)左右,但实际上这个比例高达1:9,从而如何改进它成为了一个新的问题,否则的话不仅占用内存,还占用了CPU时间来创建和销毁这些无用线程,还有运行时间。
完成了基本的框架之后,我们要向其中添加错误处理的代码——显然,不管编译过程中出了什么样的错误,分析器只是简单的报告“出错了”显然远远不够,至少我们需要一个比较具体的出错信息——好让程序员知道那里错了。前面也说过,已经在语法文件中生成了出错信息,但实际上这些信息都是非常简单的,比如“不匹配”之类。但这在很多情况下已经很不错了,总比没有好。至于错误恢复,先将它放在一边,先来看一看VMT如何报告错误。首先,有线程不匹配了,然后——返回不匹配信息到进程,然后——我发现了一个问题,那就是进程在收到其下的线程的返回时,并不管它到底是匹配返回还是不匹配返回。加入判断代码。这样只有在进程的最后一个线程返回的时候,线程才生成错误报告,并经由进程交给GrammarService,GS检查当前处于活动状态的线程,如果有则简单的放弃处理这个错误,并杀死进程,然后向调用方发送消息“不匹配”。而如果这是最后一个线程,显然GS不能够坐视不理了——已经到了没有办法继续分析下去的地步,GS需要做点什么来解决这个问题,这就是错误处理。由于不考虑错误恢复问题,我们只是简单的返回错误然后结束分析过程——对于试验项目来说这已经足够了。但是代码中却不能够“这样就够了”,需要留好接口,以便需要错误恢复的时候可以方便的添加代码实现错误恢复,以此为目的,代码中在最终产生错误的时候,GS引发事件Recover,并将最后的报错线程作为参数传递。由于VMT的内部结构都是暴露的,所以在这个事件中,错误恢复程序应该伪造分析过程,然后选择一个合适的地方重新开始分析过程,并记录下出错信息。
由于上面提到的代码上的改动大幅度的减少了无用线程的数量,从而使分析速度有了一定程度的提升。
Totel Parser run time:0.0300432
Thread Count:186
Proc Count:77
Thread forks:109
可以看到速度上已经有了比较大的提升,并且无用线程的数量大幅的地减少了(77/109)。于是开始尝试处理复杂代码。首先的一点就是需要一个复杂的语法文件。因为自编译是一项必备的工程,所以我通过改进B-使得它可以处理绝大多数的VB代码。我从代码中抽出一段完全由B-支持的代码,首先进行测试。
共852个Token,177行。
Code generate success.
Totol use time:0.110144s
速度可以,语法兼容。进而通过代码复制粘贴,生成一个2.4万行的代码文件。并使用其作为速度测试的工具。
共113858个Token,23922行。
Totel Parser run time:9.163176
Thread Count:500700
Proc Count:230472
Thread forks:270228
按照这个速度测算,上面的852个Token只需要0.0685s就可以分析完毕,除去编译.NET正则表达式所需的时间,肯定还有一定的IO时间。
4.4 自动分析器的基本原则
至此,一个自动分析器基本上构造完成。接下来让我们进入VMT的内部来看一下它的大概的运行流程。先以上面的(a|(b[c]))d为例子。当输入为ad时的流程为a,匹配,由匹配出口出,进入d而忽略b子表达式(这从上面的图中可以清楚地看到)。在此匹配的d,合法结束,生成分析树并返回。对于bd的输入,a不匹配,又不匹配出口出,进入b,匹配,有匹配出口出,进入c,不匹配出口出,进入d,匹配后结束。这只是最简单的情况,没有规则的调用。在使用规则调用的情况下(没有一个比较复杂的语言的语法可以不使用规则调用),在产生规则调用时就产生了问题,由于按照VMT语义,规则调用要创建新的Process,并不能马上知道是否匹配,从而如果终结符都不匹配,那么产生了规则调用的线程是否结束呢?答案很显然了——不能——因为如果结束的话在规则调用返回时就没有了返回点,这样分析过程就没有办法继续了。在有规则调用时不结束,那么等待吗?如果等待,后边的图元又怎么办?如果不等待,在规则调用返回时,该返回哪里呢?可以使用一个表来记录返回点的信息。规则调用返回时找到返回点继续运行。但是,如果有两个规则都正确返回了,那么第二个线程该有谁来执行呢?又如果第一个返回后迅速产生了其他的规则调用,那么返回点信息如何记录?显然这个方案遇到了不可能解决的问题——多线程的返回。这就是VMT所要解决的主要问题,VMT不管何时有产生分支的需要就制作一份当前线程的副本,让被调用的规则的返回线程设置为这个副本,然后自己继续进行终结符的匹配工作。由于在返回时是以进程的名义返回到调用源的线程,所以这个方案生效了——调用源的线程对于进程的返回并不能区分是由哪个线程返回的,也就是说副本线程和原线程对于调用源线程来说是一样的。这样当初始线程发现规则调用时只是简单的产生一个副本线程,并设置返回点然后让副本线程等待规则的返回,而自己一直执行到不匹配出口。在所有的线程中必有不多于1个线程能够最终正确返回——0个就是这个规则不匹配;1个就是匹配。这样在分析到同一个地方时可能在同一个进程中同时存在很多的线程并行执行。这样做甚至不需要分支预测!因为分析器会走遍所有可能的情况——虽说比较慢——这也就是为什么我在后继版本中重新引入分支预测的原因。好,调用/返回问题解决了。如果调用的是空产生式呢?按理说应当返回给调用源线程并由其处理。但是VMT的同步处理方式不允许等待下一轮重新发布此Token,因此必须立即处理。于是有了Redis这个参数。Redis就是Redistribute,表示重新发布,即告诉调用源被调用进程中有一个线程已经到了结尾但刚才的Token不能处理,而且合法结束,需要由调用源来处理。而调用源发现返回参数中Redis不为空则调用相应的函数进行相应的处理。这样即使调用了空产生式也会立即返回,并将刚发布的Token重新发布给规则调用源。而这实际上解决的不仅仅是空产生是的问题——对于可选元素的结束,也能很好的处理。
4.5 实际的例子


4.6 分支

再让我们来看一下VMT模型中线程的Fork的一些细节,这也正是VMT的核心。在右图的情况(假设a为一个规则,非终结符)中,当一个线程开始a的匹配之后,b的匹配什么时候开始?又应该由谁开始?按照正常的C3语义来说应该等待a匹配结束并且不匹配之后再开始匹配b。而问题常常并不这么简单,在VMT运行方式下,等待a结束也就意味着前面的Token流消失了——被a消耗掉了。这样本来正确的匹配可能由于被a消耗掉一些Token而匹配失败。所以在VMT同步方式下只有一个放案,那就是让一个线程Fork,然后由其中的一个等待a的返回,而另一个则继续匹配b。由谁来匹配b?好像都可以。但是如果经过仔细的观察分析,就会想到,一个规则调用最多产生一个匹配结束而可能由多个分支构成,那么由不匹配出口出的机会要远大于由匹配出口出的机会。这意味着让副本匹配不匹配出口的话会产生数量远大于另一条路的副本数量。而更多的线程Fork毫无疑问会使运行变得更慢。所以让副本去匹配匹配出口,而源线程一直穿透到最后不匹配出口为空的图元并由于不匹配而结束。
下图为示意:
源线程匹配a的过程中产生副本,自己则穿越a匹配b。
如果b的不匹配出口还有其它的图元,则依然使用这个规则——终结符直接匹配,而非终结符则产生副本线程等待返回。上面的就是理论上的结论。实际上的结果有些出乎意料——由匹配出口出的机会并不比不匹配出口的机会小到我们意料的那样!这个结果是在使用B-进行测试时发现的。为什么与我们的推理不符呢?仔细查阅上面的推理,我们将一条并不一定成立的条件作为前提来处理!显然要出错。这条让我们的推理失效的“前提”就是“由不匹配出口出的机会要远大于由匹配出口出的机会”。在分析过B-的语法文件之后新的发现就是绝大部分语法规则在绝大部分时间内都是单分支的,也就是说根本没有分支,也就没有了产生副本的必要。原线程应该自己来匹配而不是产生副本等待后就立即因为不匹配而结束。而判断的方法也非常简单,只需在原来的代码中加入一段察看不匹配出口是否为空的代码,如果为空就自己匹配,而不为空的话在产生副本线程。
可以证明,VMT所使用的分析方法等价于LL(1)分析:在语法遇到分支时,分析器等待下一个Token,并通过判断选择分支。但是由于即使添加了分支预测之后,经过测试仍有50%的线程为Fork后结束的线程,它们浪费了大量的运行时间。但是,这些分支并不占用过多的内存,在一个比较好的内存池的支持下,它可以跑得很好——虽然产生的无用分支较多,但同时运行的分支并不多。所以如果要移植到一个没有内存池支持的环境中的时候,建议先提供一个内存池。当然,也可以打开代码中的“使用手工内存池”选项,但还是建议实现一个内存池,因为代码中的手工内存池运行的并不快。VMT虽然没有解决左递归的问题,但是很好的完成了公共前缀和空产生式的问题,已经达到了目的。而使用上,任何人都可以照葫芦画瓢的经过修改所提供的两个测试用语言的语法文件(c-和B-)来达到目的,从而达到其设计目标。
截至到2004-3-27止,尚不完全支持大小写的区分。
4.7 关键代码
VMT模型中最重要的就是Fix函数和唤醒时的WakeUp两个函数。
Public Sub Fix(ByVal t As Token)
'初始化变量,声明变量……
If t.Type = "__End__" Then
'结束符号的处理
End If
While Not IsNothing(CurrentRuleMapItem)
Select Case CurrentRuleMapItem.Type
Case RuleMapItem.RuleMapItemType.Terminal
If String.Compare(CurrentRuleMapItem.Text, t.Text, mIgnoreCase) = 0 Then
'匹配,将当前Token移入缓冲区,CurrentRuleMapItem指针后移
Else
'生成出错信息,之后可能将自己销毁。
End If
Case RuleMapItem.RuleMapItemType.Token
If mGR.Compatible.Compatible(t, CurrentRuleMapItem.Text) Then '匹配
'同上
Else
'生成出错信息,之后可能将自己销毁。
End If
Case RuleMapItem.RuleMapItemType.Reference
'找到被调用的规则
r = mGR.FindRule(CurrentRuleMapItem.Text)
If IsNothing(r) Then
Throw New Exception("Can find rule :" + CurrentRuleMapItem.Text + ".")
Exit Sub
End If
'分支预测 If mGR.Predict.Item(r.Name).Predict(t) Or r.EnableEmpty Then '每个调用生成一个副本。 Dim th As Thread th = mProc.Fork(Me) '这个线程用来等待所以不用将t传给它。 th.SetState(RunState.Waiting) '去等待 th.mRetPoint = CurrentRuleMapItem '返回这里 th.CurrItem = CurrentRuleMapItem '当前项也在这里 mProc.Service.CallRule(r, th, t) '这里将t传给被调用进程。 Else '反正肯定不匹配干脆不理它。 End If |
End Select
opt = CurrentRuleMapItem.Optional
ri = CurrentRuleMapItem.DisMatch
End While
If TerFix Then
If Not IsNothing(iNext) Then
'Terminal/Token匹配了
CurrItem = iNext
Else
[Return](Nothing) '既然匹配了就不重新分发了。
End If
Else
If opt Then
[Return](t) '可选结束重新分发。
Else
Terminate()
End If
End If
End Sub
可以看到,最重要的分支问题就在对Reference型路线图图元的处理上(灰色区域)。
Public Sub WakeUp(ByVal pt As ParseTree, ByVal e As Boolean, ByVal redis As Token)
'每个返回再生成一个副本如果被调用进程没有结束(e=False)
If e = False Then
Dim th As Thread th = mProc.Fork(Me) th.WakeUp(pt, True, redis) '欺骗手段,进入后将执行下面的代码 |
Else
'返回后不是应该结束而是继续
If IsNothing(pt) Then 'And IsNothing(redis) Then
'没有匹配的
'如果是可选的早就运行过了。不可选的那就不匹配,结束。不必重新发布。
CurrItem = mRetPoint '给错误处理的
Terminate()
Else
'匹配了
SetState(RunState.Ready)
buff.Add(pt)
CurrItem = mRetPoint.Match
If IsNothing(CurrItem) Then '本规则已经匹配完毕。如果让Fix处理会直接Terminate
[Return](redis)
Else
If Not IsNothing(redis) Then
Fix(redis)
Else
End If
End If
End If
End If
End Sub
可以看到,代码非常简单,而核心就在于规则返回的处理(灰色区域),等待的线程并不急于开始处理,而是看看自己是不是最后一次返回的接受者,如果不是,就再次产生一个副本,让副本去处理返回(通过一个参数e来判断)。
其实,如果你仔细的阅读过VMT的代码,其核心也就只有这几行,其它的代码大部分是管理代码。可见,运行在路线图上的LL分析器还是非常容易创建的。