[Erlang30]Erlang shell是如何工作的?
一些关于Erlang启动进程的分析:希望你会喜欢。
原英文地址:http://ferd.ca/repl-a-bit-more-and-less-than-that.html
研究Erlang shell是一件饶有趣味的事情。我想肯定有很多用过它一阵子的人会对它缺少一些基本的特性感觉到很恼火,比如居然没有 history 或 历史搜索(从R16A后就支持),缺少对Emacs快捷键的全力支持,或它不使用readline,只是使用一个类似于readline仿真器(推荐使用
rlwrap
来包装Erlang shell)。关于更高级的REPLs的用法,比如提供Factor,Dr.Racket也不被Erlang shell可视化支持。不可以在shell里面声明内联模块(inline modules),只能在模块内声明。在这里,我想向大家解释Erlang shell是怎样工作的,为什么这些特性会非常难或容易添加上,更想要展示它实现了其它shell很少提供的高大上的特性。
它不是一个REPL(Read-Eval-Print Loop)
首先我们要理解Erlang shell并不是一个交互式解释器(Read-Eval-Print Loop)。至少,它的原型不是一个类似于lisp定义的
(loop (print (eval (read))))
先来点开胃菜,Erlang 并没有一个主循环(main loop),它有很多进程,每一个进程都可以有一个主循环,但这样就不容易调试追踪那些起关键作用的进程。正因为如此,Erlang才设计得支持'group' 和‘group leaders'(我们=下再继续深入了解),但它们允许多个shells把输出重定向到各种地方,比如其它的节点上,当前的标准输入输出(input/output)等等。这些groups会是从父进程继承到子进程(除非你自己手动去改变它)。
当Erlang VM启动时,它会启动
kernel
和stdlib
应用。VM神奇的可以允许这两个模块在启动和退出时彼此依赖。kernel
应用会启动一堆控制VM运行的每个’service'的监控进程--比如:分布式(distribution)。它们中的一个是user_sup。这个进程负责探测从shell里面输入到VM的参数。它会知道VM是一个master还是slave节点,还只是一个shell,它的输入输出功能是开启还是关闭的,你想加载什么样的shell(默认的那个,还是另一种很少人使用的旧式shell)。
这种转换是非常有用的,因为shell只是一个普通的Erlang进程,这意味着节点可能是分布式的:它会把输入输出都重定向到master节点上,又或者你可以对同一个VM有多个shells。如果Erlang instance只是启动,并不需要把输出指向master 节点,它就会把自己的输出都重定向到标准输入输出中(当使用-oldshell模式,或输入功能是关闭的),又或者它可以把输出通过中间usr进程重定向一个端口(port)程序中。
正如我上面所说,groups和group控制着输出的重定向。这就是Erlang shell精妙之处。group leader可以是一个注册了名字的进程。默认情况下是user,你可以让整个VM把动态的把输入输入重定向到你需要的任何地方去,根本不需要去理会多余的其它进程。如果你想改变IO,只需要改变user进程,然后所有都会被重定向。user_sup遵循以下的决策树(decision tree):
第一个选项是微不足道的。第二个选项也非常简单,它的实现放在一个声名狼藉的模块
user
中(声名狼藉是因为每一个新学者会试图起一个叫user的模块,导致很多东西不正常)。目前Erlang默认shell是第三方案,同时也是目前最复杂的方案。我们现在知道了如何处理发往IO stuff的流程,但我们依旧不清楚shell实质是怎样运行的。最简单的Erlang应该是有一个使用‘old shell' 选项得到的REPL,可以使用
erl -oldshell得到。
它是直奔user模块去的。使用其中最简单的那个,它将是一个理解shell如何运作的不错起点。
user
模块基本上处理了REPL中的’read'和‘print'部分。它会收集字符,显示提示,把收集的字符整理后发给’eval'部分来处理,然后再取结果显示出来。正如上图所示,shell.erl负责‘eval'部分。它会拿到一个字符串,然后把字符串解析成可求值(evaluation)的格式,然后再派生一个只为求值的临时进程。这样做的原因是:负责shell求值的进程自己来要负责一些棘手的state。比如:record定义在加载的文件中(record是一个编译时的小把戏,所以它们会被放在内存中并在求值时注入),求值的历史记录(shell会保存一个以前调用和结果的概要列表,以便让他们可以再被调用),绑定变量。
当表达式被求值后,会把结果返回给user.erl,user.erl会检查终端,确保结果格式正确,然后把它推给标准输出。
以上流程是可以保证正常工作,但是你离把它变成一个优雅的shell还差很多。问题在于:一个基于类似于user.erl模块的进程,如果你想不是遵循标准的io,这就会很难让这部分代码重用。如果你想让shell 通过SSH直接连接的话(这是完全可以的),或通过一个给定的C程序,一个GUI,或其它的?那么你就需要在更底层做处理:“抱着乐观的态度去移动文本流”。
Erlang 却不打算走操作文本流这条路,它使用了更多层的抽象。他们决定重写一个新的shell。代替开始的user.erl,监控进程现在启动了一个基于user_drv.erl模块的user_drv进程。新的结构如下:
在上面这种结构中,
user.erl被按功能切分开4个组件。tty扮演着前面标准输入输出的角色,可以自适应运行在不同的操作系统上。这就是你把字符输入shell到,并进入VM的地方。接下来就是user_drv,它会决定怎样处理这个字符,如果你按下的是
^C
或 ^G,user_drv会进程shell管理模式。
为了理解这个管理模式,我们需要知道:可能会有多个shells在同时运行的,他们可能被user_drv挂在一系列group.erl的进程下。这个列表决定了其中哪一个shell是处理于激活状态的。shell管理可以让你做操作shell本身的各种各样的事。下面我们来看看按下
^G的情况
:User switch command --> h c [nn] - connect to job i [nn] - interrupt job k [nn] - kill job j - list all jobs s [shell] - start local shell r [node [shell]] - start remote shell q - quit erlang ? | h - this message --> s --> j 1 {shell,start,[init]} 2* {shell,start,[]} --> c 1
Shell可以被单独的killed或中断(对于出现死循环,无限等待,或死锁时非常有用),为了连接到一个给定的shell instance上时,你就要重回到正常编辑模式(regular editing mode)。
如果你输入的是常规的文本,user_drv会查找当前激活的group.erl进程,当前shell。它是一个可以通过消息推送数据的shell。
那么,一个group.erl进程具体要做什么呢?它的主要职责就是缓存好输入的数据,整理成可求值的状态。处理一行堆栈,以便让你可以使用上下箭头来选择命令和搜索历史记录。直到 一行输入完成后,它才会和edlin.erl做一个back-and-forth的工作(它是处理光标移动,字符删除和对以前的命令做一些复杂的修改)。它们可以做一些高阶的行编辑和管理。它的第二任务就是:作为一个新起shell的group leader。
当一行命令检测为有可执行时,命令就会被发送到已准备好的shell.erl instance附属当前的group.erl进程会中,进行求值。求值后的结果就会通过group.erl进程再转移到user_drv中。
从用户驱动(user driver)角度来看,可能同时会有多个shell的数据转发过来。当收到输出结果时,user_drv就会检查:它是否属于当前group.erl instance。如果是,数据就会被格式化并打印到tty上。如果不是,数据就会被消除(muted)。但是有一种特殊的情况---把数据直接发送给用户(user),这时就有一个免费通行让它不被过滤掉。它会让标准IO把当前的shell instances转到能让数据传输到用户的shell instanec中。
这通常都是让其自动选择(This generally gives sane filtering),因为group 进程把自己设置为shell进程的group leader后,group leaders是被继承的,所有从当前激活shell instance的生成的进程都会把自己的输出限制在自己group中。当然也可以把application 启动输出到一个终端上(这仍旧是要把数据发给user的)。让大部分人永不知道这些转换无疑是比较好的,如果让用户手动去转换就会让他们不得不去了解这个是怎么工作的。
马上让它变得更完善
Erlang shell一个特别有意思的feature 就是可以开启远程的shells。正因为group feature的工作方式,得以让它变得可行。你就可以随时做到,只需要告诉group通过RPC机制开启一个新的shell,它就会给一个像下图一样的东西:
在这种模式下,常规的消息传递就可以保证它工作正常;求值会发生在远程的shell上,但编辑字符却在本地进行。这有别于slave 节点:对于slave节点来说,所有的输出都会被重定向到master节点上,但是对于上面这种远程shell来说,只有通过特殊的RPC shell(或它的子进程)才会被重定向。
另一个非常有趣的事:它在group.erl和user_drv.erl中实现了一个消息传递协议。然后再传送到tty中。这个协议与常规的 io protocol 类似。但附加支持了行编辑消息:如移动光标,光标闪烁,擦除行等等。
上面这些功能对于一个shell看上去有点太过华丽,但这也有非常好的一面:代码可以被任意的Erlang application重用。比如:我连接上的ssh daemon可以使用groups来为终端做一个定制版本的求值:
SSH daemon用自己代替了user_drv,它也可以代替tty的输入输出部分。group.erl和edlin.erl会继续保持行编辑正常工作。
每个人都可以用它现实自己的shell,即便是通过一个web浏览器。它们所需要做的事就是把行编辑协议在客户端侧自己实现。同时也要考虑安全问题。因为Erlang节点被入侵的后果是非常严重的。如果被入侵(使用一些所谓的’safe shell'),让shell.er进程被被其它的实现的进程代替。这就让group.erl和求值之前没有直接依赖关系,信息也可以随意传递变得可行了。
为什么这些会让它看上去更好?
这是一个好问题。我简单假设:只有很少的人直接知道这个shell是如何工作的。一个想实现常规REPL的开发者最终都找到这种分布式的任务管理系统的时候肯定是非常疑惑。这也是意料之中的,当你第一次了解到这个的时候。
像emacs快捷键绑定的这种特性也在edlin.erl中实现了。增加一个vi模块支持可能需要一整个新的行编辑器。支持其它的一些快捷键可能需要在‘drivers'(比如tty)中 实现。保存用户的命令历史记录需要用户进程到当前运行的group.erl里面,这样就会有很多人试图访问同一个文件。
我希望本文可以让有兴趣的实践者对Erlang shell如何工作有一个大体的了解。这段代码是非常有趣的。当然也非常有用,因为它可以让你为所欲为,即便它在其它方面有点平庸。我自己试图给Erlang shell添加各种有趣的功能。如果可能的话,让更多的人来讨论它会更加有趣!
When people are talking about using a single language for all components at work… and it’s not Erlang
写下来是好习惯: Notes