FIVETEESIXONE

(翻訳) Ruby 2.2 のシンボル GC


Ruby 2.2 の新機能にシンボル GC というものがあります。

正直、「え、シンボルって GC されないから速いんじゃないの?なのにシンボル GC で Rails が速くなるとか話聞くけど、いったいどういうことなの?」という感じでサッパリ理解できてなかったのですが、その辺りの疑問に対してまとめられている記事がありました。

Symbol GC in Ruby 2.2

とても分かりやすく素晴らしい記事だと感じたので、許可をもらって以下に日本語訳を公開します。

Symbol GC in Ruby 2.2

シンボル GC ってなに?それって気にしなくちゃいけないことなの?

リリースされたばかりの Ruby 2.2 の大きな新機能として「インクリメンタル GC」が挙げられますが、もう1つの注目すべき新機能が、この「シンボル GC」 です。

もしあなたがこれまで Ruby の世界で過ごしてきたのならば、きっと「シンボル DoS」という言葉を聞いたことがあるのではないでしょうか。「シンボル DoS」攻撃というのは、システムが大量のシンボルを作ることによってメモリが枯渇してしまうことを言います。2.2 より前の Ruby では、作成されたシンボルが永久に消えないためにこういった問題が発生してしまいます。

Ruby 2.1 で例を示しましょう。

# Ruby 2.1
 
before = Symbol.all_symbols.size
100_000.times do |i|
  "sym#{i}".to_sym
end
GC.start
after = Symbol.all_symbols.size
puts after - before
# => 100001

ここでは、100,000 個のシンボルを作成しています。その後で GC を実行して、もはやそれらのシンボルを参照する変数が存在していない状態であるにも関わらず、作られたシンボルがそのまま残っていることが分かります。

これによる問題は簡単に発生させることができます。もしあなたが書いたコードが、ユーザーパラメータを受け入れて、そのパラメータに対して to_sym を実行するようなものだったとしたらどうでしょう。

def show
  step = params[:step].to_sym
end

このケースでは、もし誰かが example.com/step= に対して大量のリクエストを送ったとき、作られたシンボルが掃除されることがないため、アプリケーションは最終的にはメモリの枯渇によってクラッシュしてしまいます。

もしかしたら、こんなでっち上げのようなコードは普通あり得ない、と感じるかもしれません。しかし実際に、私はこれによく似たコードを、自分の開発した Wicked という gem にコミットしてしまったことがあります。(もちろん、今はもう fix 済みなので心配しないでください!)。

ここで、以下にいくつかのリンクを貼ります。これらはそれぞれ異なる脆弱性に関するものですが、実は問題の本質は同じところにあります。

これら以外にも同様の脆弱性はいくつも挙げることができますが、これだけで、おそらくその危険性を感じることができたのではないでしょうか。ユーザーの入力からシンボルを作成することは、シンボルがガーベージコレクションの対象とならないという理由だけで、リスクを伴うものになってしまうのです。そして、2.2 より前の Ruby ではそれが仕様でした。

Ruby 2.2 のシンボル GC

Ruby 2.2 ではシンボルはガーベージコレクションの対象になります。

# Ruby 2.2
before = Symbol.all_symbols.size
100_000.times do |i|
  "sym#{i}".to_sym
end
GC.start
after = Symbol.all_symbols.size
puts after - before
# => 1

ここで作成したシンボルは、もう他のオブジェクトや変数によって参照されていないため、ガーベージコレクションによって回収されていることが分かります。したがって、誤って大量のオブジェクトを生成して、それらがいつまでも残ってしまうことによってプログラムがクラッシュするような問題を防止することが可能です。しかし、「すべての」シンボルがガーベージコレクションで回収されるわけではありません。

ん?「すべての」シンボルが回収されない、ってどういうこと?

#not_all_symbols (すべてのシンボルではない)

