Ruby で String オブジェクトを複製しても、文字列データは複製されません。
data = "a"*10*1024*1024 system "grep ^VmSize /proc/#$$/status" t1 = Time.now a = [] 100.times do |i| a.push data.dup end t2 = Time.now system "grep ^VmSize /proc/#$$/status" printf "%.6f\n", t2-t1
実際に10MBの文字列を作って、100回dupする前後でプロセスのメモリサイズを比較してみても変わってません。
% ruby hoge.rb VmSize: 56140 kB VmSize: 56140 kB 0.000164
複製後に文字列を変更すると、そこで文字列データも複製されます。
data = "a"*10*1024*1024 system "grep ^VmSize /proc/#$$/status" t1 = Time.now a = [] 100.times do |i| s = data.dup s[0] = 'a' a.push s end t2 = Time.now system "grep ^VmSize /proc/#$$/status" printf "%.6f\n", t2-t1
プロセスサイズが増えてるのが確認できます。10MBオブジェクトが100個なので1GBほど増えてます。
VmSize: 56140 kB VmSize: 1080540 kB 0.337337
まあ、中身を変更したら複製されるのは当然なのですが、実は部分文字列を取り出すだけでも複製されてしまいます。
10MBの文字列のうち、先頭1MBを100回取り出します。
data = "a"*10*1024*1024 system "grep ^VmSize /proc/#$$/status" t1 = Time.now a = [] 100.times do |i| a.push data[0, 1024*1024] end t2 = Time.now system "grep ^VmSize /proc/#$$/status" printf "%.6f\n", t2-t1
100MBほどサイズが増えてしまいました。
VmSize: 56104 kB VmSize: 158904 kB 0.044682
なんでこんなことが起きるかというと、Ruby の String オブジェクトが内部で保持してる文字列データは NUL(\0) 終端されているからです。部分文字列の次のバイトを NUL にすると元の文字列が変わってしまうので、複製する必要があるのでした。
ちなみに、文字列末尾の取り出しでは複製されません。文字列末尾は NUL が次にあるからです。
data = "a"*10*1024*1024 system "grep ^VmSize /proc/#$$/status" t1 = Time.now a = [] 100.times do |i| a.push data[-1024*1024, 1024*1024] end t2 = Time.now system "grep ^VmSize /proc/#$$/status" printf "%.6f\n", t2-t1
VmSize: 56136 kB VmSize: 56136 kB 0.000061
イマイチだなーとツイートしたら、教えてもらえました。
@tmtms string.cのSHARABLE_MIDDLE_SUBSTRINGを1にするとコピーしなくなる
— なかだ の (@n0kada) May 27, 2016
SHARABLE_MIDDLE_SUBSTRING
は Ruby 2.2 で導入されたようです。
ということで、SHARABLE_MIDDLE_SUBSTRING=1
を設定してコンパイルしてみた Ruby で試してみます。
% cflags=-DSHARABLE_MIDDLE_SUBSTRING=0 ./configure % make install
VmSize: 56232 kB VmSize: 56232 kB 0.000072
おおー、メモリサイズは増えないし時間も掛かってないです。すばらしい。
もうこれデフォルトでいいのでは? と思ったらまた教えてもらいました。
@tmtms Stringは常にNUL-terminatedだという前提に依存している拡張ライブラリとの互換性
— なかだ の (@n0kada) May 28, 2016
Rubyの拡張ライブラリ中では RSTRING_PTR()
とか StringValuePtr()
で String オブジェクトから文字列データの先頭ポインタを取り出すことができるのですが、それが NUL 終端されていると仮定している拡張ライブラリがあるかもしれなくて、それが動かなくなってしまうからってことですね。確かにありそうです。
ということで、行儀のいい拡張ライブラリだけ使ってることが確実なのであれば、SHARABLE_MIDDLE_SUBSTRING=1
を使うと、もしかするとメモリサイズが小さくなって速くなる…ことがあるかもしれません。
追記
Ruby 2.3.1 で SHARABLE_MIDDLE_SUBSTRING=1
でコンパイルした Ruby で gem install が動きませんでした。
調べてみたら、ホスト名からIPアドレスを求める部分に問題があるようで、
TCPSocket.new("rubygems.global.ssl.fastly.net", 80)
は動くんだけど、
TCPSocket.new("rubygems.global.ssl.fastly.netX".chop, 80)
は動きませんでした。(getaddrinfo: Name or service not known)
該当部分のソースはこんな感じです。
[raddrinfo.c]
name = RSTRING_PTR(host); if (!name || *name == 0 || (name[0] == '<' && strcmp(name, "<any>") == 0)) { make_inetaddr(INADDR_ANY, hbuf, hbuflen); if (flags_ptr) *flags_ptr |= AI_NUMERICHOST; } else if (name[0] == '<' && strcmp(name, "<broadcast>") == 0) { make_inetaddr(INADDR_BROADCAST, hbuf, hbuflen); if (flags_ptr) *flags_ptr |= AI_NUMERICHOST; } else if (strlen(name) >= hbuflen) { rb_raise(rb_eArgError, "hostname too long (%"PRIuSIZE")", strlen(name)); } else { strcpy(hbuf, name); }
RSTRING_PTR()
で得られたポインタに対して strcmp()
, strlen()
, strcpy()
とか NUL終端文字列を期待している関数を使っちゃってます。
まさか Ruby 本体に罠があるとは思いませんでした。今のところ人柱覚悟で使った方が良いかもしれません。