Ruby の文字列データの複製について

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

イマイチだなーとツイートしたら、教えてもらえました。

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

おおー、メモリサイズは増えないし時間も掛かってないです。すばらしい。

もうこれデフォルトでいいのでは? と思ったらまた教えてもらいました。

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 本体に罠があるとは思いませんでした。今のところ人柱覚悟で使った方が良いかもしれません。