Escribiendo código de alto rendimiento en .Net Core

Tiempo de lectura: 12 minutos
Imagen ornamental para la entrada Escribiendo código de alto rendimiento en C# con .Net Core

Recientemente he estado en una charla de un grande del sector como es Carlos Landeras y en el clásico afterwork tras la charla, he tenido la oportunidad de hablar con el largo y tendido con el sobre el tema y finalmente me he decidido a escribir una entrada sobre cómo hacer código de alto rendimiento utilizando .Net Core.

En primer lugar y, ante todo, hay que utilizar la cabeza y no perder tiempo en optimizar sin ser necesario. La gran mayoría de aplicaciones no necesitan de utilizar las cosas que vamos a ver a continuación, pero es conveniente saber que existen porque a veces hacen falta.

¿Cuándo debo mejorar el rendimiento de mis aplicaciones .Net Core?

Lo primero que cabe preguntarse en este momento es si vale la pena optimizar nuestra aplicación, ¿Y cómo puedo decidir si vale la pena? La pregunta es difícil de resolver porque no hay una verdad absoluta al respecto, pero principalmente podemos hacernos algunas preguntas para decidir si necesitamos modificar nuestro código:

  • ¿Tiene nuestro sistema una alta concurrencia?
  • ¿Los recursos del equipo son limitados?
  • ¿Estamos teniendo problemas con el rendimiento?
  • ¿Estamos creando una librería cuyo uso pueda caer en algún escenario de los anteriores?

Optimizar un código tiene un coste de desarrollo extra y muchas veces no vale la pena el esfuerzo necesario para conseguirlo. No es lo mismo optimizar un método que se ejecuta una vez al arrancar nuestra aplicación, por ejemplo, que un método que se llama cientos de veces por segundo. Si estamos ejecutando nuestra aplicación en un servidor tampoco es lo mismo que, si por el contrario la estamos ejecutando en una raspberry pi por ejemplo, los recursos en un caso son ‘ilimitados’ y en el otro no. A pesar de lo anterior… ¿Estamos teniendo problemas? Podemos tener un código que se ejecuta cientos de veces por segundo en un equipo con pocos recursos, pero aun, así no existir problemas en el rendimiento.

Si por el contrario eres de los que sí necesita optimizar tu código, agárrate que vienen curvas (aunque voy a intentar simplificarlo al máximo).

¿Cómo puedo saber dónde mejorar el rendimiento de mis aplicaciones .Net Core?

La respuesta a esta pregunta es muy variada y depende de que es exactamente lo que haga el código que queremos optimizar. Por ejemplo, ¿cúal de los siguientes métodos es mejor para concatenar dos cadenas?

public string StringConcat() => string.Concat(_stringA, _stringB);

public string StringFormat() => string.Format("{0}{1}", _stringA, _stringB);

public string StringInterpolation() => $"{_stringA}{_stringB}";

public string StringAdd() => _stringA + _stringB;

public string StringBuilder()
{
    var builder = new StringBuilder();
    builder.Append(_stringA);
    builder.Append(_stringB);
    return builder.ToString();
}

Siempre hemos oído que hacer una operación ‘+’ entre dos cadenas es lo peor que podemos hacer ya que las cadenas son inmutables y es una mala práctica, que es mejor utilizar un StringBuilder pero… ¿Serías capaz de decir de las 5 opciones cual es mejor y cual peor sin ser meras suposiciones?

Para poder optimizar cualquier parte del código, lo primero es medirlo. Si no tomamos métricas sobre los recursos que está utilizando un código, todo lo que hagamos serán meras suposiciones. Precisamente para temas como este, hace unos meses hablábamos sobre como medir el rendimiento de nuestro código utilizando BenchmarkDotNet y hoy vamos a recuperar la utilidad que ofrece para hacer precisamente eso, medir. No es posible optimizar código sin hacer diferentes mediciones que nos digan si estamos haciendo que las cosas vayan mejor o peor. Por ejemplo, sin medir nada, podemos pensar que lo mejor tal vez sea concatenar (el método StringConcat) o tal vez usar un StringBuilder, y que el peor quizás sea hacer una simple suma (el método StringAdd). Cuando hacemos un benchmark de esos cinco métodos, los resultados tal vez sorprendan a más de uno…

La imagen muestra los resultados para el código anterior donde los resultados dicen que StringConcat tarda 23 ns, StringFormat 118 ns, StringInterpolation 24 ns, StringAdd 23 ns y StringBuilder 49 ns,

