Erlang缺陷

Erlang代码具有较为良好的可读性, 其原因之一就在于语义简明. 大部分情况下, 每个操作的成本都清晰可辨, 没有隐式调用的对象构造函数和析构函数, 没有运算符重载(因此+运算符局部可能偷偷摸摸的复制整个对象), 没有虚函数表带来的间接调用, 没有临界区, 也没有阻塞式的消息发送原语. 当然, 函数调用几乎是“无所不能”的, 他们的行为并不是一目了然, 但通常每个函数都附有清晰的文档.

和任何编程语言一样, Erlang也不可避免的具有一些缺陷.

基础数据类型

Erlang的数据类型的大小是以机器字(machine word)为单位来计算的, 这是由BEAM模拟器的工作机制决定的. 在32位机器上, 一个字长4字节, 在64位机器上, 一个字长8字节.

数据类型 内存占用量
小整数 1个字
大整数 至少3个字(可按需增长)
浮点数 在32位架构下占4个字, 在64位架构下占3个字
原子 1个字(原子的名称字符串仅存在Erlang节点的原子表中)
二进制串或位串 3至6个字+数据长度(以字为单位)
pid, 端口或引用 本地进程/端口/引用占1哥字, 远程进程
fun函数 9至13个字+被闭包捕获的变量每个占1个字
元祖 2个字+每个元素1个字
列表 1个字+每个元素2个字

在以下讨论中, fun函数可被视作带有额外元数据的元祖, 而pid以及端口和引用, 则与整数相似.

  1. 小整数

    小整数仅占一个字的内存, 不过BEAM会将这个字中的若干位用作类型标签, 以便区分不同的数据类型.

    在32位机器上, 可用于存储整数值的位只有28个(包括符号位), 因此在单个字内, 整数的取值范围位-134217728到134217727, 处理更大的整数时需换用大数.

  2. 大数

    在Erlang中整数的大小不受限制. 一个字长塞不下时, 运行时系统会自动把它转换成长度可变的大数(但不可超出可用内存的大小). 二者之间唯一可感知的区别就是大整数运算会比小整数运算要来的慢. 在带有密集数值运算大紧凑循环中, 如果给定的输入会导致大量大数运算, 就会产生较为明显的性能差异. 这时可以对程序进行修改, 尽量使用小整数来完成运算.

  3. 浮点数及其装箱形式

    Erlang采用的是64位精度的浮点数, 一个字长容纳不下(即使在64位机器上也放不下, 和小整数的情景一样, BEAM会讲一些位用作类型标签). 因此, 浮点数必须表示成装箱形式: 在这种形式下, 浮点数的实际数据保存在进程的堆空间内, 指向该位置的指针连同类型标签一并挤入一个字. 这恶扬以来, 无论是用作函数参数还是用作数据结构的成员, 需要该浮点数时只需要复制这个字便可. 接着来看位于堆上的数据, 第一个字用于描述数据类别(浮点数)及数据长度. 紧随其后的才是真正的64位浮点数: 在32位机器上占2个字长, 在64位机器上占一个字长.

    除浮点数外, 还有几种基本数据类型也采用装箱形式, 包括大数(这就是大数至少要占三个字长的原因)和元组.

  4. 原子

    原子和小整数类似: 每个原子只占一个字. 原子的名称字符串保存在一张原子表中, 每个Erlang节点只存一份. 原子所占用的那个字中保存的实际上是原子表中对应字符串的索引. 因此, 原子的相等比较跟小整数的相等比较一样快, 只需比较索引值是否相等. 由于效率高, 原子被广泛用作标记元组的标签. 模块加载时, 模块中尚未加入表中的原子会被全部加入表中; 此外, 当前节点收到的发自其他节点的新原子, 以及调用list_to_atom(NameString)产生的新原子, 都会被写入原子表. 然而原子不会被垃圾回收, 插入表中的原子即使永不再使用也不会被删除, 清理这张表的唯一途径就是重启节点.

    出于种种目的, Erlang初学者往往会动态创建原子: x1, x2, …, x187634, 诸如此类. 对于那些一次性的, 跑完就会关闭的Erlang VM程序来说, 生成几百甚至几千个原子完全没问题. 然而原子表的容量是有限的, 目前只能容纳一百多万项. 一旦溢出, VM便会报出system limit错误, 然后崩溃. 小程序一般不会超出这个限制, 但对于需要长时间运行的线上系统来说这个问题却是知名的.

    譬如, 在将服务器接收到的外来数据转换成Erlang消息时就得特别小心. 外来数据中的字符串应该转换为Erlang字符串或二进制串, 要是转换成了原子, 就会暴露在风险之下: 攻击者只需要发送大量互不重复的字符串便可以把节点搞垮.

    在将字符串转换为原子时, 可以考虑使用BIF list_to_existing_atom(NameString), 它只会生成系统中已知的原子. 倘若原子表中没有与字符串相对应的原子, 该函数将抛出异常.

  5. 二进制串和位串

    二进制串和位串不过是些字节片段. 他们的表现形式和大数类似, 但却更为复杂, 因为底层实际上存在若干种对上层不透明对不同类型的二进制串, 他们主要分为两类:

    1. 堆型二进制串(较小)

      最大64字节. 他们跟浮点数和大数一样, 保存在进程自身的堆中. 和其他Erlang数据类型一样, 在进程间传递消息时, 这类二进制串的数据会被一并复制.

    2. 引用计数型二进制串(较大)

      保存在单独的, 为所有进程所共享的全局内存区域中, 这些二进制串采用引用技术式垃圾回收. 在同一个VM内的多个进程之间传递这类大型二进制串时无需复制数据, 只需传递一个指针即可. 有了这一机制, 我们便可以让一个进程从文件或端口中读取数据, 再将读出的数据发送给另一个进程处理, 完全不用担心数据复制的开销. 通晓底层的实现机制固然是件好事, 然而一味利用这些鲜为人知的特性盲目追求性能却绝非上策.

      Erlang二进制串的语法很强大, 但也容易用错, 要做到运用自如绝非易事, 在循环中处理二进制数据尤其困难. 一个快速判断二进制串处理效率的方法就是启用bin_opt_info编译选项, 譬如将系统环境变量ERL_COMPILER_OPTIONS设置成[bin_opt_info]. 设置该选项后, 编译器会专门针对代码中的二进制串输出一些颇有助益的警告和信息.

  6. 元组

    元组是只读数据结构, 更新就意味着复制. 另外, 记录实际上也是元组, 所以更新记录字段就意味着创建新的元组: 更新一个含有10个字段的记录, 总共要写12个字. 但另一方面, 元组或记录中的字段选取操作却非常之快. 简而言之, 要么快速读取要么快速更新, 鱼和熊掌不可兼得. 对于恒定不变的数据, 将大型元组用作数组可以提高访问效率, 但更新效率堪忧. 如果将元组嵌套成树状结构, 虽然会引入多次间接寻址从而降低读取速度, 但更新操作的效率却会得到提升, 标准库中的array模块采用的就是这种做法.

  7. 列表

    列表单元的第一个字包含一个特殊的类型标签和一个指针, 其中标签表明这是一个列表单元, 指针则指向其余的位于堆上的数据. 为了指明类型和元组的长度, 二元组位于堆上的数据的最前端有一个用于保存这些附加信息的首部字; 然而列表单元的元素数固定位两个, 无需这些附加信息, 只需堆上的两个字即可完整表示一个列表单元, 这一设计有效保障了用作通用数据结构的Erlang列表的效率.

函数

函数调用类型 耗时
本地函数: foo() 非常快
已知的远程函数: bar:foo() 几乎和本地函数调用一样快
未知的远程函数: Mod:foo() 大约比本地调用慢3倍
Fun函数调用: F() 比本地调用慢2-3倍
元调用: apply(Mod, Func, Args) 比本地调用慢6-10倍

