刚刚发现了github.dev这个东西,实在是太适合写这个博客了。也可能这就是为什么原来web端的在线编辑那么简陋还不改的理由吧。
随便记录一下最近的一下思考中间状态。
除了主要考虑的oskr::stage
和unfancy,还出现一个新点子:双向文本格式化bifmt。
在scanf的时代,输入格式化描述是和输出高度重合的。然而,输出格式化逐渐向基于花括号的新潮流靠拢,而输入在相比之下则较为停滞:要么是简单地先split后parse,要么是复杂地先正则match后parse。不说其本身是否好用,输入和输出之间的对称性破缺,让系统损失了一部分的美感。
而bifmt的核心就是恢复这种对称性:对于两个操作scan
和print
,以及一个特定格式化描述fmt
,在操作均没有报错的情况下,满足print(fmt, scan(str, fmt)) == str
,以及scan(print(fmt, ctx), fmt) == ctx
。
从某种意义上,bifmt也可以看作是一种序列化设施,和json、yml的性质是类似的。放弃了内建的结构化以获取额外的灵活性。
这种设计会引出一些有趣的细节,比如说print
也可能失败(由于输出流以外的原因):考虑一个给定最小宽度靠右侧对齐输出字符串的场景。如果字符串左端是空白符的话,再重新scan
回来的时候就会丢失相关的信息,打破上面的对称性质,所以如果输出这样的字符串就只能失败。
大体上来说是这样。其实已经可以动手去写个原型,只是考虑优先做其他的事(如开黑打Dota)。
关于unfancy的原型已经设计了若干数目了,但是总是难以掌握其复杂度,很难从中抽出一个最小可运行版本。最近从IR入手感觉相对可行了一些。
大体上的思路就是,模仿LLVM IR,并且给类型系统引入泛型和类型重写的支持。无论最后决定在哪个阶段展开泛型参数,都可以用同样的重写逻辑去完成。
目前的设计着眼点又回到了语义层面。先前的计划基本都是惰性展开的:在对应的函数被调用时对其本身展开,且不对其内部的调用进行递归展开。这样做一个方面是确保最小的AOT编译工作,只要生成完带泛型参数的IR就算完工;并且JIT编译的工作也可以被最小化,没有被执行的代码路径上的调用始终不触发展开编译。另一方面,将编译尽量推迟,以力求对于运行时的动态能力有所帮助。
先前的设计下,每个调用点只会对应同一个函数重载:每个调用IR都会在第一次执行时确定其所调用的重载,进行对应的展开和编译,随后的调用直接进入不再重新分发。这样做是为了压缩一些密集函数调用逻辑(比如包含大量运算符重载的算数逻辑)的调用开销。我假设了Julia也是这样的逻辑,后面要验证一下。这样做的后果之一是损失了大部分的动态多态:OOP的一个经典例子,遍历一个列表,对每一个对象调用用一个方法并分发至不同的实现,这样的场景就彻底被抛弃了。
如果遵循先前设计的话,那么似乎把展开编译尽量推迟并没有带来任何新的灵活性空间:同样的功能完全可以用递归展开调用,甚至在编译时静态展开所有调用的方案实现。唯一的优势只剩下尽量少做编译,节省AOT时间和空间。
另外一个问题在于多态的返回类型。我们似乎不能假设调用者始终知道调用的返回类型,甚至将返回类型用于分发时的重载匹配:一个例子是迭代器模式。实际上,我们最好都不要规定返回迭代器的函数的返回类型:在一个分支中多filter
一下的情况很普遍,而在Rust中这导致返回类型不一致而不得不box的情况也很不理想。
如果调用者不能(提前)知道调用的返回类型,那么分配无论是AOT还是JIT,编译时编配栈上空间的操作就无法进行。这是编译时不展开调用就一定无法解决的,甚至展开了也不一定能解决的。
这些设计细节盘根错节,让人深感设计一个高度自洽还实用的模型是多么困难。也许我需要先往动态类型的方向后退一步,重新审视一下了。
在Oskr的stage模块上面磨蹭了许久。
实际上是在Oskr的整体重构上磨蹭了许久。我决定从stage入手,然后就发现一来这个东西的设计很麻烦,二来我是真的懒。
对于stage的要求最主要的是要支持active模式,在接收一个新的消息以后,确定性的完成这个消息触发的所有后续任务。这是后面实现仿真系统的基本要求。
这部分没什么抽象设计的困难,主要是在实现的时候发现需要换一个宿主来持有状态,这个应该算是Rust导致的人为困难。
与此同时,牵扯出一个我希望解决的任务乱序问题,似乎可以趁着改模型一道解决。
这个问题是指,本地的并发处理任务会引入额外的乱序。比如,在收到消息先验证签名再处理的场合,如果后收到的消息签名验证得更快,就会比先收到的消息先被状态机见到。
由于异步网络本身也是具有乱序的,所以这些乱序的处理一部分本来也是存在的。这也是NSDI周期内没有非要解决这个问题的原因。更多的乱序也许会导致性能变差,不过除了有一次因为打了太多log以外,似乎没有发现明显的性能差别。
但是对于多步骤处理,如果步骤之间会乱序,那么这个语义可能会导致不得不引入更多的检查。比如结果缓存表的更新。如果同一客户端的两个请求的处理存在并发,那么在upcall的时候两个请求是按序的,等到了更新缓存表的时候可能就变成乱序的了。这时要么就要避免错误的“倒退”更新,要么就要把靠后的请求给阻塞住,等前一个请求更新完缓存再执行。如果可以在调度器的层面上,避免这种乱序的发生,则可以让协议完全去除这些多余的乱序逻辑,直接依赖于更强的底层语义。
这个问题的解决之所以和前面的active模式联系起来,是因为我发现这两个问题的关键之一都是区分任务头和后续任务。对于乱序的解决很可能设计一大堆新的编号相关的工程细节,这些想想似乎没有什么写下来的必要;但是如何设计一个好用的提交任务头的接口,大概是我需要做出回答的了。
最近做了不少噩梦。
希望对应的,能有点好事发生,有点好的代码来到吧。