Sin ánimo de venir a decir ahora que hacer suma de cadenas ya no es un problema (que sí lo es), lo que pretendo mostrar es que siempre es necesario medir para poder determinar dónde está el problema. En el caso anterior al ser solo dos cadenas y una única repetición, el caso de sumar cadenas no tiene impacto mientras que otros métodos pueden costar hasta 5 veces más, si estuviésemos haciendo esto en ciclos largos (por ejemplo 1000 veces) donde se haga la cadena A unida a la B y el resultado a la B evidentemente los resultados serían muy distintos:

La imagen muestra los resultados para el código anterior repetido 1000 veces donde los resultados dicen que StringConcat tarda 648 ns, StringFormat 1922 ns, StringInterpolation 616 ns, StringAdd 610 ns y StringBuilder 11 ns,

En este caso, solo nos hemos fijado en el tiempo de ejecución, pero… ¿Y la memoria? ¿Puede una mala gestión de memoria ser un problema que nos haga perder rendimiento en una aplicación .Net Core? La verdad es que sí. Por cómo trabaja .Net en general, la memoria la maneja una herramienta conocida como el Garbage Collector.

¿Cómo funciona el Garbage Collector en .Net Core?

El recolector de basura es una herramienta que utilizan lenguajes como C# o Java para gestionar la memoria de la aplicación. Básicamente el recolector se encarga de hacer las reservas de memoria cuando creamos un nuevo objeto por referencia. El concepto de la gestión de memoria en .Net es un tema complejo y muy interesante y saber sobre él nos permite generar código con mucho mejor rendimiento.

La memoria se divide en dos secciones diferentes con diferentes finalidades.

  • Stack (Pila)
  • Heap (Montón)

La pila es el lugar donde se almacenan las variables por valor (struct) mientras que el montón es donde se almacenan las variables por referencia (class).

Esto no es siempre así como plantearemos más adelante pero de momento podemos asumir que sí.

La pila es ‘reducida’ pero muy rápida y no es manejada por el recolector de basura mientras que el montón es más grande y si lo gestiona el recolector de basura y tiene una manera muy especial de hacerlo. De cara a optimizar el proceso de limpieza, .Net divide los objetos del montón en 3 generaciones, siendo la generación 0 la de los objetos más efímeros y la generación 2 la de los objetos más longevos de la aplicación.

El recolector de basura es quien se encarga de asignar la memoria cuando creamos nuevos objetos por referencia, pero no siempre tiene esa memoria disponible. Cuando no tiene suficiente memoria libre para darnos la que necesitamos, iniciará una recolección de la generación 0 que consiste en limpiar los objetos que ya no use usan y subir de generación los que sí se usan.

Después de hacer este trabajo, pueden pasar dos cosas, o ya hay memoria suficiente o no la hay. En caso de que la haya, nuestra aplicación sigue funcionando normalmente pero si no había memoria suficiente, lo que va a hacer el recolector es el mismo proceso con la generación 1. Por último, si la generación 1 tampoco es suficiente, hará lo mismo con la 2. La diferencia es que en caso de la generación 2, los objetos siguen en la generación 2.

Vale, llegados a este punto me vas a decir: `¡¡Menuda brasa!! ¿Y esto de qué me vale?`(Con razón avise de que venían curvas). Es importante saber cómo funciona el garbage collector porque, aunque su existencia nos facilita mucho la vida, su trabajo es más prioritario que el nuestro. Esto quiere decir que cada vez que el recolector hace una recolección nuestra aplicación se va a parar mientras dure la recolección. (Por suerte esto tampoco es así al 100% pero asumamos que sí).

Cuanto más trabaje el recolector de basura, peor rendimiento tendrá nuestra aplicación. En las mediciones del código que hemos hecho antes, solo estábamos mostrando el tiempo de ejecución, pero vamos a hacer lo mismo añadiendo la memoria:

La imagen muestra los resultados con memoria para el código anterior donde los resultados dicen que StringConcat consume, 4921 KB StringFormat 14899 KB, StringInterpolation 492 1KB, StringAdd 4921 KB y StringBuilder solo 26 KB

¿Cómo reduzco el consumo de memoria de mi aplicación?

En este punto tenemos claro que utilizar memoria de cualquier manera es algo que puede afectar negativamente al rendimiento y nosotros lo que queremos es precisamente mejorar el rendimiento de una aplicación .Net Core. Para eso .Net Core nos ofrece ciertas herramientas para poder hacer código ‘memory friendly’.

