这篇文章谈一谈最近火爆的 Elixir,同时说一下对编程语言选择的看法。同时作为 Erlang 发烧友,Elixir 不可不提。即使有了那么多编程语言 Elixir 也值得接触。

Elixir 并不是一个最近出现的语言。但是近期 Elixir 的生态逐渐完善,越来越多的专家开始关注这门语言,并且 给予 Elixir 好评

现在开始接触 Elixir

一个小的 Elixir 例子

并行处理 JSON 字符串输入,并且解析成可用的变量,计算每秒处理的速度并输出。

https://github.com/doubaokun/exsample

用 entop 监控 Elixir 应用状态

对编程语言选择的一点看法

作为个语言发烧友,之前接触过 Java、Erlang、Scala、PHPJavaScript、C#、C、Python、Ruby 等一大堆各种风格的编程语言。有人说,学那么多编程语言是想做”翻译”吗?其实事情并不那么简单。

不同的语言背后是风格截然不同的类库群、技术堆栈、生态和工具链。不同的语言针对了不同类型的问题。某些语言解决某些问题的成本会比其他语言低非常多。回归本质,学习编程语言还是为了低成本高效的解决实际的业务问题。

个人喜欢的编程语言风格

可以近实时更新变更

最好不需要长时间编译才能执行、应用启动快。

Java、 C 编译很慢,不适合频繁修改的项目。但是 PHP 、Node.js 修改即可见,可以极大提高开发效率。最好还能 hot-reload 就像很多前端工具一样,只要源码有一点变更,不需要刷新页面自动反应在浏览器中。Play framework 类似的自动加载功能也可以。

更进一步,能够在生产环境热加载就更好了,更新代码不影响用户。针对这一点,很多人乐了, PHP 默认就是这样的啊,部署后刷新 APC 缓存就可以实现。
这正是无状态、短链接的 HTTP 应用的优势,虽然随之而来的是性能相对降低更多 TCP 的开销,但是把问题变得简单很多。但是很多其他语言做到这点就很难了,比如大部分 Java 应用。

Elixir、Erlang 可以做到真正的任何情况下开着跑车换轮子。

关于热加载,见另一篇文章:编程开发常用的热加载工具。

支持并发执行

人们更习惯顺序执行的思路,并且大部分业务逻辑都是顺序执行的。但是为了降低延迟、提高性能,最好能在语言层面支持并发执行。比如,一个操作开始还未返回结果,就可以开始另一个操作。
这样调用远程 API 或者远程 RPC,耗时为最慢那一个操作的耗时。从这一点看,大部分流行语言都可以做到并发调用,但 PHP 难以做到。

轻量级执行进程或者线程

由于某些限制,某些业务逻辑不可避免的会因为大量计算、网络磁盘 IO 等占用一个执行进程或者线程。所以希望这个执行体能够尽量轻量级,很少的内存占用,很快的启动时间,很少的切换消耗,最好能在 IO 执行的时候自动让出计算资源。

并发和并行

我们更多关注并发,但是比较少关注并行。因为通过增加机器数量能抗住大量用户的请求比节省机器更加简单和迫切。
这也是很多互联网公司动辄几百台上千台服务器的现状。用户和请求量的多少由于业务逻辑的不同很难比较,只能比较机器数量了。

并发之进程模型

PHP 既是典型的这种模式。曾经见过某异步 PHP 框架 CS 高居不下,甚至比业务逻辑的 CPU 使用更高。

并发之线程模型

这种模型相对于进程模型好了很多,因为线程比进程轻量很多,创建、切换也快很多。

问题:线程和内核线程的关系为多对多,内核线程有限。能够调度的用户线程有限,无法充分利用多核性能。创建新线程消耗非常大。IO 阻塞无法释放计算资源。

每个 CPU 核心只能同时运行一个线程,多个线程之间需要切换调度(CS)。如果是 CPU 密集类型的计算,没有或者很少 IO 操作,最好启动 CPU 核心数量的线程。
但是如果有 IO 操作,比如 磁盘或者网络,多余 CPU 核心数的线程有效,因为 IO 操作的时候可以切换到其他线程执行 CPU 操作。

并发之 Fork-join 轻量级进程模型:

Fork-join 创建自己的进程池来执行小粒度的任务。
相对于 Erlang 那种真正的抢占式调度的 VM 实现或者操作系统的抢占式调度,Fork-join 模型非常简单,也意味着相比之下效率相对低。
Fork-join 针对计算密集操作设计,意味着无法告诉 F/J 框架你因为 IO 等待而释放一会儿计算资源。所以,一般需要将异步 IO 操作放到另外的线程池,FJ 只处理纯计算。
基于 Scala 的 Akka 既是这种模型。所以,假如处理不当, Akka 的 Actor 很容易阻塞执行线程,如果执行线程池的线程被耗光,整个应用将会僵死在那里。而 Erlang 则没有这个问题。

并发之 Erlang 轻量级进程模型:

VM 调度线程,将计算划分为非常小的执行单元。可以支持非常多的进程。IO 阻塞可以自动释放资源。真正的抢占式调度。

类型系统

静态类型可以避免很多失误。动态类型经常会出现不可预期的结果,这有悖于 UNIX 风格的最少意外原则。
动态类型可以让开发更加快速。强静态类型系统会执行很快,比如 Java,但是也可以在有必要的时候使用反射,比如很多 RPC 框架的实现 (当然也有更进一步的字节码修改技术)。
每个语言的类型系统都有自己的特点。

丰富的内置结构或者容器类

最好能够区分 Interface、Struct 和 Implementation。能够以比较统一的模式轻松的定义自己需要的结构体。

GC 系统

除非 Erlang 无可媲美的轻量级线程级别的 GC 。否则你要么需要记住和理解复杂的 GC 调优参数、要么像 PHP 那样过一段时间将进程杀掉重来。

元编程和 DSL 扩展性

在语法级别的抽象和封装更能提高开发效率。Elixr 中如何实现 DSL。

执行速度和性能

这点和并发并行模式、以及多核利用率密切相关。

UNIX 风格

简单说就是模块化;每个模块完成相对单一的功能、复制任务由多个模块组合完成。项目设计就像搭积木。不同模块之前的输入输出可以拼接。
另外就是极简风格。

依赖和库管理系统

这点 Node.js npm 是最好的依赖管理系统了,这样导致了 Node.js 社群库数量的爆发。因为创建和发布一个库实在是太容易了,找到需要的库也非常简单。
极大提高了开发效率。

打包和发布系统

最好能打包成单一文件,容易分发和部署。比如 Java 应用打包成 Fat Jar 包到处执行,或者 Golang 那样编译成单一文件。

日志系统

真实的项目、日志非常重要。之前的文章已经提到日志的重要性。所以好的内置日志系统或者比较统一高效的日志模式非常重要。
最好支持屏幕打印、写文件等等功能。这可能不能算一个编程语言的特性了,要看这个语言是不是有很好的日志类库。
Java 的 SLF 就是一个比较好的日志系统类库。

工具链

项目构建、编译、测试工具比较完善。
比如 Java、Scala 项目的 maven、sbt 。Erlang 项目可以用 rebar ,但是 Elixir 的 mix 友好的很多倍。
另外一个好的 REPL 命令行工具非常重要,因为这可以方便的侵入应用进行调试,或者测试一条代码片段。
比如 PHP 的 php -a, sbt, Clojure 的 lein, Erlang 的 erl, Elixir 的 iex 等等。

脚本执行

这是脚本语言的一大优势。小任务可以立刻创建一个脚本执行,而不需要修改、编译部署现有运行的应用。
这点对于小任务非常重要。Erlang 和 Elixir 都支持这样运行,escript 或者 Elixir 脚本。比如,连接到集群,读取状态或者进行一次性的数据操作,然后断开。

测试系统

最好有一种比较标准的单元测试模型。比如 Java、Node.js、Scala、Elixir 等等。

说了这么多,回到 Elixir。首先 Elixir 执行和 Erlang 没有任何差别。Erlang 的优点 Elixir 完全具备。比如:真正的抢占式调度;充分利用多核心并行执行;Actor 模型;监控树;透明的分布式;
极其高的稳定性;代码的热更新部署;函数式编程;模式匹配;等等。并且很多 Erlang 下工具也是可以直接使用。比如 entop 。

另外 Elixir 比 Erlang 多出的好处在于更加友好的语法、工具链、社群。很多之前写 Ruby 的开始写 Elixir,因为他们的语法最接近。

Elixir 的元编程 (meta programming) 和 DSL

1. quote 将代码变成 AST,很像 LISP 语法。

quote do: 1 + 2

2. 执行 quote 的表达式

Code.eval_quoted(quote do: 1 + 2)

3. unquote 用来引用 quote 范围之外的变量

number = 13
Macro.to_string(quote do: 11 + unquote(number))

Elixir 成熟的工具链

mix:项目创建、构建工具
hex:可以和 npm 媲美的依赖和库管理系统 https://hex.pm/
iex: 类似 Erlang 的 erl 既是 EPRL 又是应用启动命令
exunit: 单元测试工具

Tip: (Erlang\Elixir\Akka 都需要注意不要让某一个 Actor 的 Queue 积压过多消息成为系统瓶颈。监控 Queue 长度非常必要。)

Erlang、Elixir 一些有用的工具和库

entop
gproc
:observer.start()
rebar

更多有用的 Elixir / Erlang 类库,比如 Web 类库 Phoenix Webframework,常见的 MySQL、Redis、MongoDB 类库:

https://github.com/h4cc/awesome-elixir

很多有用的链接

https://en.wikipedia.org/wiki/Strong_and_weak_typing
https://en.wikipedia.org/wiki/Unix_philosophy
https://en.wikipedia.org/wiki/Fork%E2%80%93join_model
https://en.wikipedia.org/wiki/Preemption_(computing)
http://yosefk.com/blog/parallelism-and-concurrency-need-different-tools.html
https://www.subbu.org/blog/2012/03/async-io-and-fork-join
http://www.slideshare.net/zacharycox/dont-block
http://stackoverflow.com/questions/4436422/how-does-java-makes-use-of-multiple-cores
http://cabol.github.io/programming-languages-and-multicore-crisis/
http://www.neo.com/2013/08/27/two-days-with-elixir
http://learningelixir.joekain.com/profiling-elixir/
https://github.com/d0rc/exrabbit/blob/master/lib/exrabbit/dsl.ex
http://blog.jonharrington.org/elixir-and-docker/
http://www.smashingmagazine.com/2013/04/introduction-to-programming-type-systems/

注册 DigitalOcean 256MB 内存, 20GB 硬盘 VPS, 获取 10 美元折扣

系统的可靠性比性能、高并发更重要。没人希望整天分析错误数据、修复错误数据。任何一个错误都可能导致客户的损失。

成熟可靠的系统和不可靠系统之间的差别很大程度取决于:异常的正确处理。经验丰富的程序员和经验不丰富的程序员之间的差别是是否考虑到并正确处理可能发生的各种异常。这和处理用户输入数据的验证非常相似。

可以发现即使是运行多年,知名的互联网公司的大型系统都或多或少存在异常处理的问题。

Try catch 的内部实现

这篇文章有比较简单的解释。

异常必然发生

原因很多种:比如,网络不稳定、硬件故障、软件 BUG 等等。很多程序员如果不接触系统运维的话,很难考虑到并处理这些异常。这是问题原因的一部分。从这个角度看全栈工程师还是非常有用的。

异常处理的性能考虑

Donald Knuth: “premature optimization is the root of all evil”.

考虑到这个问题其实就已经进入的误区。该交给编译器的事情还是不要过多考虑了。

应该使用 try catch 的情况

说道 try catch 的性能,可能很多人会想到这会让程序变慢。其实不然,虽然它对应用性能的影响取决于编译器的实现,但是大部分情况下不会影响不抛异常情况下的执行速度,但是会有稍微内存占用的提升。并且,假如异常很少触发,使用 try catch 取代错误码然后 if 判断的方式可以提升速度,因为你不需要没次都判断这个错误码。使用异常代替判断还可以让程序更可读。

不应该使用 try catch 的情况

异常处理不应该取代 if else 用来做流程控制。

几个理念:Fail fast vs Retry vs Let it crash

Let it crash & Fail Fast

可以让错误停止扩散,保证整个系统的健康。可以重置系统状态、清理变量和内存占用等等。让系统恢复到原始状态。这对于恢复系统异常非常有用。

重试 Retry

在操作幂等的情况下,假如第一次操作失败,重试是处理异常的很好的方式。但是注意自动重试在处理不当的情况下会引起操作数量持续增长导致系统雪崩。

异常和错误的类型

第一种划分:

1. 编译错误:很明显,易处理。
2. 应用逻辑错误:最难发现问题;(类型系统有助于避免逻辑错误。Let it crash 主要针对逻辑错误和 BUG。)
3. 运行时错误:终止进程。
4. 生成的异常:throw 异常,尽早发现问题。

第二种划分:

1. 内部异常:可以内部恢复的异常;一般记录日志或打印相关信息告知用户错误。
2. 外部异常:Error,只能从外部恢复;一般打印堆栈信息并退出。还包括 RuntimeException, 软件 BUG,逻辑错误,错误使用 API。比如 NullPointerException。Let it crash 就是主要针对逻辑错误。这需要人工介入及时修复 BUG 。

异常区分的简单原则:

如果应用可以从异常中自动恢复,则为内部异常。如果不能,则为外部异常。

守门员:全局异常处理

这是处理不可预见异常的非常有用的一种方式。应用初成,难免考虑不到所有需要处理的异常。可以用全局异常处理记录日志、发现问题。

Node.js:

process.on('uncaughtException', function(err) {
    console.log(err)
});

Java:

package cn.eood.blog.example;

public class ExceptionHandlerExample {
    public static void main(String[] args) throws Exception {

        Handler handler = new Handler();
        Thread.setDefaultUncaughtExceptionHandler(handler);

        Thread t = new Thread(new MyThread(), "My Thread");
        t.start();

        Thread.sleep(100);

        throw new RuntimeException("Thrown from Main");
    }

}

class Handler implements Thread.UncaughtExceptionHandler {
    public void uncaughtException(Thread t, Throwable e) {
        System.out.println("Throwable: " + e.getMessage());
        System.out.println(t.toString());
    }
}

class MyThread implements Runnable {
    public void run() {
        throw new RuntimeException("Thrown From Thread");
    }
}

PHP:

function exception_handler($exception) {
  echo "Uncaught exception: " , $exception->getMessage(), "\n";
}

set_exception_handler('exception_handler');

throw new Exception('Uncaught Exception');
echo "Not Executed\n";
?>

异常处理总结和一些原则

  1. 异常处理是互联网架构中很重要的一部分。需要在架构中集成到日志系统、监控系统、通知系统。(可能很多公司的“架构”还没有这几个必须的系统,它们是可靠性的基础。)
  2. 正确区分内部处理异常和外部处理异常。能内部处理的内部处理;否则 Fail fast 交由外部处理。
  3. 异常可以大致分为:系统层异常和应用层异常。正确区分系统层异常和应用层异常。这也和内部处理和外部处理对应。
  4. 不要用异常处理掩盖错误,不用吞噬异常,至少需要记录日志,以供查看分析。
  5. Web 系统异常情况下返回正确 HTTP 错误码。这非常有利于发现和处理问题。正确使用 200 500 状态码。

一些有用的资料

注册 2.5 美元每月独立 IP 国内访问 30M 带宽 VPS

注册 DigitalOcean 256MB 内存, 20GB 硬盘 VPS, 获取 10 美元折扣