Los simbolos inmortales

Lo que el recolector de basura de ruby jamas limpiara


May 21, 2021


TL;DR; Valida siempre los nombres de los métodos admitidos cuando realices metaprogramación

En ruby es muy común usar símbolos para todo, casi todo, ya que son más eficientes a la hora de realizar búsquedas o comparaciones que simples cadenas. Y gracias al recolector de basura no nos preocupamos por las asignaciones de memoria que usan nuestros símbolos. Sin embargo, hay un escenario en el que el recolector de basura los ignora y habilita “símbolos inmortales”. El cual nunca será eliminado por el recolector de basura y vivirá para siempre en la memoria. Hagamos una pequeña prueba, usaremos ObjectSpace para fines prácticos y en este caso, solo nos enfocaremos en símbolos y cadenas.

En la ejecución tenemos el siguiente resultado

require 'objspace'
require 'SecureRandom'

puts ObjectSpace.count_objects.slice(:T_SYMBOL, :T_STRING)

10.times { SecureRandom.hex.to_sym }

puts ObjectSpace.count_objects.slice(:T_SYMBOL, :T_STRING)

GC.start

puts ObjectSpace.count_objects.slice(:T_SYMBOL, :T_STRING)

Como podemos ver al principio tenemos varias cadenas y símbolos cuando llamamos a SecureRandom.hex instanciamos una cadena y luego se convierte en un símbolo, ya que no se usa ninguno, el recolector de basura elimina estos símbolos y algunas otras cadenas.

Ahora, supongamos que tenemos algo de metaprogramación en nuestro código, para lo cual usaremos method_missing, send y define_method

require 'objspace'
require 'SecureRandom'

puts ObjectSpace.count_objects.slice(:T_SYMBOL, :T_STRING)

class Inmortal
  def method_missing(method, *args, &block)
    create_method(method)
    send(method)
  end

  def create_method(name)
    self.class.define_method(name) { "method_#{name}/0" }
  end
end

my_inmortal = Inmortal.new

10.times { my_inmortal.send(SecureRandom.hex) }

puts ObjectSpace.count_objects.slice(:T_SYMBOL, :T_STRING)

GC.start

puts ObjectSpace.count_objects.slice(:T_SYMBOL, :T_STRING)

Y tenemos como resultado

$ ruby inmortal.rb
{:T_SYMBOL=>28, :T_STRING=>10107}
{:T_SYMBOL=>38, :T_STRING=>10179}
{:T_SYMBOL=>28, :T_STRING=>7787}

¿Que pasó aquí? Cuando generamos 10 nuevos símbolos que nunca generamos. Para depurar agregaremoa a method_missing

   def method_missing(method, *args, &block)
    puts method.class
    create_method(method)
    send(method)
  end

y ahora pasa esto

{:T_SYMBOL=>28, :T_STRING=>10135}
Symbol
Symbol
Symbol
Symbol
Symbol
Symbol
Symbol
Symbol
Symbol
Symbol
{:T_SYMBOL=>38, :T_STRING=>10144}
{:T_SYMBOL=>38, :T_STRING=>7793}

Ok, aparentemente method_missing está recibiendo un símbolo, ok pero ¿quién lo está enviando?, investiguemos dentro del método send

  def send(method, *args, &block)
    puts method.class
    super
  end

y tenemos

$ ruby inmortal.rb
{:T_SYMBOL=>28, :T_STRING=>10152}
String
Symbol
String
Symbol
String
Symbol
String
Symbol
String
Symbol
String
Symbol
String
Symbol
String
Symbol
String
Symbol
String
Symbol
{:T_SYMBOL=>38, :T_STRING=>10164}
{:T_SYMBOL=>38, :T_STRING=>7794}

Por lo tanto, el método send se llama dos veces, la primera vez como una cadena y luego llama al metho_missing y la convierte a al símbolo. Esto tiene sentido ya que la documentacion dice

Send: Invokes the method identified by symbol, passing it any arguments specified. When the method is identified by a string, the string is converted to a symbol.

De acuerdo, ya determinamos quién se convierte en símbolos, pero ¿por qué el recolector de basura nunca elimina esos símbolos?

Vamos a profundizar, si modificamos nuestra prueba para ver los métodos definidos en nuestra clase tendremos algo mas de informacion a detalle

puts my_inmortal.methods.count
10.times { my_inmortal.send(SecureRandom.hex) }
puts my_inmortal.methods.count

y conseguiremos

$ ruby inmortal.rb
{:T_SYMBOL=>28, :T_STRING=>10134}
60
70
{:T_SYMBOL=>38, :T_STRING=>10228}
{:T_SYMBOL=>38, :T_STRING=>7793}

Como podemos ver, nuestra instancia de clase tiene 10 nuevas definiciones de métodos que son símbolos, por lo tanto, el recolector de basura ignora esas referencias ya que pertenecen a una instancia de objeto. Incluso si la instancia del objeto se elimina por completo, la referencia a los métodos de ese objeto permanecen para siempre.

Entonces, ¿cómo podemos evitar estas pérdidas de memoria? Simplemente evitando la creación de métodos no deseados

require 'objspace'
require 'SecureRandom'

puts ObjectSpace.count_objects.slice(:T_SYMBOL, :T_STRING)

class Inmortal
  def method_missing(method, *args, &block)
    super unless method.start_with?('m_')
    create_method(method)
    send(method)
  end

  def respond_to_missing?(method, include_private = false)
    method.start_with?('m_') or super
  end

  def create_method(name)
    self.class.define_method(name) { "method_#{name}/0" }
  end
end

my_inmortal = Inmortal.new

10.times { my_inmortal.send("m_#{SecureRandom.hex}") }
10.times { my_inmortal.send(SecureRandom.hex) rescue nil }

puts ObjectSpace.count_objects.slice(:T_SYMBOL, :T_STRING)

GC.start

puts ObjectSpace.count_objects.slice(:T_SYMBOL, :T_STRING)

Resultando con algo asi

$ ruby inmortal.rb
{:T_SYMBOL=>28, :T_STRING=>10145}
{:T_SYMBOL=>48, :T_STRING=>10204}
{:T_SYMBOL=>38, :T_STRING=>7805}

Como podemos ver, cuando validamos tanto en “respond_to_missing” como en “method_missing” se evita la creación de un nuevo método, por lo tanto no habrá símbolos “inmortales”, entonces solo los requeridos para la instancia.

Basicamente prodrias hacer una bomba de memoria en ruby haciendo algo asi

require 'SecureRandom'

class Bomb
  def method_missing(meth, *args, &blk)
    self.class.send(:define_method, "is_#{meth}?") { true }
    send("is_#{meth}?")
  end
end

loop { Bomb.new.send(SecureRandom.hex) }

Asi que ya lo sabes ten cuidado con el uso de simbolos cuando hagas metaprogamación.