Haskell语言和Erlang语言实现P2P协议的对比

  • 发布于:2020-02-03
  • 204 人围观

Jesper Louis Andersen根据他分别使用Haskell和Erlang语言编写两个BitTorrent客户端——Combinatorrent和Etorrent的经验,向听众阐述运用这两种语言进行开发的优势和困难。他特别详细说明了如何善用这两种语言各自的精华之处,充分发挥消息传递机制强健的并发能力。

个人简介
Jesper热爱钻研编程语言,尤其是并发与函数式编程。他担任领导人和主要开发者的两个开源项目,分别用Erlang和Haskell语言实现了BitTorrent P2P内容分发协议,并在项目中尽量发挥了各自实现语言的长处。

 大家好,我是Sadek Drobi,现在在GoTo大会上采访Jesper Louis Anderson。Jesper能向大家介绍下你自己吗?

大家好,我是Jesper。我就是丹麦的一个编程语言技术宅,喜欢摆弄编程语言,摆弄各种类型的编程语言,通过用它们实现各种东西来达到钻研语言的目的。我钻研的对象以函数式语言为主,但也会使用和研究命令式编程语言。尽量一个都不放过。

 我知道你用Erlang和Haskell两种语言都实现过BitTorrent协议。是什么原因令你这样做?

一开始我只是想学习语言。我的想法是说,要学一门语言,必须用它来做点什么东西,一定要真正用过,不能只尝试那些玩具一样的例子。用一门语言去解决真正的问题之后,才能确实掌握它的用法。所以我就带着这种观念开始学习Erlang,因为BitTorrent客户端对于并发和并行能力有很强烈的需求,我就想到Erlang应该适合用来实现BitTorrent客户端。

开发过程的动力首先是自我挑战“我能做出来吗?”,其次是学习语言的愿望,希望去理解语言的机理。这些就是我用Erlang语言编写BitTorrent客户端的动机。后来尝试Haskell语言的理由也是一样的。第二次因为已经实现过BitTorrent协议,可以说对问题域相当熟悉,知道它的原理,知道那些黑暗的角落,所有不好下手的地方都清楚。但即便如此,实现工作仍然不简单,还是有一些困难要克服。不过毕竟提前知道哪些地方可能出问题,新语言学起来更快一点。

因为可以集中精力在新语言的特性上面。要做的事情没变,还是实现BitTorrent协议,只剩下语言特性需要钻研。总之目标是学习语言,提高我自己对Haskell语言的认识水平。

 那就等于说前后两次的经验是完全不同的:第一次你学习如何实现BitTorrent客户端,第二次你学习Haskell语言,用Haskell把事情重做一边?

是这样。

 你一开始偏偏选了Erlang,它特别在哪里?

特别在哪里?我觉得Erlang有两点很特别:其一是它具有一种被动的错误处理观念,对于可能发生的错误,你要准备万全的应变计划去面对一切可能的情况,把错误清理掉或者尽量纠正,尽量保证程序能继续运行下去,不退出或死机。系统尽其所能维持运转。这种观念会影响你对错误的处理方式,造成Erlang程序独特的写作方式。这是Erlang非常突出的特点,引起我的兴趣和喜好。

第二个特点是它的内部模型,Erlang程序里面存在大量的小进程,而每个进程完全独立于其他进程。在一般的编程语言里面,多个进程之间往往共享内存空间。Erlang从VM的角度来看不是这个样子。当然从更底层的角度,从内核的角度来看,还是存在一个共享的内存空间。但是在Erlang的思维里面,每个进程被认为是独立的。这样的模型有其代价,即当某些内容要从一个进程转移到另一个进程的时候,必须完全复制一份。

在我的认知里面,唯有Erlang完全采用这样的模型。也许有些玩具语言也这样,但至少在比较流行的语言里面,没有第二个这样做的。一般的语言都采取共享内存的概念,不会让每个进程完全独立,甚至有自己的垃圾收集器。所以我认为这是Erlang的第二个独特的地方。

 Erlang的特点正好有利于你实现BitTorrent客户端。那么当你换到Haskell语言的时候,没有了这些特性,你用什么东西来代替?

Haskell是共享内存的,它也可以fork进程什么的,但内存确实是共享的。好在它有持久化的不可变性。利用persistence immutability的概念,即使没有完全独立的进程,也能取得很好的效果。如果我向另一个进程发送一个数据结构,对方进程得到的本质上是一个副本。假如我方改变刚才发送的数据结构,将得到一个新版本的数据结构,持久化的概念使双方不受变化的影响,对方看到的原始旧版本还是有效的。

大体上可以把这种特性看成给数据加上版本管理,它化解了进程不完全独立的问题。函数式语言Haskell具备不可变性和持久化的特点,为它解决很多这方面的问题。

 错误处理在Haskell里面是怎样的?

这方面有点意思,因为Haskell的错误处理观念不像Erlang那样被动。Haskell程序出错的时候会抛出异常,这种错误处理方式看起来没什么特别。然而Haskell的最大特点是它具有非常强的类型系统。所以当你主动发挥类型系统的表达能力的时候,类型系统会防止你写出存在错误的程序,从根子上阻止错误发生,这样就达到减少程序中错误和错误状态的目的。从这个意义上说,Haskell语言的错误处理观念是主动、积极的。