2.2 より前の Ruby では、シンボルを回収することは不可能でした。なぜなら、シンボルは Ruby インタプリタの内部でも利用されるものだからです。まず、各シンボルはユニークなオブジェクト ID を持っています。例えば、:foo.object_id の値は、プログラム実行中は必ず同じである必要がありました。これは、rb_intern の仕組みによるものです。

C 実装の Ruby でメソッドを作成するときは、メソッドテーブルにユニークな ID を格納します。

https://www.dropbox.com/s/elge1r14kwew6r4/Screenshot 2014-12-25 12.33.03.png?dl=1

Nari 氏によるシンボル GC に関するスライド より引用


メソッドを呼び出すときには、Ruby はそのメソッド名のシンボルを検索し、シンボルの ID を取得します。シンボルの ID は、C 言語レベルで関数のメモリ上の位置を示すために使われているので、それを元に関数が呼び出されるというわけです。Ruby はこの仕組みでメソッドを実行しています。

このように、メソッドへの参照をシンボルが担当している状態で、もしシンボルがガーベージコレクションの対象となったのであれば、今まで通りのメソッドの呼び出しは不可能になってしまいます。それはマズいですよね。

Narihiro Nakamura は、この問題への回避策として、 C の世界の「Immortal Symbol (不死のシンボル)」と Ruby の世界の「Mortal Symbol (死ぬ運命にあるシンボル)」を区別するというアイデアを提唱しました。

基本的に、Ruby の実行時に (to_sym などによって) 動的に生成されるすべてのシンボルは、ガーベージコレクションの対象とすることができます。その理由は、それらのシンボルが Ruby インタプリタの内部で利用されることはないためです。一方、新しいメソッドの定義によって作られたシンボルや、静的にコードの中で定義されているシンボルはガーベージコレクションの対象とはなりません。例えば、:foodef foo; end はどちらもガーベージコレクションの対象となりませんが、"foo".to_sym はガーベージコレクションの対象になります。

ただ、このアプローチに関して注意すべき点は残っています。それは、もしユーザーの入力に基づいてメソッドを作成する場合には、やはり DoS のリスクが伴うということです。

define_method(params[:step].to_sym) do
  # ...
end

define_method は裏側で rb_intern を実行しているので、そこでもし動的に (to_sym などで) 定義されたシンボルを渡したとしても、そのシンボルは「Immortal Symbol」に変換されます (だからこそ、そのシンボルをメソッドの探索に利用できるわけです)。おそらく、上記のようなコードを書くことは普通しないと思いますが、こういったいくつかのリスクがまだ存在していることは知っておきましょう。

変数も、その裏側でシンボルを利用しています。

before = Symbol.all_symbols.size
BAR = nil
GC.start
after = Symbol.all_symbols.size
puts after - before
# => 2

例え中身が nil であったとしても 、変数は裏側でシンボルを使用していて、それらのシンボルがガーベージコレクションで回収されることはありません。先に書いた通り、ユーザーの入力に基づいてランダムなメソッドを定義することは避けなければなりませんが、それに加えて、ユーザーの入力に基づいて変数を作成することに対しても注意しておいてください。

プログラムを確実に安全なものにするためには、定期的に GC.start を実行し、その後で Symbol.all_symbols.size をチェックすることによって、シンボルテーブルが肥大化していないかを確認すべきでしょう。いずれ、このような、シンボルの扱いに関する「安全なこと」および「危険なこと」についてのノウハウが一般化し、スタンダードとしてまとまれば嬉しいですね。もしあなたがここで挙げたもの以外にも、何か共有可能な注意事項を知っていたら、ぜひ Twitter で私 (@schneems)に教えてください。この記事にも反映したいと思います。

嬉しいことに、@nari3 がこの節をレビューをしてくれて、内容についてフィードバックを提供してくれました。もし、ここで書かれている内容に関しての内部的な実装などについてより詳しい情報が知りたければ、Nari 氏のスライドを読むか、RubyKaigi でのプレゼンを聞いてみてください。

