

实现 resumable exception
source link: https://www.zenlife.tk/resumable-exception.md
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

实现 resumable exception
2022-12-21
最近一次提交,在 cora 里面把 resumable exception 给实现了。
resumable exception 和 delimited continuation 或 algebraic effect,其实都是差不多的概念,就是用法和接口看起来不一样而已。在 common lisp 里面的 condition 系统, 就是 resumable exception,而到了后面 paper 里面的概念,就变成了 algebraic effect,新瓶装旧酒。
先看看普通的异常,可以用这样的形式表示:
(try (lambda () (throw 42))
(lambda (e) ...))
try 在执行前一段代码块时,如果遇到了 throw,则跳转到后一段的 handler 函数中处理,也是后面可以 catch 住前面 throw 出来的异常。
而 resumable exception,和普通异常的区别是,它是可以恢复到之后 throw 的位置继续执行的:
(try (lambda () (+ (throw 42) 1))
(lambda (e cont)
(cont 666)))
这段代码会执行到 throw,然后逻辑跳转到异常处理中,注意 handler 接受两个参数,第一个是 throw 出来的异常 e
,第二个参数,则是抛出异常位置的 continuation。 当调用 (cont 666)
之后,代码会返回到 throw 位置继续执行,也就是 (+ 666 1)
得到最终的执行结果 667。
resumable exception 和普通异常的区别就是在于普通异常之后,原始的栈就丢失了,异常处理里面只能做少量清理工作。而 resumable exception 则强大得多,它实际上就是一个 delimited continuation,边界是从 try 到 throw 区间的栈。由于是有界的,比 call/cc
的性能会好一些。
我之前用纯库的形式实现了 algebraic effect,但是那样子实现有一些局限性。举个例子,这段代码用以前那个库就跑不过:
(defun f (n)
(if (= n 0)
(yield v (eff 'C n)
v)
(+ (f (- n 1)) 1)))
(with-handler ['C (lambda (v k) (k v))]
(f 300))
或者这样一个例子也跑不过:
(with-handler ['C (lambda (v k) (k v))]
(map (lambda (x)
(yield v (eff 'C x)
v))
[1 2 3 4 5]))
这些局限性的本质,可能一方面是来自于 yield 弱鸡了,它并不是真正的 generator,而是把剩下的计算(continuation)包装成 (lambda (v) ...)
,返回了 value + continuation 的 tuple。 所有非 tail 位置的调用都会有问题。(+ (f (- n 1)) 1)
实际变成了 (+ [tuple result continuation] 1)
这类型显然是匹配不上了的。
另一方面,可能这个问题的本质还是在 monad 的不可组合性。之前有一篇文章里面,我是写了非尾递归的时候,要用 monad-do 的写法,所以猜想这里有可能要写成这种形式:
(monad-do
x (f (- n 1))
(return (+ x 1)))
delimited continuation 本身确实是可以用纯库实现的,不过绕不过 monad。让程序员去尝试理解 "自函子范畴上的一个幺半群" 很容易造成永久性脑损伤。monad 方式实现后,上面 map 的例子就是不可组合性的体现。
这次实现 resumable exception,我是在语言层面实现的,而不是库的形式。换了实现方式,也因此克服了前面的问题。新的实现中,这种代码都是可以跑的:
(try (lambda () (map (lambda (x) (throw x)) [1 2 3 4 5]))
(lambda (v cont) (cont v)))
(defun f (n)
(if (= n 0)
(throw 42)
(+ (f (- n 1)) 1)))
(try (lambda ()
(f 300))
(lambda (v cc) (cc v)))
其实就是按 gambit 的做法 实现的。关键词:segment stack。

step1 是刚调用到 try 的时候栈的样子,chunk 和 handler 分别是参数。从左往右看。

step2 是开始执行 chunk 的内容。注意,这里是新分配了一个 segment 栈,在新的栈里面执行 chunk,而不是在原栈上面。用的 segment stack。

step3 在 chunk 函数继续执行,它又调用了 throw,进入到 throw 的栈里面处理。throw 会做这样两件事情:一个是把当前位置,一直到 segment stack 的起点的所有栈桢,打包起来,伪装成一个 closure 对象。另一个是,回退到 segment stack 之前的栈,在那里开始调用 handler 函数。

step4 进入到了 handler 的栈,前面的 throw 在调 handler 的时候已经为它把参数准备好了,第一个参数是 throw 的 v,第二个参数是 continuation,也就是用之前 segment stack 打包伪装而成的一个 closure 对象。

step5 是如果 resume 之后,会把打包的 segment stack 恢复出来,于是之前 throw 位置的 continuation 也就恢复了。继续往后执行就是正常的函数调用协议了。
关于 segment stack,这里还有一个小技巧。可以利用操作系统的分页机制。开始每个 segment stack 分配比较大的内存空间,实际上只是虚拟地址空间。只有地址真正被访问到的时候,才会发生分页中断,操作系统执行实现的虚拟地址到物理页的映射。
说起来概念好像挺简单,调试代码还是有一丢丢痛苦的。
</div
Recommend
About Joyk
Aggregate valuable and interesting links.
Joyk means Joy of geeK