这里头有一个要点,即对待类型系统有两种态度。如果你觉得不喜欢类型系统,那么类型系统往往就发挥不出它的效用。空有一个类型系统不去充分利用,而以勉强的态度去将就适应存在类型系统这个事实,那么类型系统就会干扰刺激你。假如你换一种态度,把类型系统看作描述程序的手段来积极地运用,那么当程序中存在错误的时候,有很大几率被类型系统所揭发,起到保护的作用。

所以要注意类型的用法,尽量让类型检查在出错的时候给你指出来:“这里不对!”甚至直接给你指出错在哪一行。所以我这样总结Haskell和Erlang的异同,它们都极重视程序的正确性和健壮性,但在取得健壮程序的方式上,Haskell偏向于主动的方式,Erlang偏向于被动的方式。

我在写Haskell客户端的时候,处理异常的做法有点像Erlang的错误处理方式。我部分地实现了Erlang的错误处理机制,但没有全盘复制。所以当程序死掉的时候就死掉了,不会像Erlang那样死不了,也不存在重启一部分系统的情况。

 Erlang有actor,而Haskell没有,你是怎么做的?

用了迂回的办法。Actor模型是个老模型,从1970年代就出现了,有着严格定义。Erlang在某种意义上实现了Actor模型,它的“类Actor”特征非常鲜明。我开始编写Haskell客户端的时候,做了一个决定:既然已经有一个用Erlang风格的actor搭建的进程模型,何不直接把它套用到Haskell。于是我费了一些时间来做这件事,用了Haskell并发库和CML。CML我以前就用过一阵子。我在CML的基础上重新实现了actor模型。后来发现CML不适合做这件事,而且我不满意CML内部有些抽象泄露的情况。

所以我决定把CML那部分换成软件事务内存(software transactional memory)——STM模型,正好Haskell有这个。系统最后的样子,最底层是STM模型,上面是STM模型营造出来的一个“类Actor”的世界,Haskell客户端的搭建工作就在这个世界里面进行。大体上是这样的过程。

 在STM上面实现起来容易吗?

是的,我觉得挺容易的。就是有一个地方跟Erlang不一样:Erlang是向进程发送消息。发送消息的目标,那个标识符代表了一个进程。Haskell客户端通过channel来发送消息,消息的发送途径是通道。这一点与Haskell属于静态类型语言有关。通道因为类型是确定的,用起来肯定简单很多。而进程的话会遇到一个很难回答的问题:“那个进程的输入类型是什么?”因为肯定会遇到一些别的类型,别的很复杂的类型,类型层面的问题不容易解决。

通道的话,在语言层面几乎不需要操心类型的问题。所以在Haskell这样的静态类型语言里面,通道方案最为简单。因此当我编写Haskell版的BitTorrent客户端的时候,我决定采用通道,而不在这方面照搬Erlang模型。另外由于STM已经包含了通道基本类型,基本不用再费什么事。只有一件事情需要另外准备,即同时接收多条通道的能力,而STM连这方面的规划功能都给你准备好了。基本上就是万事具备的样子。

我记忆里面,只补充了一些进程处理结构和一些监控结构,用来重置部分系统,或者处理连接突然断开之类的事情。总量不超过四、五十行代码,就足以将大部分的Erlang模型复制过来,重新在Haskell里面实现。

 在某种程度上,你的Erlang实现影响了你的Haskell实现。那么你的Haskell实现有没有反过来影响Erlang实现?可以说说吗?

有的。我先有的Erlang实现,然后当我用Haskell重新实现的时候,突然发现因为有通道这个东西,某些部分必须推翻掉。Haskell版的编写过程让我对每个进程的相互关系有了更进一步的理解。我意识到有些进程存在用户节点意义上的局部性,只对单个用户节点有意义,还有一些进程存在Torrent意义上的局部性,比如只作用于某个完成了的Torrent。

例如有局部的进程,围绕一个Torrent与一群用户节点进行通信,然后有全局的、与多个Torrents通信的进程。请想象一个例子,假设你希望在系统中增加限制带宽的设施,限制上行带宽的占用量。这个东西是全局性的,因为你希望限制客户端整体占用的带宽。你还可以对每个Torrent单独限制其占用量,这种情况属于在Torrent意义上的局部。

继续举例的话,还有比如对单个用户节点的限制,限制某节点允许占用的最大带宽。思路大概就是这个样子,我从这里头总结出来——全局、Torrent意义上的局部、用户节点意义上的局部——这些关于局部性的系统规律。Erlang实现根本没考虑这些方面,它的基本思路就是一切都是互相联系的。后来我按照新的思路回头去翻新Erlang版的进程模型,看到一个进程知道说“哦,它是Torrent局部的进程”,那就把它放在这个地方。那个地方一定不能放一个全局的进程,不然它会同时含有多个Torrents的状态。

这些关于进程定位的知识被回馈到Erlang客户端,补其不足。大体上可以认为Haskell实现是第二次迭代,重新对Erlang实现进行局部修整算是第三次迭代。如果日后又对模型有了新的认识,那么出现第四次迭代也不足为奇。

万企互联
标签: