[分享]从一个小测试来理解 Erlang 的性能特性
Joel Raymond 最近搞了一个“悬赏”,看[这里]——如果能在1秒钟以内完成对20K客户端的广播(包含网络通讯,当然,为了排除网络速度的影响,是在本机做的测试),那么就能得到 $1000 。
Joel Raymond 是一个富有经验的 Erlang 开发者,连他都搞不定的问题,可以想象,应该简单不了。但这的确是个有趣的问题,虽说咱没想拿奖金,但这个有趣的题目,不拿来做个山寨测试,岂不可惜?
咱们就从这个很简单的测试程序开始。(注意,相当之初级,高手请闭眼,哈哈)
-export([test/1]).
-define(DATA, "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef\
0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcde\
f0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcd\
ef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abc\
def0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789ab\
cdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789a\
bcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789\
abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef012345678\
9abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef01234567\
89abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456\
789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef012345\
6789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef01234\
56789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123\
456789abcdef").
test(N) ->
Pid = self(),
spawn_link(fun() -> sender(N, Pid) end),
receiver(now()).
sender(0, _) ->
ok;
sender(N, Pid) ->
Pid ! ?DATA,
sender(N-1, Pid).
receiver(T1) ->
receive
{'EXIT', _, _} ->
io:format("time pass:~p~n", [timer:now_diff(now(), T1)]);
_Any ->
receiver(T1)
end.
程序本身很简单,就是起一个进程,给自己发指定条数的消息,这个新进程发完之后就结束,自己等待消息的流程接收到消息就直接丢弃(我们只想测试发消息的速度),如果收到退出消息 {‘EXIT’, _, _} 就打印起止时间之差(也就是我们这里想要得到的值),然后退出。
编译,运行一下:
erl +c +K true -sname test -eval "tsend:test(1048576), c:q()." -noshell
time pass:45604070
在我的古董级1.4G单核笔记本上,这1M条消息的发送花了45.6秒,也就是说,发送速度为 22.4K条/s 。此时,所发消息的内容为长度为 1K 的 list (按每个元素占 4 byte 算,每条占 4K ),数据流速为 22.4K * 4K = 89.8M/s。
这是第一个版本。
此时,我们做一个小改动,将消息内容由 list 改为 binary (把那个长字符串加一个 <<>> 扩上就行了),然后再做测试。
erl +c +K true -sname test -eval "tsend:test(1048576), c:q()." -noshell
time pass:1494110
没错!只用了 1.4 秒,731.4K条/s,快了约 31 倍!这说明什么呢?这很可能说明 Erlang 在同一个 VM 内发送 binary 类型的消息时,采用的是传递引用的方式。
这是第二个版本。
上面提到 “同一个VM的内部的发送”,那么,如果我们的消息需要发到另一个 VM,又会发生什么呢?我们稍微改改上一个版本,让发送进程在另外一个节点(也就是另外一个VM)上运行。
-export([test/1]).
-define(DATA, <<"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef\
0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcde\
f0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcd\
ef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abc\
def0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789ab\
cdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789a\
bcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789\
abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef012345678\
9abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef01234567\
89abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456\
789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef012345\
6789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef01234\
56789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123\
456789abcdef">>).
test(N) ->
Pid = self(),
{ok, _} = slave:start_link(chaos, tsend),
spawn_link(tsend@chaos, fun() -> sender(N, Pid) end),
receiver(now()).
sender(0, _) ->
ok;
sender(N, Pid) ->
Pid ! ?DATA,
sender(N-1, Pid).
receiver(T1) ->
receive
{'EXIT', _, _} ->
io:format("time pass:~p~n", [timer:now_diff(now(), T1)]);
_Any ->
receiver(T1)
end.
再来测试:
erl +c +K true -sname test -eval "tsend:test(1048576), c:q()." -noshell
time pass:59080936
31倍速的魔法消失了。这一次,1M条消息的发送,花了59秒,也就是说,在跨 VM 发送的情况下,binary 的数据发送无法继续传引用,因而在发送时应该是传递拷贝。此时的发送速度是 17.3K条/s 。数据流速为 17.3K * 1K = 17.3M/s。
这是第三个版本。
如果再改成 list 又会怎样?(去掉 <<>> 即可)
erl +c +K true -sname test -eval "tsend:test(1048576), c:q()." -noshell
time pass:79264072
老机器上,又是一顿猛跑……。这一次花了 79 秒,发送速度为 12.9K条/s 。此时,数据的流速为 12.9K * 4K = 51.7M/s。
需要说明的是:我的机器很老旧,优化选项也没怎么弄,所以,上述结果的参考价值不大。各位有兴趣,可以自己跑一跑。
按照最差的 12.9K条/s 算,每次通过网络发送只用 0.075 毫秒。其实,这样的数据已经非常令人满意。
通过这个测试,我们至少可以知道如下两点:
1,节点之间发送消息,是发送拷贝,是有代价的,如果可能尽量在单一节点内搞定。
2,在一个节点的内部,发送 binary 的效率太高了,应该尽量利用之。
说回 Joel Raymond 的“悬赏”。相比他的测试场景,同样都是跨网络,但他的测试场景之中,需要向更多的并行进程发送,而发送的循环,则是对 list 的 for each 操作,可以预计,要比我这个简单的尾递归更加耗时。此外,他的发送是通过 socket 设施进行的,这里又意味着更多的CPU运算。从这个角度,考虑到他的机器是双核 2G,就按比我的机器快 3 倍来估算,17.1K * 4 = 68.4K/s。单机每秒 50~60K 数量级的广播,有可能会是 for each 这种广播方式的上限。
那么,又有什么方式能够突破这一限制呢?相信这会是一个更有趣的问题。欢迎大家实验/讨论。
1. binary>64字节的时候才是传递引用。
2. erl -smp disable比 erl快很多, 因为没有锁开销。
3. 测试3是通过node间的tcp通讯进行的, 数据在一个tcp连接中流动,所以系统可以累积包, 一次发送多个包,所以速度快。
4. joel的测试是20K个不同的tcp连接, send的开销是几十个us级别的 发送消息的开销是1us左右, 所以joel的测试是测试io的性能和系统在大压力下的延迟时间,这个涉及到系统调度和IO调度的问题, 是很复杂的,和机器的配置和优化很大的关系。而且因为erlang的公平调度原则,所以的计算和IO都是平均分配的, 容易造成大面积的延迟。 其实这个广播在driver层面做就非常的快速,我曾经做过这样的广播, 单机广播6-7W比较轻松。
http://blog.javaeye.com
joel的测试是单CPU, 而且是beam模式, 而不是带锁的beam.smp, 这个性能差很多的。。。
http://blog.javaeye.com
看了代码才知道 去往IO的binary 只有》256字节的时候是引用计数的. 参看ERL_SMALL_IO_BIN_LIMIT = (4 * ERL_ONHEAP_BIN_LIMIT) = 4 * 64= 256
总结一下 yufeng 的观点:
1, erl -smp disable 大有关系(我的古董机为单核,未涉及)。
2, 源码表明 binary > 256 会产生传引用的行为。
3, node 之间的 TCP 传递可以累积,一次发送多个包。
很有营养的认识。:D
把 broadcast 放到 driver 层面来做似乎是个靠谱的想法。然而,以 yufeng 之前的实践经验来看,单机 6w-7w/s 的广播性能,与之前根据 erlang 发包效能所做的性能推测(50K-60K/s),似乎并没有我想象中的那么高(我的意思是,比如说,我原本以为会快上一两个数量级之类的)。这是否说明:
1,Erlang 的发送其实效率已经非常高,在 for each 的方式下,与底层 driver 方案之间的性能差异已经差别不大(至少不会有一个数量级之类的差异)?
2,现有 for each 的发送方式,无论是在 Erlang 里做还是在 driver 里做,其极限恐怕也差不多就是这么多了?(当然,大小为 1K 的数据包,要在 1s 以内广播给 50K-60K 的客户端,这本身也是一种极端场景)
根据 Erlang 的公平调度原则来推论,这确实很容易导致大面积的延迟(比如,在广播数量大于某个值之后,平均延迟会与之呈现出类似指数曲线的相关性)。
我现在很好奇,除了 driver 层方案(外包给更高效率的底层代码)之外,还有什么方式可供考虑呢?
发送包的最大开销有3部分 1. send系统调用(30us) 2. 内存拷贝(0.xxxus) 3. 包拼凑。 也是说最大的开销在send。其他的都是小头,优化无意义的。 正确的思路是减少 send调用的次数,积累包,在系统可写的时候一次写出去. erlang已经提供了这个功能。delay_send这个选项就是解决这个问题的。 对于erlang的erts, 如何用proc_bin的refc减少内存copy,gen_tcp的发送是用消息确认发送成功的,这个消息可以和主loop一起处理(rabbitmq和joel的程序里面都这么做了), 还有io调度和process调度的时间的微调(可以参考erl +T参数,改变ioinput_reduction和context_reds的时间), gc什么时候参与内存回收, 内存和cpu占用的平衡, 是个值得研究的内容。只有这些问题都能做到最好,那么我们的服务器程序只有顶级的c程序员可以追随。
目前我在研究这些,有兴趣的同学一起来哦。
单单写这种程序的话, 平均程序员写的erlang程序至少可以达到c的百分之大几十, 所以应该很有竞争力。
我的意思是,遵循 Erlang 所提倡的“小消息,大运算”思路,那么是不是有可能找到某种方式/结构,减小“广播”业务之中的消息发送数量,或者说“不依赖于 send 来实现广播”。
现在的广播,是用 for each 的方式来发送,一个广播意味着 X 次 send 调用,这里的 X 等于用户数量。那么,是否有可能采用某种底层设施(比如说 driver)来执行广播,此时,只需要发送一条消息,其内容为:接收者列表+消息内容,然后交由具体的底层设施来做实际的发送动作(X = 1)。此前 yufeng 提到 C 写的广播也只能做到 6-7w 。那么在发送性能上,两者的差别其实并不是太显著,所以,实际上已经失去了这么做的意义。
另外一个思路是通过多机集群来实现,比如,将每台单机的用户数量控制在一定的数量上(N),由 M 台机器来实现整个集群。广播时,对每台机器只需发送一条消息(M条),再由各自的机器分别对 N 个用户广播(X = M * N)。如此,应当可以实现对庞大用户数量的广播。但,如果 N 的值并不够大,在成本的考量上可能并不理想,此乃后话,另当别论。
是否还有其他的思路?不妨讨论讨论。
rabbitmq就是这么做的, 广播消息发到节点, 由节点负责对该节点的所有组里的进程广播,所以扩展能力很强!
看了你们的讨论,我总结一下
大量的消息广播:
1,使用binary
2,分布到多台处理
考虑到实际应用中这类的消息并不需要很及时,
可以在正常发送给client的时候附带发送
比如每个Node有个ets表存储广播的消息,用户进程记录上次发送的广播ID,以此来对比是否有新的广播
广播消息用多播来做比较好一点吧
互联网搜是你家局域网?