Erlang RunTime System の闇(闇というほどのものでもないけど)

みなさんご存知かもしれませんが、*nixではひとりのユーザーが同時にオープンできるファイルディスクリプタの数の上限がきまっていたりします。もっと有り体にいうと、ひとつの*nixプロセスで動作するファイルディスクリプタの上限が制限されてしまうというのが一番の問題。いくらC10Kに耐えられるようなスーパープログラムを書いたところで、こいつの初期値は1024とか256だったりするので「あり?おかしいな?」とかいうのは誰もが通る道だと思います*1
で、まあやっぱりErlangのはなしになります。ErlangでC10KなTCPサーバー書いたところでこの問題は避けて通れません。そいでsudo ulimit -n 1000000とかやっちゃいますよね。当然です。
とはいえ、そうしたらなぜかしらん、erlコマンド叩いてerts起動しただけでメモリを500MBを食う[要検証]との報告があったので、一体なんぞやということで調査したのであります。
サーバープログラムっていうのは、途中でリソースが足りなくなろうがどうしようが動き続けなければならないのです。メモリが足りなくなってもディスクが足りなくなっても、これまでにconnectしているクライアントにはサービスを提供するし、今持っているデータは絶対に壊さない。そういう風に作らないといけないので、よくやるのが「最初にぜーんぶ予約しておいて後でOSに怒られることがないようにしておく」という戦略。メモリプールとかコネクションプールとかいったりしますね。こいつのメリットは、いちいちOSに返したりしないのでその分のオーバーヘッドがかからないというのもありますが、予め環境の限界をプログラムに教えておくことで不用意な障害を防ぐこともできたりします。
それで、ですね。

まずは結論

erlの残念なところ。どうもユーザの最大FD数に比例してメモリを確保するみたいなんですよ。これで何が困るかっていうと、同じユーザで適当にerl叩いて管理系コマンド(様子見とか、一時停止とか?)叩こうとするだけで余分なメモリを大量に確保してくれちゃったりする。困る。というわけでソースを調べてい…という前に、まずは結論から。

erlコマンドを叩く環境変数にERL_MAX_PORTS = 1024などとしておくこと

こうすれば、システムが最大FD数を観に行って大量確保なんてこともないみたいです。とはいえ、なんでこういうことになるのか全然分からんですよね。管理系コマンドなんだからちょっと叩いて帰ってきてくれればいいじゃん。

解決編

ertsの発想は簡単です。ネットワークサーバー書くってーとなると、TCPなんかだと簡単にFDを大量消費します。FD使いまくるのは別にいいのですが、ERTSはOSのFD使って、Erlangの軽量プロセスと親和性の高い独自の非同期I/Oシステムを持っています。なので、それに関連してFD当たりのランタイムのオーバーヘッドを可能な限り下げるために、起動時にメモリをガッツリとる、なんていうのは容易に想像がつきます。なので、その辺りのことを適当に調べます。最初に疑ったのは「そもそもソケットだけ最初に全部確保しちまってるのか?!」*2ってことでsocket(2)を読んでいるところを探します。

$ pwd
/usr/src/otp
$ find . -print | grep "\.c$" | xargs grep socket

とすると山のように出てくるので、それっぽいところをじーっと見てみましょう。本気でソースコード読むときはcscopeEmacsの組み合わせが最強なのですが今回はちょこちょこっと調べるだけなので省略します。
と思いきや、検索語がけっこう悪い。nofileとかulimitとかでいろいろ調べてみてうーんと唸ったりします。それっぽいファイルをじーっと眺めてフリーズしたりします。そうすると天啓が降って来るので、

$ find . -print | xargs grep max_ports

とかやると

./erts/emulator/beam/bif.c: sizeof(Eterm)*erts_max_ports);

なんてのが最初に出てきます。キターって感じですね。どうみてもmallocの引数です。本当にありがとうございました。で、この中をじーっと見ていくと