绝对耗时取决于硬件速度, 相对耗时也会随编译器和运行时系统的版本而变化. 例如, 在很多年前调用其他模块中的函数比调用本地函数要慢得多, 现如今, 二者已经差不多了. 从表中可以看出, 除非是对性能要求极其苛刻的代码, 否则一般情况下无需太过关注函数调用的开销, 只有元调用的速度显著落后. 在参数数目固定的情况下, Mod:Fun()形式优于apply/3.

进程

进程是所有Erlang程序的基本执行环境. 所有代码都要依托于进程才能执行. 即使是自身不启动任何进程的库模块的代码, 运行时也要依托于调用他的进程才行.

如前所述, Erlang中的进程十分廉价. 大量进程并发运行在Erlang中可谓司空见惯. 然而每个进程执行的工作却会对整个系统的性能产生显著影响.

  1. 要不要用OTP行为模式

    虽然新进程的创建仅需数毫秒, 但OTP行为模式容器进程的初始化却是另外一回事. gen_server:start_link()调用会引发一系列动作, 包括调用行为模式实现模块中的init/1回调. init/1回调不结束, start_link函数就不会返回. 这一设计是为了保证服务启动过程的确定性, 确保当调用方拿到新服务器进程的ID时, 服务器已经完成了初始化并且随时可以接受请求.

    在大压力下, 测试数据表明大量时间被耗费在进程初始化上. 进程的生存期越短, 耗费在OTP库代码上的时间比就越高. 在速度至上的情况下, 抛弃OTP行为模式, 转而直接利用spawn自行打造轻量级的进程管理机制也许更为实际, 这样做可以在最大程度缩减开销的同时提供精简的控制系统. 然而这种做法很容易出错, 只可用于处理非常情况, 而且只有在熟练掌握进程和OTP编程之后才行, 这样你才会明白自己为了性能而放弃了什么.

  2. 设置堆的初始尺寸

    如果大量进程在创建之后快速消亡, 那么还可以采取另外一种优化措施: 调大每个进程的初始堆大小, 以避免垃圾回收及进程启动之后的内存分配.

    进程堆的默认大小是233个字(在32位机器上等于932字节), 后续还可按需增减. 这一自动内存管理机制十分方便, 但会带来一定的运行时开销. 如果能够算出这些临时进程在他们短暂的生存周期内总共需要多少内存, 就可以在启动他们时预先设置堆的初始大小. 这一任务可借spawn_opt系列函数完成:

    erlang:spawn_opt(Fun, [{min_heap_size, Words}])

    在这种方式下, 每个进程都被视作一块内存区域, 这块内存在进程启动时分配, 在进程结束时回收, 除此之外不再需要其他内存管理.

    这么做的缺点在于每个进程的内存占用量都高于实际需要, 因此实际上是在拿内存空间换速度.

  3. 休眠

    如果有大量进程需要长期保持活跃, 且其中大部分进程因等待消息而处于睡眠状态, 就可以考虑让这些进程转入休眠状态.

    调用erlang:hibernate(Mode, Func, Args)即可令进程休眠. 休眠的进程会抛弃调用栈, 忘却自身在程序中的当前执行位置. 有鉴于此, hibernate/3永不返回, 当前正活跃的catch或try/catch表达式也会被忽略. 接着, 将会强制执行一次垃圾回收, 精简进程的内存占用. 最后, 进程进入睡眠状态, 直到新消息再次进入信箱(若休眠时信箱不为空, 进程将被立即唤醒). 进程被唤醒后的行为就仿佛是调用了apply(Mod, Func, Args), 不过该“调用”没有返回地址.

    休眠可以精简睡眠中的进程的内存占用, 释放出更多的空间容纳更多的进程. 这一首手法特别适用于那些监控着大量外部实体的系统.

    给予proc_lib的进程, 如gen_server及其他OTP行为模式, 应该使用proc_lib:hibernate/3而不是erlang:hibernate/3, 以确保进程醒来后周遭一切都遵照OTP库的约定再次打点妥当.