Para esto tenemos varias opciones built-in que nos van a permitir mejorar y optimizar las reservas de memoria, consiguiendo así ponerle las cosas más fáciles al recolector.

Devolver valores por referencia en vez de nuevas copias para mejorar el rendimiento de aplicaciones .Net Core

Acabamos de plantear que el hecho de instanciar clases tiene un coste para el recolector de basura, pero utilizar instancias de structs tiene también su personalización. Esto es porque al ser objetos por valor, cada vez que pasemos el dato como parámetro o retorno de un método, realmente estaremos pasando una copia completa de la estructura… En este caso no son los problemas con el recolector, sino la sobrecarga de copiar datos dentro de la pila lo que pretendemos mejorar. Imagina una estructura con varios campos, el hecho de que tengamos un método que retorne una copia o una referencia puede cambiar significativamente el tiempo cuando es un código ‘caliente’ por el que se pasa miles de veces. Los resultados de llamar a este código 1000 veces ya tienen una diferencia del 30%.

private BigStruct CreateBigStruct()
{
    return _bigStruct;
}

private ref BigStruct CreateRefBigStruct()
{
    return ref _bigStruct;
}
La imagen muestra los resultados de devolver una estructura por valor o por referencia, donde el hecho de devolverla por referencia mejora un 31% el tiempo usando .Net Core

Para poder conseguir esto, lo que tenemos que hacer es hacer es añadir el ‘ref’ a la firma de nuestro método. Tras esto, después del ‘return’ también añadimos ‘ref’, para indicar que el retorno es por valor. A la hora de consumir el retorno de este método utilizaremos variables ‘ref’ locales:

ref var item = ref CreateRefBigStruct();

Vale, pero con esto estamos devolviendo una referencia y podemos modificar el contenido de la estructura… ¿Esto se puede evitar de alguna manera? Pues la respuesta es sí, gracias a las herramientas del lenguaje disponibles podemos declarar el método como ‘readonly‘. De este modo conseguimos crear una referencia de solo lectura hacia la estructura, de modo que conseguimos lo mejor de los dos mundos.

private ref readonly BigStruct CreateRefBigStruct()
{
    return ref _bigStruct;
}
ref readonly var item = ref CreateRefBigStruct();

Span<T>/ReadOnlySpan<T>

La primera de estas herramientas es ‘Slice. Esta memoria a la que apuntamos puede estar tanto en la pila como en el montón o incluso memoria no administrada. Imaginemos esto como una ventana móvil que apunta hacia una colección completa, cuando necesitemos acceder a una parte especifica de la colección, en vez de crear una subcolección, simplemente vamos a mover la ventana a la parte que nos interesa sin crear una colección nueva.

La imagen enseña mediante un diagrama que respecto a una colección completa, Span<T> permite abrir o cerrar la región que esta controlando mejorando así el rendimiento en .Net Core

‘Span<T>’ es un tipo de dato especial (ref struct) que nos garantiza que siempre se va a almacenar en la pila, por lo que solo podemos hacer uso de ella en situaciones que garanticen que esto se cumple. En caso contrario el compilador nos generará un error. Vamos a poder utilizar Span<T> como variable local, como parámetro o como retorno de un método, pero no como miembro de una clase o estructura.

Esto es porque, aunque las estructuras en principio van a la pila, no siempre tiene porque ser así. Si por ejemplo está dentro de una clase, la estructura se almacenará en el montón.

¿Y qué ventajas me da esto? Pues, por ejemplo, vamos a poder crear colecciones de datos en la pila mientras que si utilizásemos una lista o array, estarían en el montón. Para poder hacer esto vamos a utilizar la palabra reservada ‘stackalloc’ para indicar que esa memoria que queremos tiene que venir de la pila.

//Con reserva de memoria
int[] array = new int[1024];
//Sin reserva de memoria
Span<int> array = stackalloc int[1024];

Por poner un ejemplo, crear una colección de 100000 elementos y asignarles un valor:

public void InitializeWithArray()
{
    int[] array = new int[_lenght];
    for (var i = 0; i > _lenght; i++)
    {
        array[i] = i;
    }
}

public void InitializeWithStackalloc()
{
    Span<int> array = stackalloc int[_lenght];
    for (var i = 0; i > _lenght; i++)
    {
        array[i] = i;
    }
}

Nos aporta unos resultados muy claros:

La imagen muestra que utilizar Span consigue una reducción del tiempo de un 27% además de no consumir nada de memoria mientras que el método con array consume un total de 40 KB con lo que mejora el rendimiento en .Net Core

