Demasiada memoria gracias al Garbage Collector

16:03 0 Comments

Parece que está claro que programar en algo como C#/.NET (incluso C#/Mono) es mucho más rápido que intentarlo con C/C++: unas librerías potentes, un lenguaje sencillo y (lo que todos dicen) no más mallocs ni frees.

(Nota: hace un par de semanas en Game Developers Conference 2010 había una mesa rendonda en la que precisamente hablaban de las limitaciones de C++ (su complejidad básicamente) para el desarrollo de videojuegos y cómo les gustaría tener GarbageCollector… aunque al final todo el mundo coincidía en que les gustaba C++ a pesar de todo…)

Bien, volviendo a .NET: Garbage Collector, uno de los elementos claves de framework (y lo mismo se puede decir en Java) proporciona muchas ventajas: escribes y listo, te olvidas de líos. Pero… ¿es todo positivo? En Java estamos acostumbrados a ver aplicaciones consumiendo cantidades astronómicas de memoria (incluso hay IDEs como IntelliJ con un botoncito para llamar al GC, ni más ni menos) y lo mismo puede ocurrir con .NET. En muchas ocasiones no es un problema sino un tema estético: como el GC sabe que hay memoria de sobra disponible, y para mejorar el rendimiento, no recolecta la basura, así que el proceso sigue creciendo.

Pero para mí el mayor problema no es ese sino la sensación que va creando en todos los programadores de que la memoria es gratis. ¿Qué quiero decir con esto? Pues que acabas creando objetos y objetos y objetos nuevos porque es rápido y sencillo… aunque puedes estar estresando al GC más de la cuenta.


Un ejemplo práctico: servidor y blobs

Un ejemplo claro de cómo me encontré con el problema y cómo creo que en C jamás hubiera ocurrido.

Tengo un proceso servidor en .NET que procesa peticiones de clientes realizadas a través de la red, lee datos (blobs) de una base de datos y los devuelve al cliente. Muy sencillo.

Bien, cuando el nivel de carga del servidor no es muy alto todo funciona perfectamente: el servidor lee objetos, los devuelve, libera memoria de vez en cuando… todo perfecto.

Pero, ¿qué ocurre cuando ponemos unos cuantos cientos de clientes contra el mismo servidor?

Pues que la memoria del servidor comienza a crecer muchísimo, vemos como el tiempo haciendo GC aumenta también (se puede ver con el Process Explorer de SysInternals, http://technet.microsoft.com/es-es/sysinternals/bb896653.aspx, por ejemplo, donde te da una medida del tiempo que tu proceso (.Net 2 o superior) está gastando en GC).

¿Por qué ocurre esto?

Lo que yo estaba haciendo para procesar la petición era algo como esto (tipo pseudocódigo):
public byte[] GetData(long dataId)
{
byte[] result = new
byte[GetDataLen(dataId)];
ReadData(dataId, result);
return result;
}

¿Veis el problema?

Ahora me parece totalmente evidente pero la verdad es que tardé un rato en darme cuenta porque con el profiler de memoria (usaba la herramienta de RedGate Ants, http://www.red-gate.com/products/ants_memory_profiler) no detectaba nada de nada, parecía que todo estaba bien.


Objetos en tránsito

¿Cómo funciona el servidor? Cada método GetData se va a invocar en un thread (realmente no creando un thread nuevo, que parece un pecado, sino usando un thread pool) diferente, hace su trabajo y termina.

Entonces, ¿por qué tanta memoria? El siguiente gráfico lo explica claramente:

El método GetData está funcionando bien pero lo está haciendo en "modo .NET", es decir, lo he escrito como si la memoria fuera gratis.

Cuando se acumulan peticiones la cantidad de memoria inútil reservada será enorme dependiendo de cuántas llamadas se estén procesando (de la carga del servidor). Viendo el gráfico está claro que la memoria que se reservó para procesar la primera petición en el instante 0 en el primer thread no vale para nada ya en el instante 2 y sin embargo sigue ocupando sitio en el proceso…

Y este es precisamente el problema: los objetos en tránsito, objetos que todavía no se han liberado pero que siguen ocupando sitio.


Pensamiento tipo C

Esto nunca le hubiera pasado a un desarrollador C/C++ por dos motivos: primero porque un free es un free de verdad y liberaría la memoria inmediatamente. Segundo porque estando acostumbrado a una vida mucho más dura, trataría de evitar tanto malloc/free (o new/dispose).

¿Qué pasa si creamos un buffer pool con unos cuantos buffers pre-creados que reutilizamos en cada petición? El código pasaría a ser algo como esto:

public byte[] GetData(long dataId)
{
byte[] result = GetBuffer(GetDataLen(dataId));
ReadData(dataId, result);
return result;
}

public void ProcessRequest(long dataId)
{
byte[] result = GetData(dataId);
ReturnData(result);
ReleaseBuffer(result);
}

Varios puntos a tener en cuenta:

  • Para simplificar algo como esto ayuda mucho si los datos son de tamaño fijo, o al menos tienen un tamaño máximo. En mi caso un bloque de datos nunca era mayor de 4Mb, por lo que es muy sencillo de manejar el buffer pool porque todos los buffers serán del mismo tamaño (el máximo).

  • Si todos los buffers son del mismo tamaño habrá que manejar cuál es el tamaño usado de verdad, es decir, si estás leyendo 1024bytes en un buffer de 4Mb, luego no envíes 4Mb por la red!!! Envía sólo lo que estás usando. Esto posiblemente requerirá un código del tipo:
    public void ProcessRequest(long dataId)
    {
    byte[] result = GetBuffer();
    try{
    int size = ReadData(dataId, result);
    ReturnData(size, result);
    }
    finally
    {
    ReleaseBuffer(result);
    }
    }

Que por otro lado es mucho más limpio.

  • Hay que acordarse de liberar el buffer (tipo C) que realmente no hace nada más que devolverlo al buffer pool. Si el código de obtención y liberación del buffer está en el mismo método y con un try/finally, mucho mejor: más fácil de leer y menos propenso a fallos (no me gusta cuando el código de obtener y liberar un recurso o memoria, o lo que sea, no está en el mismo bloque).

Lógicamente hay que implementar el buffer pool de forma que:

  • Sincronice el acceso de varios threads

  • Se pueda limitar el número máximo de buffers: de este modo se puede controlar cuál va a ser el uso de memoria de nuestro servidor: no ocurrirá NUNCA que se descontrole como pasaba antes, el consumo será plano, el GC no tendrá trabajo y todo será más rápido. ¿Cómo limitarlo? Por ejemplo con un sencillo semáforo que bloquee en la llamada GetBuffer hasta que queden buffers libres. NOTA IMPORTANTE! En caso de usar un semáforo es muy importante considerar el riesgo de deadlocks si empezamos a usar múltiples pools (después del primero vendrán más) y los threads intentan acceder a ellos sin un orden muy estricto (la vieja regla: reserva SIEMPRE en el mismo orden y nunca tendrás deadlocks, reserva en orden diferente y espera el desastre).

Remoting: más complicado


En mi caso real (no descubrí esto en un programa de 5 líneas con un GetData, desgraciadamente) el servidor no estaba usando sockets directamente sino .NET Remoting, así que en la película entraban más complicaciones como la serialización de objetos y las múltiples copias que Remoting hace hasta llegar a la capa de red. Para evitarlo no quedó más remedio que escribir un custom tcp channel capaz de manejar el buffer pool reservado en la capa de aplicación y liberarlo justo después de poner los datos en la red, y evitando también la lenta serialización estándar de .Net… pero esto ya es otra historia para otro día.

0 comentarios: