Elixir 代码性能优化指北

Elixir 作为基于 Erlang/OTP 的年轻语言,拥有良好的并发模型设计,在 Web 场景下对于实现能承载高并发的服务毫无问题。有好事者对比过包括 Phoenix 在内不同的 Web Framework 的性能,可见如果采用 Phoenix/Plug 来实现 Web Server 在性能上不会有太大的问题(代码实现良好的情况下)。所以,本文不会讨论真正的工业生产环境下整个系统的性能状态,因为系统性能受到很多因素的影响,具体编程语言的运行时的执行效率往往不是真正的问题所在,与其考虑编程语言本身的运行时效率,不如探讨系统在具体架构和实现上如何能优化来承载更高的负载来得实际。在生产环境中,我们可以借助 APM 服务来监控系统状态和性能指标。

由于 Elixir 本身是基于 Erlang 的更高层次的抽象,所以直觉上我们会觉得 Elixir 在运行性能上应该比 Erlang 本身要差一些。实际情况是不是这样呢?Elixir 相比于 Erlang 而言,为我们提供了一些可以快速调用的高阶函数库,典型的有 EnumStream,提高了日常实现需求的开发效率,可以让代码实现得更清晰且更易维护。而更高的抽象又几乎必然意味着底层的实现逻辑需要更通用健壮,从而也会更复杂。更高的抽象程度似乎天然与更高的运行效率有着内在的矛盾。本文的焦点在 Elixir 代码的运行性能,即对于实现同样的功能,用哪样的 Elixir 实现方式会让代码在运行时跑得更快。

接下来会拿我在刚接触 Elixir 时实际开发过程中真实写出来的代码来举例如何进行优化,相信不少 Elixir 新手会跟当时的我一样或多或少犯类似的错误。源代码可以在这里找到:ex_fieldmask - GitHub,通过项目的提交历史也可以看到我的修改过程,整个代码才一百多行,改动也都很简短,非常适合举例。这个库实现的需求是用简单的字符串解析的方式来实现类似 Google+/YouTube API 中的 Partial Responses 的语法和功能。我会以这个代码仓库中的例子来举例能明显改善 Elixir 运行性能的实现改进,其他不会明显改善性能但是也会让代码更优的改动也会稍微提一下。我们从提交历史中从前往后挑选一些值得一提的 commits 来一一解说。

  • 判断值时,用 case 替换 condcommit 913be42

    改动前:

    1
    2
    3
    4
    5
    cond do
    keys === [] -> data
    keys === ["*"] -> ......
    true -> ......
    end

    改动后:

    1
    2
    3
    4
    5
    case keys do
    [] -> data
    ["*"] -> ......
    _ -> ......
    end

    解读:能用 case(或函数子句)的就不要用 condcond 适用于多个独立的表达式求值判断真假的情况,它需要从上至下对每个表达式求值直到遇到第一个值为「真」的分支。在这里,我们全部是关于 keys 的简单比较,显然用 case 直接模式匹配会是更优的实现。cond 里的 true 的 fallback 分支在 case 里可以用 _left 来对应变更用于匹配任意项。

  • 函数用一个完整的 Pipeline 串联来提升可读性:commit 3e610d8,其他类似的改动还有 commit fa03938

    改动前:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    def reveal(tree, data) when is_map(tree) do
    keys = Map.keys(tree)

    case keys do
    [] -> data
    ["*"] -> ......
    _ -> ......
    end
    end

    改动后:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    def reveal(tree, data) when is_map(tree) do
    tree
    |> Map.keys()
    |>(fn
    [] -> data
    ["*"] -> ......
    _ -> ......
    end).()
    end

    解读:最后的匿名函数中的参数匹配跟 case 一样也是模式匹配,同样优于 cond,原代码中把 case 改成匿名函数的形式是为了让整个 reveal 函数是一个完整的用 |> 串联起来的 Pieline 而又不失简洁,但性能上并不会明显更优。这样的改动更多是把命令式的代码风格改成函数式的代码风格,Pipeline 的每个部分都是独立无状态的,可读性和可维护性都会有所提升。

  • List/Tuple 直接用模式匹配取值:commit 801ff47

    改动前:

    1
    2
    chars = elem(item, 0)
    delimiter = elem(item, 1)

    改动后:

    1
    {chars, delimiter} = item

    解读:同样也是用模式匹配替代使用函数来取值,不仅让代码可以一行解决,而且还会提升性能。如果 List/Tuple 很长,而我们只需要提取前面一部分的片段,则又可以使用 _tail 来匹配我们不关心的尾部区域。

  • 匿名函数用 & 改写:commit c19d49f

    改动前:

    1
    Enum.filter(fn str -> str !== nil and str !== "" end)

    改动后:

    1
    Enum.filter(&(&1 !== nil and &1 !== ""))

    解读:无他,就是代码更简洁了,而且我们不再需要想如何给函数参数命名。众所周知,命名在编程里是一件很难的事情……(当然,只有在这种函数很简单的情况下值得这样做)

  • 在函数参数中直接匹配复杂数据结构内部的值:commit 9af5145commit 456d3d4,其他相同原因的改动还有 commit 21b1fee

    改动前:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    Enum.reduce({%{}, [], [], nil}, fn token, acc ->
    {tree, path, stack, last_token} = acc

    case token do
    "," ->
    if List.first(stack) === "/" do
    {tree, tl(path), tl(stack), token}
    else
    acc
    end

    "/" ->
    {tree, [last_token | path], [token | stack], token}

    "(" ->
    {tree, [last_token | path], [token | stack], token}

    ")" ->
    {tree, tl(path), [token | stack], token}

    _ ->
    {put_in(tree, Enum.reverse([token | path]), %{}), path, stack, token}
    end
    end)

    改动后:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    Enum.reduce({%{}, [], [], nil}, fn
    "," = token, {tree, path, stack, last_token} ->
    if List.first(stack) === "/" do
    {tree, tl(path), tl(stack), token}
    else
    {tree, path, stack, last_token}
    end

    "/" = token, {tree, path, stack, last_token} ->
    {tree, [last_token | path], [token | stack], token}

    "(" = token, {tree, path, stack, last_token} ->
    {tree, [last_token | path], [token | stack], token}

    ")" = token, {tree, path, stack, _} ->
    {tree, tl(path), [token | stack], token}

    token, {tree, path, stack, _} ->
    {put_in(tree, Enum.reverse([token | path]), %{}), path, stack, token}
    end)

    解读:改动前的写法更多的还是在用其他无模式匹配特性的编程语言的思维在写代码,在 Elixir 里,我们可以直接在函数参数中使用模式匹配,不仅简化了步骤让代码变得更简洁,而且也简化了变量个数、少了命名需求。在分支的匹配过程中我们还可以给匹配到的字符串同样用模式匹配 "/" = token 的方式来命名。为什么已经确定的匹配还要用一个新的变量来匹配呢?原因是在分支内部需要多次重复引用 "/",我们直接用 token 来统一引用即可,小小改动却充分体现了 Don’t repeat yourself 的原则。

  • List Comprehensions 替换高阶函数的使用:commit 8de1abf,其他类似的改动还有 commit 48afae9

    改动前:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    fn
    [] ->
    data

    ["*"] ->
    data
    |> Map.keys()
    |> Enum.map(&[&1, reveal(tree["*"], data[&1])])
    |> Map.new(fn pair -> List.to_tuple(pair) end)

    keys ->
    case data do
    data when is_list(data) ->
    Enum.map(data, &reveal(tree, &1))

    data when is_map(data) ->
    keys
    |> Enum.map(&[&1, reveal(tree[&1], data[&1])])
    |> Map.new(fn pair -> List.to_tuple(pair) end)
    end
    end

    改动后:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    fn
    [] ->
    data

    ["*"] ->
    data
    |> Map.keys()
    |> (fn keys ->
    for key <- keys, into: %{} do
    {key, reveal(tree["*"], data[key])}
    end
    end).()

    keys ->
    case data do
    data when is_list(data) ->
    for item <- data do
    reveal(tree, item)
    end

    data when is_map(data) ->
    keys
    |> (fn keys ->
    for key <- keys, into: %{} do
    {key, reveal(tree[key], data[key])}
    end
    end).()
    end
    end

    解读:这里性能上是不是真的有优化可能不那么明显。在 Elixir 1.9.1 中我使用 Benchee 做 benchmark 发现 List Comprehensions 确实要比使用 Elixir 提供的高阶函数要快。没有看 Elixir 的具体的实现,但大致揣测 List Comprehensions 在底层是用简单的递归函数实现的,而高阶函数应该做了更多复杂的事情,从逻辑上可以想到的是 Enum/Stream 库需要先处理传入数据结构的 Enumerable 协议的相关要求再用不同数据类型对应的不同逻辑来处理,自然会复杂一些。

总结上来,简单的明显可以改善代码性能的写法其实只有两类:尽可能用模式匹配、用 List Comprehensions 替换高阶函数,其他的只是从其他角度考虑的代码层面的优化。最终我们线上没有使用这个库,因为 benchmark 发现直接定义 Partial Responses 的语法,然后用 Erlang 的 leex 做词法分析,再用 yecc 做语法分析生成 AST,最后遍历 AST 就可以得到做了 mask 的结果,即我们要的 Partial Response。代码同样开源在 GitHub:fieldmask - GitHub,也是一个绝妙的学习 Erlang leexyecc 的例子。

听说,你想请我喝杯咖啡?☕️