在第一篇中,我们构建了一个 mochiweb Comet应用程序,它以每10秒给客户端发送消息. 另外我们调整了 Linux内核,以及构建了一个工具来建立大量连接用于测试性能和内存开销. 而且我们发现每个连接需要45KB .
第二篇是关于把应用程序变得实用以及节省内存:
- 用一组login/logout/send API实现一个消息路由器
- 修改 mochiweb应用程序使其从路由器接收消息
- 安装一个分布式erlang 系统使我们可以在不同主机和节点运行路由器
- 写个工具用于给路由器发送大量无用消息
- 24小时运行后的内存开销图表, 为节省内存优化 mochiweb应用程序.
这意味我们要解析从mochiweb应用程序发送消息的逻辑机制。利用第1部分的floodtest工具,可以建立更接近产品级的基准测试。
实现消息路由器
路由器API就是3个函数
- login(Id, Pid) 注册一个进程 (of pid Pid) 用来给Id接收消息
- logout(Pid) 用来停止接收消息
- send(Id, Msg) 给任意以Id登陆用户发送消息 Msg
注意以上所述,从设计角度看,一个进程可以用多个不同Id登陆。
这个例子中路由器模块用2张ets表用于保存Pids和ids之间的双向映射(pid2id和 id2pid 在 #state的记录如下.)
router.erl:
- -module(router).
- -behaviour(gen_server).
- -export([start_link/0]).
- -export([init/1, handle_call/3, handle_cast/2, handle_info/2,
- terminate/2, code_change/3]).
- -export([send/2, login/2, logout/1]).
- -define(SERVER, global:whereis_name(?MODULE)).
- % will hold bidirectional mapping between id <–> pid
- -record(state, {pid2id, id2pid}).
- start_link() ->
- gen_server:start_link({global, ?MODULE}, ?MODULE, [], []).
- % sends Msg to anyone logged in as Id
- send(Id, Msg) ->
- gen_server:call(?SERVER, {send, Id, Msg}).
- login(Id, Pid) when is_pid(Pid) ->
- gen_server:call(?SERVER, {login, Id, Pid}).
- logout(Pid) when is_pid(Pid) ->
- gen_server:call(?SERVER, {logout, Pid}).
- %%
- init([]) ->
- % set this so we can catch death of logged in pids:
- process_flag(trap_exit, true),
- % use ets for routing tables
- {ok, #state{
- pid2id = ets:new(?MODULE, [bag]),
- id2pid = ets:new(?MODULE, [bag])
- }
- }.
- handle_call({login, Id, Pid}, _From, State) when is_pid(Pid) ->
- ets:insert(State#state.pid2id, {Pid, Id}),
- ets:insert(State#state.id2pid, {Id, Pid}),
- link(Pid), % tell us if they exit, so we can log them out
- io:format("~w logged in as ~w/n",[Pid, Id]),
- {reply, ok, State};
- handle_call({logout, Pid}, _From, State) when is_pid(Pid) ->
- unlink(Pid),
- PidRows = ets:lookup(State#state.pid2id, Pid),
- case PidRows of
- [] ->
- ok;
- _ ->
- IdRows = [ {I,P} || {P,I} <- PidRows ], % invert tuples
- % delete all pid->id entries
- ets:delete(State#state.pid2id, Pid),
- % and all id->pid
- [ ets:delete_object(State#state.id2pid, Obj) || Obj <- IdRows ]
- end,
- io:format("pid ~w logged out/n",[Pid]),
- {reply, ok, State};
- handle_call({send, Id, Msg}, _From, State) ->
- % get pids who are logged in as this Id
- Pids = [ P || { _Id, P } <- ets:lookup(State#state.id2pid, Id) ],
- % send Msg to them all
- M = {router_msg, Msg},
- [ Pid ! M || Pid <- Pids ],
- {reply, ok, State}.
- % handle death and cleanup of logged in processes
- handle_info(Info, State) ->
- case Info of
- {‘EXIT’, Pid, _Why} ->
- % force logout:
- handle_call({logout, Pid}, blah, State);
- Wtf ->
- io:format("Caught unhandled message: ~w/n", [Wtf])
- end,
- {noreply, State}.
- handle_cast(_Msg, State) ->
- {noreply, State}.
- terminate(_Reason, _State) ->
- ok.
- code_change(_OldVsn, State, _Extra) ->
- {ok, State}.
更新mochiweb应用程序
假定一个用户用一个基于他们接入mochiweb 网址的整数Id表示,接着用这个id和消息路由器注册。而不是阻塞10秒后发送消息,mochiweb循环将阻塞从路由器接收消息,并为路由器发送的每封邮件发送一个HTTP数据块给客户端:
- 客户端用http://localhost:8000/test/123连接到mochiweb
- Mochiweb的 pid注册器用于连接id ‘123′的消息路由器
- 如果往标记为id‘123’的路由器发送消息的话,这将传达给正确的mochiweb进程,并出现在那个用户的浏览器中
以下是mochiconntest_web.erl的更新版本
- -module(mochiconntest_web).
- -export([start/1, stop/0, loop/2]).
- %% External API
- start(Options) ->
- {DocRoot, Options1} = get_option(docroot, Options),
- Loop = fun (Req) ->
- ?MODULE:loop(Req, DocRoot)
- end,
- % we’ll set our maximum to 1 million connections. (default: 2048)
- mochiweb_http:start([{max, 1000000}, {name, ?MODULE}, {loop, Loop} |Options1]).
- stop() ->
- mochiweb_http:stop(?MODULE).
- loop(Req, DocRoot) ->
- "/" ++ Path = Req:get(path),
- case Req:get(method) of
- Method when Method =:= ‘GET’; Method =:= ‘HEAD’ ->
- case Path of
- "test/" ++ Id ->
- Response = Req:ok({"text/html; charset=utf-8",
- [{"Server","Mochiweb-Test"}],
- chunked}),
- % login using an integer rather than a string
- {IdInt, _} = string:to_integer(Id),
- router:login(IdInt, self()),
- feed(Response, IdInt, 1);
- _ ->
- Req:not_found()
- end;
- ‘POST’ ->
- case Path of
- _ ->
- Req:not_found()
- end;
- _ ->
- Req:respond({501, [], []})
- end.
- feed(Response, Id, N) ->
- receive
- {router_msg, Msg} ->
- Html = io_lib:format("Recvd msg #~w: ‘~s’", [N, Msg]),
- Response:write_chunk(Html)
- end,
- feed(Response, Id, N+1).
- %% Internal API
- get_option(Option, Options) ->
- {proplists:get_value(Option, Options), proplists:delete(Option, Options)}.
动起来!
现在让我们给它加点活力-我们将使用2个erlang shells,一个用于mochiweb另一个用于路由器。编辑start-dev.sh ,用于启动mochiweb ,并给erl新增下列参数:
- -sname n1 用于命名erlang 节点为 ‘n1′
- +K true 打开kernel-poll 。
- +P 134217727是 缺省的最大进程数,可以产生进程的数量是32768,考虑到我们给每个连接分配一个进程(不知道有什么更好理由可以不这样做)这只是设置的最大可行值。 134,217,727是“erl”手册中的最大值
现在运行 make && ./start-dev.sh ,会看到下面的提示: (n1@localhost)1> - 你的mochiweb 应用程序正在运行,而且这个erlang节点有个名字。.
现在运行另一个erlang shell如下:
erl -sname n2
目前这两个erlang实例并不能互相通讯,需要修正这个问题
(n2@localhost)1> nodes().
[]
(n2@localhost)2> net_adm:ping(n1@localhost).
pong
(n2@localhost)3> nodes().
[n1@localhost]
现在编译,再从以下shell启动路由器
(n2@localhost)4> c(router).
{ok,router}
(n2@localhost)5> router:start_link().
{ok,<0.38.0>}
为了有趣点,在浏览器中连接 http://localhost:8000/test/123 (或在控制台使用 lynx --source "http://localhost:8000/test/123" ). 检查你启动路由器的 shell ,你会看到有一个用户登陆了.
现在你可以给路由器发送消息及在你浏览器中观察它们。目前只是传送字符串,因为在种子函数中我们使用~s,通过 io_lib:format 格式化它们。
再借用一下启动路由器的shell
(n2@localhost)6> router:send(123, "Hello World").
(n2@localhost)7> router:send(123, "Why not open another browser window too?").
(n2@localhost)8> router:send(456, "This message will go into the void unless you are connected as /test/456 too").
检查你的浏览器,你将获得彗星 :)
在分布式erlang系统内运行
在不同机器上运行路由器和mochiweb前端是很平常的。假设你有两个备用机器来做测试,你应该用分布式节点启动的erlang的shells,即用-name n1@host1.example.com替换 -sname n1 (n2也同样处理),用 上面net_adm:ping(...) 确保它们之间可以相互通讯。
请注意router.erl16行,用于全局注册路由器进程( '路由器' )的名字,因为我们用以下宏来标记/定位路由器调用gen_server,这已经可以很好的运行在分布式系统环境下。
-define(SERVER, global:whereis_name(?MODULE)).
在一个分布式系统内给进程注册一个全局名只是Erlang的免费午餐之一。
生成大量消息
在实际环境中我们可能会看到一个长尾模式,有一些非常活跃的用户和许多不活跃的用户。
但这个测试,我们只是给随机用户平均的伪造消息并发送
msggen.erl:
- -module(msggen).
- -export([start/3]).
- start(0, _, _) -> ok;
- start(Num, Interval, Max) ->
- Id = random:uniform(Max),
- router:send(Id, "Fake message Num = " ++ Num),
- receive after Interval -> start(Num -1, Interval, Max) end.
这将给ID在1和Max之间的随机用户发送 Num 个消息,每个发送之间按Interval毫秒等待。
如果运行路由器和mochiweb应用程序可以看到以下动作,用浏览器连接http://localhost:8000/test/3 然后运行:
erl -sname test
(test@localhost)1> net_adm:ping(n1@localhost).
pong
(test@localhost)2> c(msggen).
{ok,msggen}
(test@localhost)3> msggen:start(20, 10, 5).
ok
这将给1-5之间随机Id发送20个消息,消息之间间隔时间为10ms .机会是Id3将收到一个或4个消息, Chances are Id 3 will receive a message or four.
甚至可以并行模拟多个消息源。以下例子产生10个进程,每个进程给1-5之间的Id发送20个消息,不同消息间延迟100ms。
[ spawn(fun() -> msggen:start(20, 100, 5), io:format("~w finished./n", [self()]) end) || _ <- lists:seq(1,10) ].
[<0.97.0>,<0.98.0>,<0.99.0>,<0.100.0>,<0.101.0>,<0.102.0>,
<0.103.0>,<0.104.0>,<0.105.0>,<0.106.0>]
<0.101.0> finished.
<0.105.0> finished.
<0.106.0> finished.
<0.104.0> finished.
<0.102.0> finished.
<0.98.0> finished.
<0.99.0> finished.
<0.100.0> finished.
<0.103.0> finished.
<0.97.0> finished.
再次感受C10K
现在我们需要运行另一个较大规模的测试;客户端连接到我们的mochiweb应用,用消息路由器注册它们。我们可以生成大量的虚假信息来火攻路由器,这将给任何注册客户发送消息。 让我们再次运行第1部分的10000个并发用户的测试,但这一次当我们通过该系统爆发大量消息时将让所有连接的客户等一段时间。
假定按第一部分的步骤调整内核及增加最大打开文件数ulimit,这些很容易。mochiweb应用和路由器已经在运行,接着让我们输出更多有关流量的信息。
没有任何连接客户时, mochiweb beam进程使用约40MB (常驻) :
$ ps -o rss= -p `pgrep -f 'sname n1'`
40156
这些greps是对进程ID用’sname n1′参数 命令,这是我们mochiweb erlang的进程,然后使用某些格式选项用于ps用于打印RSS的数值-常驻内存大小(KB)
本人炮制的这一惊人的桉一个班轮打印时间戳(可读的及我们后面需要的unixtime) ,目前mochiweb的内存开销(常驻KB)及每60秒设置连接的数量 –放下这个运行mochiweb机器用一个备用终端:
$ MOCHIPID=`pgrep -f 'name n1'`; while [ 1 ] ; do NUMCON=`netstat -n | awk ‘/ESTABLISHED/ && $4==”127.0.0.1:8000″‘ | wc -l`; MEM=`ps -o rss= -p $MOCHIPID`; echo -e “`date`/t`date +%s`/t$MEM/t$NUMCON”; sleep 60; done | tee -a mochimem.log
如果有谁知道有更好的方式给出一个进程随时间推移的内存开销信息的话,请发表评论..
现在启动这第一部分中的floodtest工具:
erl> floodtest:start("/tmp/mochi-urls.txt", 10).
这将以每秒100个新连接的速度创建连接,直到有10,000个连接的客户端。你将看到很快就达到10K个连接。
erl> floodtest:start("/tmp/mochi-urls.txt", 10).
Stats: {825,0,0}
Stats: {1629,0,0}
Stats: {2397,0,0}
Stats: {3218,0,0}
Stats: {4057,0,0}
Stats: {4837,0,0}
Stats: {5565,0,0}
Stats: {6295,0,0}
Stats: {7022,0,0}
Stats: {7727,0,0}
Stats: {8415,0,0}
Stats: {9116,0,0}
Stats: {9792,0,0}
Stats: {10000,0,0}
...
检查每行输出的内存开销:
Mon Oct 20 16:57:24 BST 2008 1224518244 40388 1
Mon Oct 20 16:58:25 BST 2008 1224518305 41120 263
Mon Oct 20 16:59:27 BST 2008 1224518367 65252 5267
Mon Oct 20 17:00:32 BST 2008 1224518432 89008 9836
Mon Oct 20 17:01:37 BST 2008 1224518497 90748 10001
Mon Oct 20 17:02:41 BST 2008 1224518561 90964 10001
Mon Oct 20 17:03:46 BST 2008 1224518626 90964 10001
Mon Oct 20 17:04:51 BST 2008 1224518691 90964 10001
达到了10K并发连接(另外加一个用firefox打开的连接)mochiweb常驻内存大小在90MB(90964KB)
现在释放一些信息:
erl> [ spawn(fun() -> msggen:start(1000000, 100, 10000) end) || _ <- lists:seq(1,100) ].
[<0.65.0>,<0.66.0>,<0.67.0>,<0.68.0>,<0.69.0>,<0.70.0>,
<0.71.0>,<0.72.0>,<0.73.0>,<0.74.0>,<0.75.0>,<0.76.0>,
<0.77.0>,<0.78.0>,<0.79.0>,<0.80.0>,<0.81.0>,<0.82.0>,
<0.83.0>,<0.84.0>,<0.85.0>,<0.86.0>,<0.87.0>,<0.88.0>,
<0.89.0>,<0.90.0>,<0.91.0>,<0.92.0>,<0.93.0>|...]
即100个进程每个以每秒10个消息的速度给1到10,000.随机Id发送,也就是说路由器将看到每秒1000个消息, 10K中的每个客户平均将每10秒钟得到一个消息
检查floodtest shell的输出,会看到客户端正在接受的http数据块(记住是{NumConnected, NumClosed, NumChunksRecvd})
...
Stats: {10000,0,5912}
Stats: {10000,0,15496}
Stats: {10000,0,25145}
Stats: {10000,0,34755}
Stats: {10000,0,44342}
...
100万个消息以每秒每个进程10个的速度将需要27小时才能完成。以下是刚过10分钟的内存开销:
Mon Oct 20 16:57:24 BST 2008 1224518244 40388 1
Mon Oct 20 16:58:25 BST 2008 1224518305 41120 263
Mon Oct 20 16:59:27 BST 2008 1224518367 65252 5267
Mon Oct 20 17:00:32 BST 2008 1224518432 89008 9836
Mon Oct 20 17:01:37 BST 2008 1224518497 90748 10001
Mon Oct 20 17:02:41 BST 2008 1224518561 90964 10001
Mon Oct 20 17:03:46 BST 2008 1224518626 90964 10001
Mon Oct 20 17:04:51 BST 2008 1224518691 90964 10001
Mon Oct 20 17:05:55 BST 2008 1224518755 90980 10001
Mon Oct 20 17:07:00 BST 2008 1224518820 91120 10001
Mon Oct 20 17:08:05 BST 2008 1224518885 98664 10001
Mon Oct 20 17:09:10 BST 2008 1224518950 106752 10001
Mon Oct 20 17:10:15 BST 2008 1224519015 114044 10001
Mon Oct 20 17:11:20 BST 2008 1224519080 119468 10001
Mon Oct 20 17:12:25 BST 2008 1224519145 125360 10001
可以看到内存开销已经悄悄从40MB赠加到了90MB,当所有10K的客户端都连接上来后,运行一段时间后就增加到了125MB。
值得指出的是, floodtest shell几乎是受制于CPU的,msggen shell是2 %的CPU利用率,路由器和mochiweb的CPU利用率小于1 % (即模拟大量客户端用了很多CPU-服务器应用本身在CPU开销上是很轻的)。它有助于有多个机器,或多核CPU来进行测试。
运行24小时后的结果
运行24小时后,mochiweb进程将把内存开销信息生成到mochimem.log文件中 。这是与10000连接的客户端级每秒1000个消息被发送到随机的客户产生的信息。
以下bash/ awk代码是用gnuplot把收集的mochimem.log文件转换成图表的小技巧:
(echo -e "set terminal png size 500,300/nset xlabel /"Minutes Elapsed/"/nset ylabel /"Mem (KB)/"/nset title /"Mem usage with 10k active connections, 1000 msg/sec/"/nplot /"-/" using 1:2 with lines notitle" ; awk 'BEGIN{FS="/t";} NR%10==0 {if(!t){t=$2} mins=($2-t)/60; printf("%d %d/n",mins,$3)}' mochimem.log ; echo -e "end" ) | gnuplot > mochimem.png
10K个 连接客户的内存开销, 1000msg/sec, 24hrs
这张图展示了内存开销(在10k个活跃连接及1000 msgs/sec条件下),
有两个大的下降,一个在测试开始附近,一个在测试结束附近,这是我在运行mochiweb的erlang进程测试时偶然发现的
erl> [erlang:garbage_collect(P) || P <- erlang:processes()].
以上将迫使所有的进程进行垃圾收集,回收到了100MB内存左右-下篇我们将调查一些方法可以以不用手动强迫垃圾收集的方式来节省内存。
在mochiweb中减少内存开销
我们看到mochiweb应用程序只是发送消息,然后立即丢弃它们,内存开销不应该随着发送消息数量增加而增加。
遇到Erlang的内存管理我的经验也是有限,但我认为,如果我能迫使它垃圾收集的更频繁,这将使我们能够回收大部分的内存并最终让我们用较少整体系统内存为更多的用户服务。这样我们可能有一点CPU开销,但这是一个可接受的权衡
深入erlang文档中yields此选项:
erlang:system_flag(fullsweep_after, Number)
Number是一个非负整数,它表明产生了多少次垃圾回收。这个数值将应用到新的进程中,对已经运行的进程不受影响。
在低内存系统(特别是没有虚拟内存的),设定为0能帮助节省内存。
一个变通的方法是通过(操作系统)环境变量ERL_FULLSWEEP_AFTER.来设定
听起来有意思,但它仅适用于新的进程,及将影响在VM中的所有进程,不只是我们的mochiweb进程。
接下来:
erlang:system_flag(min_heap_size, MinHeapSize)
对进程设置默认最小堆的大小。大小以words为单位。新的min_heap_size只影响修改min_heap_size后产生的进程,通过使用spawn_opt/N or process_flag/2.对独立进程设置min_heap_size。
可能有用,但我敢肯定我们的mochiweb进程需要比默认更大的堆。我尽量避免需要修补mochiweb代码来添加生成选项
下一个抓眼球的:
erlang:hibernate(Module, Function, Args)
使调用进程进入等待状态时其内存分配会尽量减少,如果进程不久将来不指望得到任何消息。这个就有用
当一个消息发送给进程,它将被唤醒,并将空调用堆栈中由Args给定参数的Module:Function恢复控制,也就是说,当这个函数返回时这个进程将终止。所以 erlang:hibernate/3 将永远不会返回到调用它的地方.
如果进程在其消息队列中有任何消息,进程将以上述同样的方式立即被唤醒
更专业一些,erlang:hibernate/3做了以下几点:它摒弃了进程的调用堆栈。然后,对进程垃圾收集。在垃圾收集后,所有实时数据是在一个连续堆中。然后缩小堆到它要的精确大小(即使大小低于最低的堆大小) 。
如果进程中实时数据的大小低于最低堆大小,在进程唤醒后第一次垃圾收集后将确保堆的大小变成不会比最小堆更小的大小
请注意空调用堆栈意味着,任何围绕追赶被删除并在休眠后重新被插入。一个效果是使用proc_lib启动进程 (还有间接的,如gen_server进程) ,应使用proc_lib:hibernate/3以确保异常处理程序持续运行到进程唤醒。
这听起来合理 – 让我们在每个消息后试着休眠然后看会发生什么.
编辑 mochiconntest_web.erl,以下为修改内容:
- 制作最后一行种子(Response, Id, N)函数调用休眠状态而不是自调用
- 登录到路由器后立即调用休眠,而不是种子和阻塞接收
- 请记住导出feed/3 ,这样休眠状态可以在函数唤醒时调用返回
更新mochiconntest_web.erl用于不同消息之间的休眠状态:
- -module(mochiconntest_web).
- -export([start/1, stop/0, loop/2, feed/3]).
- %% External API
- start(Options) ->
- {DocRoot, Options1} = get_option(docroot, Options),
- Loop = fun (Req) ->
- ?MODULE:loop(Req, DocRoot)
- end,
- % we’ll set our maximum to 1 million connections. (default: 2048)
- mochiweb_http:start([{max, 1000000}, {name, ?MODULE}, {loop, Loop} |Options1]).
- stop() ->
- mochiweb_http:stop(?MODULE).
- loop(Req, DocRoot) ->
- "/" ++ Path = Req:get(path),
- case Req:get(method) of
- Method when Method =:= ‘GET’; Method =:= ‘HEAD’ ->
- case Path of
- "test/" ++ IdStr ->
- Response = Req:ok({"text/html; charset=utf-8",
- [{"Server","Mochiweb-Test"}],
- chunked}),
- {Id, _} = string:to_integer(IdStr),
- router:login(Id, self()),
- % Hibernate this process until it receives a message:
- proc_lib:hibernate(?MODULE, feed, [Response, Id, 1]);
- _ ->
- Req:not_found()
- end;
- ‘POST’ ->
- case Path of
- _ ->
- Req:not_found()
- end;
- _ ->
- Req:respond({501, [], []})
- end.
- feed(Response, Id, N) ->
- receive
- {router_msg, Msg} ->
- Html = io_lib:format("Recvd msg #~w: ‘~w’<br/>", [N, Msg]),
- Response:write_chunk(Html)
- end,
- % Hibernate this process until it receives a message:
- proc_lib:hibernate(?MODULE, feed, [Response, Id, N+1]).
- %% Internal API
- get_option(Option, Options) ->
- {proplists:get_value(Option, Options), proplists:delete(Option, Options)}.
修改后,重新构建mochiweb, 再重新做 c10k 测试 (24小时1000消息/秒).
Results after running for 24 hours w/ proc_lib:hibernate()
Memory usage with c10k, 1000msg/sec, 24hrs, using hibernate()
明智地利用hibernate 意味着在10K连接下mochiweb应用程序常驻内存级别在78MB,大大优于我们在第1部分中看到的450MB。而且没有显着增加CPU开销
总结
我们开发了基于Mochiweb的一个comet应用 ,向用整数Id标记的用户发送任意消息。经过24小时给10,000个连接用户,每秒发送1000消息,我们观察到使用了80MB内存,即每个用户8KB。还做了些很漂亮的图表
这是个很大的改进相对于第1部分中我们看到的每用户使用45KB。这个节省是由于使应用程序的行为更接近实际应用,以及在mochiweb进程的各个消息间使用 hibernate
下一步
第3部分(即将推出) ,将优化成100万个连接客户端。在有大量内存及多CPU的64位服务器上部署测试应用程序 。比较有哪些区别,如果有的话,在64位虚拟机上运行。我还会详细介绍一些额外的技巧和调整需要以模拟100万客户端连接。
应用程序将演变成一种公用子系统,订阅相关用户ID及由应用程序存储,而不是当他们连接时由客户提供。我们将使用一个典型的社会网络数据集:朋友。这将允许一个用户用Id登录和自动接收一个朋友产生的任何一个事件。