./erts/emulator/beam/io.c: for (i=0; i < erts_max_ports && res<0; ++i, ++num) {
./erts/emulator/beam/io.c: erts_max_ports = atoi(maxports);
./erts/emulator/beam/io.c: erts_max_ports = sys_max_files();
./erts/emulator/beam/io.c: if (erts_max_ports > ERTS_MAX_PORTS)
./erts/emulator/beam/io.c: erts_max_ports = ERTS_MAX_PORTS;
./erts/emulator/beam/io.c: if (erts_max_ports < 1024)
./erts/emulator/beam/io.c: erts_max_ports = 1024;
./erts/emulator/beam/io.c: if (erts_max_ports > ERTS_MAX_R9_PORTS)
./erts/emulator/beam/io.c: erts_max_ports = ERTS_MAX_R9_PORTS;
./erts/emulator/beam/io.c: port_extra_shift = erts_fit_in_bits(erts_max_ports - 1);
./erts/emulator/beam/io.c: erts_max_ports = 1 << port_extra_shift;

うわーこれっぽい。どうみても。いろいろ見てもよいのですがsys_max_files()という関数をさがします。

erts/emulator/sys/unix/sys.c:int sys_max_files(void)

あー、なるほど。erts/emulator/sysの下はOS毎の違いを吸収するコードが入っているのです。いかにもですね。これでファイルを見れば、ulimitをとってくる何か入ってるんだろうな!余裕だぜ!…と思ったら

int sys_max_files(void)
{
   return(max_files);
}

orz …あなどれません。んっじゃそっりゃあ。というわけで操作は振り出しに戻ります。しこしことmax_filesの出自を調べます。

./erts/emulator/sys/unix/sys.c: max_files = erts_check_io_max_files();

うん。よしよし。erts_check_io_max_files()という関数をみればいいんだな。じゃ、定義を探していくと…

shuna:otp kuenishi$ find . -print | xargs grep erts_check_io_max_files
./erts/emulator/sys/common/erl_check_io.c:ERTS_CIO_EXPORT(erts_check_io_max_files)(void)
./erts/emulator/sys/common/erl_check_io.h:int erts_check_io_max_files_kp(void);
./erts/emulator/sys/common/erl_check_io.h:int erts_check_io_max_files_nkp(void);
./erts/emulator/sys/common/erl_check_io.h:int erts_check_io_max_files(void);
./erts/emulator/sys/unix/sys.c: max_files = erts_check_io_max_files_kp();
./erts/emulator/sys/unix/sys.c: max_files = erts_check_io_max_files_nkp();
./erts/emulator/sys/unix/sys.c: max_files = erts_check_io_max_files();
./erts/emulator/sys/vxworks/sys.c: max_files = erts_check_io_max_files();

あり。なんだーそれわ。でerl_check_io.cをみてみると

#if defined(ERTS_KERNEL_POLL_VERSION)
#  define ERTS_CIO_EXPORT(FUNC) FUNC ## _kp
#elif defined(ERTS_NO_KERNEL_POLL_VERSION)
#  define ERTS_CIO_EXPORT(FUNC) FUNC ## _nkp
#else
#  define ERTS_CIO_EXPORT(FUNC) FUNC
#endif

おー。キモいことやっとるんやなぁ。それで*_kpとかヘンな関数ができるわけです。とするとやっぱりerts_check_ip_max_files()という関数の定義は

int
ERTS_CIO_EXPORT(erts_check_io_max_files)(void)
{
#ifdef  ERTS_SYS_CONTINOUS_FD_NUMBERS
    return max_fds;
#else
    return ERTS_POLL_EXPORT(erts_poll_max_fds)();
#endif
}

とかいうキモいものになるわけです。いや実にキモい。さてここで二択を強いられるわけです。max_fdsを追うかerts_poll_max_fdsとかいうキモいのを追うか。わたしは決心しました。max_****sというのをさんざん追ってきたので、こうなりゃとことんまでイッたろうと。というわけで!

./erts/emulator/sys/common/erl_poll.c:ERTS_POLL_EXPORT(erts_poll_max_fds)(void)
./erts/emulator/sys/common/erl_poll.c: return max_fds;
./erts/emulator/sys/common/erl_poll.c: max_fds = erts_vxworks_max_files;
./erts/emulator/sys/common/erl_poll.c: max_fds = sysconf(_SC_OPEN_MAX);

と思ったら、どっちもそれっぽいのを見つけた!そうこれでわたしの勝ち。sysconf(2)を見ればよいのです! くれぐれもERTS_POLL_EXPORT(erts_poll_max_fds)(void)を追ってはいけません。

int
ERTS_POLL_EXPORT(erts_poll_max_fds)(void)
{
    return max_fds;
}

終いにゃ怒るでしかし。いやまて。おれにはsysconf(2)がある。落ち着け。やっぱLinuxが大事だから、ググっておこう。お。見つけた。あり。sysconf(3)なんだな。まーいっか。

OPEN_MAX - _SC_OPEN_MAX
一つのプロセスが同時にオープンできるファイル数の上限。 _POSIX_OPEN_MAX (20) 未満であってはならない。

はい。これだね。念のためerl_poll.cもチェック。

void
ERTS_POLL_EXPORT(erts_poll_init)(void)
{
    erts_smp_spinlock_init(&pollsets_lock, "pollsets_lock");
    pollsets = NULL;

    errno = 0;

#if defined(VXWORKS)
    max_fds = erts_vxworks_max_files;
#elif !defined(NO_SYSCONF)
    max_fds = sysconf(_SC_OPEN_MAX);
#elif ERTS_POLL_USE_SELECT
    max_fds = NOFILE;
#else
    max_fds = OPEN_MAX;
#endif

#if ERTS_POLL_USE_SELECT && defined(FD_SETSIZE)
    if (max_fds > FD_SETSIZE)
	max_fds = FD_SETSIZE;
#endif

    if (max_fds < 0)
	fatal_error("erts_poll_init(): Failed to get max number of files: %s\n",
		    erl_errno_id(errno));

#ifdef ERTS_POLL_DEBUG_PRINT
    print_misc_debug_info();
#endif
}

erts_poll_initて書いてあるからこれなんだろうな。initてなまえなので、起動して初期化している間に実行される(はず)。erlというコマンドを叩くとErlang VMが立ち上がるわけだけど、たぶんそれの起動シーケンスのひとつ。ここでulimitの値をとってきていろんなところでメモリをとっておくロジックが働くわけです。ちなみにMacOSだとデフォルトは256なのでerlと叩いたところでメモリをガバっととられるなんてことはない。UbuntuとかDebian, RedhatLinuxでも、ふつうのユーザだと1024とかそんくらいじゃなかったけか。
というわけで、ここで探索打ち切り。
他にも自分向けメモ

  • ERTS_MAX_PORTSの環境変数は max_ports とかでgrepかけてるときに見つけた気がする
  • 他のアーキテクチャでどうなるかは知らん
  • grepしまくってるときに sizeof(Hoge) * erts_max_xxs);みたいなコードを沢山みかけたので、初期確保のメモリのうち、最大ファイル数にO(N)で比例するところが結構あるんだと思う。そこいらのヤワなLLなら「そんなの有り得ねー」ってことになるんだろうけど、ガチガチのシステムならそうやってもおかしくないよね
  • あといくつか闇っぽいコードみたけど見なかったことにしたいな(とくにfork/execまわり…)

まとめ

Erlangのランタイムシステムで、ユーザがオープン可能な最大FD数(ulimit -n)が起動時にメモリ麺で与える影響が大きいので、その下回りについて調査しました。らー麺。もとい。まとまってない。

謝辞

これはid:Voluntasにこっそり振られたネタでぼくも気になって調査してみました。実際に問題発見したりERTS_MAX_FILESの環境変数が効果アリとか証明してくれたのは彼でした。ありがとうございました*3

*1:ハマったことないだと!!キミ!すごいね! @voluntasに相談して転職だ!

*2:よく考えたらFDってソケットだけじゃないので当たらずとも遠からずってとこ

*3:とはいえ僕が答えをみつけたときには彼も答えにたどり着いていたみたいだけど