El hecho de utilizar Span<T> está suponiendo un ahorro del 27% en cuanto a ejecución y además estamos evitando el uso de 40 KB de memoria del montón.

La principal ventaja es que desde .Net Core 2.1 tiene un soporte nativo en el framework, por lo que ahora en .Net Core 3 son muchísimas las APIs del framework que nos da la opción de darle como parámetro o recibir como retorno objetos de tipo Span<T>, por lo que con unos cambios mínimos en nuestra manera de desarrollar podríamos aplicar una mejora en el rendimiento.

Memory<T>/ReadOnlyMemory<T>

Span<T> es muy útil como hemos visto a la hora de optimizar el uso de memoria y con ello el rendimiento en .Net Core, pero tiene sus limitaciones respecto a donde usarlo… Precisamente para suplir esas limitaciones está a nuestra disposición ‘ReadOnlyMemory‘. A diferencia de Span y ReadOnlySpan, estas dos nuevas versiones si pueden almacenarse en el montón y por tanto podemos usarlo en cualquier sitio.

ArrayPool<T>

Hasta ahora hemos planteado opciones para evitar el uso del montón para minimizar las recolecciones y evitar así tiempos muertos. Esto no siempre es posible porque la pila tiene un tamaño limitado. Si pretendemos almacenar grandes colecciones con estructuras en ella vamos a tener un desbordamiento…

Para evitar este problema podemos aplicar una estrategia de reciclaje de memoria gracias a ‘Rent‘ y ‘Return‘.

var array = ArrayPool<BigStruct>.Shared.Rent(1024);
//Código que utiliza el array
ArrayPool<BigStruct>.Shared.Return(array);

Utilizando ArrayPool estamos pidiendo memoria al proceso y si no se la devolvemos, vamos a provocar una fuga de memoria que acabará en un fallo catastrófico.

Supongamos un caso donde tengamos una colección de 1000 posiciones de una estructura grande. La diferencia entre crear un array de la manera normal o utilizar ArrayPool es esta:

La imagen muestra que utilizar ArrayPool es más de 100 veces más eficiente además de no consumir nada de memoria

Como se puede comprobar, utilizar ArrayPool en torno a 100 veces más eficiente. Además de la mejora de la eficiencia, también hay un ahorro de la memoria utilizada por el proceso.

Conclusiones

Existen más métodos de optimización para mejorar el rendimiento en aplicaciones .Net Core con lo que conseguir mejoras muy importantes aunque en esta entrada he querido plantear los más generalistas. Dicho lo anterior vuelvo a reiterar que todo esto solo se debe utilizar si se necesita y solo si se necesita. El hecho de utilizar más structs y no pensar que las clases son la solución a todo es una buena idea, al igual que utilizar las APIs con Span siempre que se pueda, pero no volverse loco sin motivo. Optimizar código suele traer consigo el dificultar la lectura respecto a código sin optimizar. Por supuesto, antes de plantearse hacer cambios como estos, siempre hay que medir. La optimización prematura es una de las mayores fuentes de problemas a la hora de desarrollar.

¿Conocías estas maneras de mejorar el rendimiento para .Net Core? Si crees que me he dejado algún método importante no dudes en dejarlo en los comentarios y amplio esta entrada. Todas las herramientas disponibles en el cinturón siempre son buenas. 🙂

Para que cada uno pueda tomar sus propias conclusiones, he dejado el código en GitHub listo para poder ejecutarlo.

