RubyのGCと仲良くしたい〜WeakRefオブジェクトを削除するぞ編〜

  • 24
    いいね
  • 0
    コメント

Rubyでは、作成したオブジェクトがなにかのタイミングでGCによってメモリから解放されています。
一般的なオブジェクトは、どこからかに参照されている間は必要、参照されていないなら不要とGCに判断され、メモリから解放されます。
例外的に 弱い参照 -Wikipedia として定義されたオブジェクトは、まだ参照されていてもなにかのタイミングでGCにぽいぽいされてしまいます。
一見、使いようが無いようですが、生成コストがかかる値をキャッシュしておき、メモリに余裕がない場合(一般にGCが動くタイミング)にキャッシュを破棄する、というようなことができます。
機能の実現に必要となることはありませんが、速度改善などに役立つテクニック的な。

なおタイトルはてきとーです。「○○編」と書いていますが、別の編があったりはしません。たぶん。
Ruby2.1で動作検証をしましたが、1.9以降ならたぶん大丈夫なんじゃないでしょうか。

メモリキャッシュを使わない実装

まず普通に実装してみましょう。
名前(name)と犬種名(breed)を与えと犬を作成するクラスを作ってみます。
このクラスはどうやら、登録された名前と犬種からその犬の性別と強さを計算するようです。

require 'digest/sha1'

class Dog
  Info = Struct.new :name, :age, :sex, :strength

  def initialize(name, breed="mix")
    sex = calc_sex(name)
    strength = calc_strength(breed) + calc_strength(name)
    @info = Info.new name, 0, sex, strength
  end

  private

  def basic_calc(str)
    # sleepはなんらかの重い処理を表現
    Digest::SHA1.hexdigest(str.to_s.downcase).split(/[^0-9]/).inject(0){|sum, num| sleep 0.1; sum + num.to_i} 
  end 

  #  @return [Symbol] 犬の性別 (:male or :female)
  def calc_sex(str)
    sex = basic_calc(str) % 2
    sex.zero? ? :male : :female
  end

  #  @return [Integer] 犬の強さ (0-255)
  def calc_strength(str)
    basic_calc(str) % 256
  end
end

Dog.new("ninja", "Chihuahua")
#=> #<Dog:0x007f9178 @info=#<struct Dog::Info name="ninja", age=0, sex=:male, strength=190>>
Dog.new("kotaro")                                                                               
#=> #<Dog:0x007f9170 @info=#<struct Dog::Info name="kotaro", age=0, sex=:male, strength=152>>
Dog.new("chappy", "unkown")                                                                  
#=> #<Dog:0x007f9172 @info=#<struct Dog::Info name="chappy", age=0, sex=:female, strength=306>>
Dog.new("ninja", "Chihuahua")
=> #<Dog:0x007f917c0 @info=#<struct Dog::Info name="ninja", age=0, sex=:male, strength=190>>

どうやらninjaはメスのようです。あと、チャッピー強い。

計算結果をキャッシュする実装

さて、上の実装だと、おなじ名前・犬種の組合せでは毎回同じ結果が帰ってきます。
しかし内部的には毎回 basic_calc メソッドが呼ばれているためなんだか無駄な気がします。
おなじインプットに対しておなじ結果が帰ってくることがわかっているなら、二度目の計算では前の結果を使いたいと思うのが人の常。
クラス変数に結果を格納するようにして、おなじ文字列が与えられたならば、basic_calcは計算をしないで結果を返すようにしましょう。

require 'digest/sha1'

class Dog
  @@cache_basic_calc ={}

  :

  private

  def basic_calc(str)
    key = str.to_s.downcase
    @@cache_basic_calc[key] ||=
      Digest::SHA1.hexdigest(key).split(/[^0-9]/).inject(0){|sum, num| sleep 0.1; sum + num.to_i} 
  end 

  :

end

10匹の犬を登録する(一部、名前や犬種の重複あり)場合に、キャッシュなしとありの場合を測定してみると、明らかな差がでました。当然ですね。

調子に乗って犬5匹を1チームとして登録するクラスを作りましょう。
名前と犬種のセットが同じならば、Dogはおなじ構造体を返すのでキャッシュして問題なさそうですね。

class DogTeam
  @@cache_dog = {}

  def initialize(breed, dog1, dog2, dog3, dog4, dog5)
    @team = [
      dog(dog1, breed),
      dog(dog2, breed),
      dog(dog3, breed),
      dog(dog4, breed),
      dog(dog5, breed),
    ]
  end

  private

  def dog(name, breed)
    @@cache_dog[[name, breed]] ||= Dog.new(name, breed)
  end
end

# 一億チームの集う犬の祭典である
100000000.times do |n| 
  puts DogTeam.new("team#{n}", "won", "one", "wonwon", "wanwano", "nyao")
end``

盛大な催しのようです。このスクリプトを実行してみましょう。嘘です。実行しないでください。
というのも、実際に実行したらパソコンが止まって再起動するはめになったからです。Qiitaで書いていなかったら、この記事も1から書き直すところでした。
Screenshot_from_2015-07-31 02:51:31.png
上記はスクリプト実行からのメモリを監視した画像だが、モリモリとメモリ使用量が増加していく様子が分かります。このあと滅茶苦茶フリーズした。

どういうことでしょう。
犬を数えると機械は寝てしまうのでしょうか。それともRubyのバグ? いいえ、このスクリプトのバグです。

DogTeam#dogのなかで、生成されたDogオブジェクトが@@cache_dogに保存されていきます。
@@cache_dog[["hoge", "huga"]]Dogオブジェクトを参照し続けるため、ループが続くと@@cache_dogが肥大化しメモリを食いつぶすというわけです。

要約冒頭の話に戻ってきます。

このようなメモリキャッシュ機構を作る際、Hashがオブジェクトを「弱い参照」で参照することで、メモリが圧迫された際にGCがキャッシュを削除してくれるのを期待するわけです。

弱い参照、WeakRefクラス

上記の問題はWeakRefクラスObjectSpaceクラスを利用することで解決できるかも知れません。

使い方

irb を起動して確認してみましょう。
WeakRefクラスによって作成したオブジェクトをHashから参照し、GCを動かしてみましょう。
強い参照の値は保持されますが、弱い参照はGCによって削除され、putsで値を参照しようとするとWeakRef::RefErrorになりました。

require "weakref"
hash = {}
hash[:a] = "hoge"*100               # 強い参照
hash[:b] = WeakRef.new("fuga"*100)  # 弱い参照

hash #=> {:a => "hogehogeho...", :b => "fugafugafu..."}

GC.start

hash #=> {:a => "hogehogeho...", :b => #<WeakRef:0x3ff67d2b6908>}
puts hash[:a] #=> "hogehogeho..."
puts hash[:b] #=> WeakRef::RefError: Invalid Reference - probably recycled

上記のようにHashの参照先にWeakRefオブジェクトは残ったままなので、可能ならばGC時にHashからキーそのものを削除してあげたいところです。
ObjectSpace#define_finalizerメソッドを利用することで、オブジェクがGCによってメモリから解放される際のイベントを登録できるので利用しましょう。

Dog, DogTeamへの応用

では具体的な実装。
まず以下のようなクラスを用意します。

require "weakref"

class WeekCache
  def initialize
    @caches = {}
  end

  def [](key)
    @caches[key]
  end

  def []=(key, value)
    ref = WeakRef.new(value)
    ObjectSpace.define_finalizer(value, self.class.ensure_cleanup(self, key, ref)) #1 GC時のイベント定義
    self[key] = ref 
  end 

  #2
  def self.ensure_cleanup(caches, key, ref)
    proc{
      caches.delete(key) if caches[key] == ref 
    }   
  end 
end

#1でvalueがGCされるときに#2を呼び、WeakRefオブジェクトをまるごとHashから消してあげることにしました。
これをしないと、

  • WeakRefオブジェクト自身は強い参照によってメモリ上に残り続けるので、Hashが肥大化すればメモリリークが発生する可能性が残る
  • 参照先のオブジェクトがGCされたWeakRefオブジェクトを参照しようとするとエラーになる

上記クラスを使うように、DogTeamを書き換えましょう。
なおDogでは、キャッシュが参照しているのがFixnumのためWeakRefで参照することは出来ません(というかする必要がない)。

class DogTeam
  @@cache_dog  = WeakCache.new

  :

  def dog(name, breed)
    @@cache_dog[[name, breed]] ||= Dog.new(name, breed)
  end
end

実際10億回くらいのループでDogTeamインスタンスを作成しても、メモリを食いつぶすことはなくなりました。

ベンチマーク

最初の実装・WeakRefをつかったキャッシュ・使わないキャッシュでベンチマークもとってみた所、以下のような関係になりました。

## 10インスタンス程度作る場合 ##
                    user   system        total        real
no_cache        0.000000   0.000000   0.000000 (  0.000611)
weakref_cache   0.000000   0.000000   0.000000 (  0.000349)
cache           0.000000   0.000000   0.000000 (  0.000208) #よさげだがメモリリークの危険あり

## 1000インスタンス程度作る場合 ##
                    user     system      total        real
no_cache        0.050000   0.000000   0.050000 (  0.053537)
weakref_cache   0.050000   0.000000   0.050000 (  0.045995)
cache           0.030000   0.000000   0.030000 (  0.030020)

GCが頻繁に発生するような場合(作成インスタンス数が多い場合)は、仕方ないですが効果が薄くなりますね。
がんばって実装した割にはあまり大した効果がない……?

ベンチマークテストを含めたコードはGist登録しておきました。

まとめ

  • Hashをつかったメモリキャッシュは工夫しないとメモリリーク起こすよ
  • WeakRefを使うときは、GC後にWeakRefも消す仕組みをつかおうよ

参考