もっと速さを・・・!

シンボル GC に関して気にしておくべき点は、セキュリティについてのことだけではありません。最も大事なのは「速さ」に関することです。世の中には、ユーザーの入力に基づいてシンボルを割り当ててしまうことを避けるため、シンボルを文字列に変換するような処理の書かれたコードが山ほどあります。普通、「コード」と「山ほど」と言う言葉が一緒に使われていたら、その結果が速いってことはないですよね。

シンボルの割り当てを避けるための最も一般的な例は、Rails (ActiveSupport) の HashWithIndifferentAccess でしょう。以前に私は subclasses of Hash like Hashie being slow という記事を書いているので、それを読んだ方は、Rails にこのように大きなパフォーマンス上の弱点があることを聞いてもそれほど驚かないでしょう。

require 'benchmark/ips'
 
require 'active_support'
require 'active_support/hash_with_indifferent_access'
 
hash = { foo: "bar" }
INDIFFERENT = HashWithIndifferentAccess.new(hash)
REGULAR     = hash
 
Benchmark.ips do |x|
  x.report("indifferent-string") { INDIFFERENT["foo"] }
  x.report("indifferent-symbol") { INDIFFERENT[:foo] }
  x.report("regular-symbol")     { REGULAR[:foo] }
end

上記の実行結果はこちらです。

Calculating -------------------------------------
  indifferent-string   115.962k i/100ms
  indifferent-symbol    82.702k i/100ms
      regular-symbol   150.856k i/100ms
-------------------------------------------------
  indifferent-string      4.144M (± 4.4%) i/s -     20.757M
  indifferent-symbol      1.578M (± 3.7%) i/s -      7.939M
      regular-symbol      8.685M (± 2.4%) i/s -     43.447M

シンボルをキーとした普通のハッシュへのアクセスと比べて、文字列を使った未分化 (indifferent) ハッシュへのアクセスは半分の速度しか出ていないことがわかると思います。更に、シンボルによるアクセスの場合には、通常のハッシュを使った場合の、なんと 5 倍も遅いのです。

以前に、Ruby 2.2 で、文字列キーのパフォーマンスが大きく向上していることについての記事を書きました。しかし、現在でも、ハッシュへのアクセスではシンボルを使ったアクセスが最も速く、また、好ましいやり方という点でも、シンボルを使うのが (議論の余地はあるかもしれませんが) ベストです。Ruby 2.2 では、Rails のパラメータでシンボルのキーを使用することができるようになります。このことによって、セキュリティの心配をすることなく、同時に、HashWithIndifferentAccess よるオーバーヘッドを発生させずに済むようになります。

注意: 大きなパフォーマンスの変化を期待する場合、そしてそれが API の非推奨化を伴う場合には特に、必ずアプリケーションのレベルでベンチマークを測定すべきです。パフォーマンスの修正を適用する場合に、「こうすると速くなるよ、といくつかのブログで書いてあったので〜」といったことを正当化の理由としては絶対にいけません。もしそれが、この私のブログだとしても!いつでも、それぞれのケースごとに、ベンチマークを測定することによって検証すべきです。

まとめ

シンボル GC のおかげで、DoS 攻撃から身を守りながら、シンボルを使いたいところでは自由にシンボルを使う、という柔軟性を得ることができます。Ruby 2.2 には、他にも、インクリメンタル GC や、ハッシュのキーにおける文字列の重複排除などのパフォーマンス改善機能もあります。それを考えると、今すぐアップグレードすることをためらう理由はありません。

ローカルにインストールするには以下のようにします。

$ ruby-install ruby 2.2.0

本番環境では (もし Heroku を利用していれば) 以下のようにします。

$ echo "ruby '2.2.0'" >> Gemfile

のんびり待っていてはいけません!今こそがRubyの未来です!

Original article by Richard Schneeman (@schneems)