8 comentarios en «Escribiendo código de alto rendimiento en .Net Core»

  1. Interesante mi estimado. este post lo tendre dentro de mis favoritos.
    Tengo una consulta, si quiero aprender mas sobre automatizacion de codigo, donde puedo encontras mas informacion asi como tu post?

    Responder
    • Buenas tardes Christian,
      Gracias por tu comentario!! Me alegro de que te haya gustado 🙂
      ¿A que te refieres exactamente con automatización de código? ¿Qué es lo que te gustaría que te recomendase?
      Autores hay muchos en muchas áreas diferentes, si centras un poco el tiro te puedo dar algunos enlaces de interés más acertados. También coméntame el nivel en el que te mueves ya que hay algunos autores que hacer cosas mucho más técnicas que otros.

      Atte

      Responder
  2. Buenas.

    Comentas que ArrayPool con ‘Rent‘ y ‘Return‘. reserva y libera la memoria de la pila.
    ¿Qué pasa si reservas X pero necesitas X+Y? ¿Una vez lleno X sigue con Y y luego libera solo X?

    Un saludo.

    Responder
    • Buenas noches Joseba,
      Gracias por tu comentario!!
      Al final ArrayPool lo que te devuelve es directamente un array del tamaño y tipo que le indicas. El tamaño y el tipo son inmutables una vez que ‘alquilas’ esa memoria por lo que si intentas acceder a un indice fuera de rango obtendrás un error como con cualquier otro array. Si lo que has reservado no es suficiente y necesitas más, tendrás que ‘alquilar’ otro trozo de memoria mayor, mover los datos y devolver el primer array, pero ojo si haces esto con datos por referencia porque se pueden liberar al devolver el primer array. Si no eres capaz de calcular la longitud total del array desde un principio porque necesitas ciertos datos del propio array, tendrías que hacer el proceso en dos pasos.

      Un ejemplo de esto puede ser trabajando con un socket donde los primeros X bytes sean la cabecera del mensaje donde viene la longitud total, primero ‘alquilarias’ un array para la cabecera que si es fija, procesarías la cabecera y devuelves esa memoria. Después ya sabiendo la longitud del cuerpo vuelves a hacer el proceso para leer el cuerpo.

      Realmente es un código más tedioso de escribir y de leer, pero en general cuando estas haciendo micro optimizaciones la legibilidad pasa a un segundo plano en favor de la eficiencia.

      Espero haber resuelto tu duda (sino ya sabes que puedes escribirme sin problemas)

      Un abrazo!

      Responder
  3. No sé si lo he entendido. por ejemplo, tengo:
    var array = ArrayPool.Shared.Rent(1024);

    ¿Como sabe cuanta memoria tiene que reservar si string es variable?
    ¿1024 * X?

    Responder
    • Buenas Joseba,
      Es una buena pregunta, lo primero de todo, cuando usas ArrayPool tienes que indicarle la clase ArrayPool, por lo que el compilador ya es capaz de inferir el tamaño de los objetos que tienen tamaño fijo. En la práctica es casi todo en .Net menos los strings, las colecciones y alguna cosilla suelta más.

      Para los casos en los que aun así el compilador no es capaz de calcular el coste como puede ser un string como dices, la verdad es que ahora mismo no te puedo decir al 100% como lo hace, son todo suposiciones, dame un par de días para que lo investigue a fondo y te contesto por aquí

      Atte

      Responder
    • Buenas Joseba,
      Perdona por la tardanza… He estado revisando a fondo la implementación de ArrayPool en GitHub y el funcionamiento básico es:

      1. Se pide la memoria y en caso de no existir se crea con un new
      2. Te da la memoria para trabajar
      3. Retornas la memoria y en ese momento se almacena en un buffer
      4. Cuando pides de nuevo memoria, te la da de la que has devuelto antes

      Es un poco más complejo pero una reducción a lo absurdo sí es. De esto se entiende que no haya consumo extra de memoria ya que la que se consume se devuelve y se la queda el pool.

      Sobre que el caso de string en concreto, la diferencia esta en como funciona string y en ciertas abstracciones que .Net hace por nosotros. En .Net las string son cadenas inmutables que realmente son una colección de caracteres de solo lectura, de hecho, si haces new string verás que puedes pasarle un char[]. donde se aplica mucha magia, por ejemplo si la cadena esta escrita en el código, el compilador la va a meter como parte de tu ensamblado.
      En caso de utilizar ArrayPool para cadenas que están literalmente en el código, no hay mucho misterio ya que simplemente estas leyendo un recurso del propio ensamblado. En el caso de que sean cadenas que se reciben desde algún otro lugar (una petición a un servicio por ejemplo), realmente es como hacer un new, por lo tanto si se esta reservando esa memoria a mayores ya que un string[] es salvando las distancias equivalente a char[][]. La diferencia es que para la reserva de la primera dimensión del array estas aprovechando la propia memoria del pool.

      Respondiendo literalmente a la pregunta, cuando tu haces ArrayPool.Share.Rent(1024), realmente lo que el pool te esta dando es la memoria para que coloques esos 1024 char[], que tienen su memoria a parte.

      No se si he contestado a la pregunta o te he creado más dudas, la verdad es que el tema de las cadenas es un tema complejo porque hay mucha magia por debajo para hacer que sean muy sencillas de utilizar, en cualquier caso si tienes más dudas ya sabes que solo tienes que preguntar 🙂

      Un abrazo

      Responder

Deja un comentario