Cuando un objeto, cadena o array es creado, la memoria requerida para almacenarlo se asigna desde un pool central llamada la pila. Cuando el ítem ya no está más en uso, la memoria que una vez ocupaba puede ser recuperada y usada para otras cosas más. En el pasado, era responsabilidad del programador el asignar y liberar estos bloques de memoria de la pila en forma explícita usando las llamadas de función apropiadas. Hoy en día, los sistemas en tiempo de ejecución como el motor Mono de Unity gestionan la memoria automáticamente. La gestión automática de memoria requiere menos esfuerzo de escritura de código que asigne/libera explícitamente, y reduce enormemente la posibilidad de una fuga de memoria (situación en donde la memoria es asignada, pero nunca es liberada después).
Cuando una función es llamada, los valores de sus parámetros son copiados a un área de la memoria que es reservada para este llamado en específico. Los tipos de datos que ocupan sólo unos pocos bytes pueden ser copiados en forma muy rápida y fácil. Sin embargo, es común que los objetos, cadenas y arrays sean muy grandes, y sería muy ineficiente si estos tipos de datos fueran copiados con regularidad. Afortunadamente, esto no es necesario; el verdadero espacio de almacenamiento para un ítem grande es asignado desde la pila y un pequeño valor de “apuntador” es usado para recordar su ubicación. A partir de entonces, sólo el apuntador necesita será copiado durante el paso de parámetros. Siempre que el sistema en tiempo de ejecución pueda localizar el ítem identificado por el apuntador, una copia sencilla de los datos puede ser usada tan a menudo como sea necesario.
Los tipos que son almacenados directamente y copiados durante el paso de parámetros son llamados tipos de valor (value types). Entre estos están los integers, floats, booleans y los tipos de estructuras de Unity (p.ej. Color y Vector3). Los tipos que son asignados en la pila y luego accesados por medio de un puntero son llamados tipos de referencia (reference types), dado que el valor almacenado en la variable sólo se “refiere” a los datos reales. Ejemplos de tipos de referencia son los objetos, las cadenas y los arrays.
El gestor de memoria realiza un seguimiento de las áreas de la pila que se sepa que no están siendo usadas. Cuando un nuevo bloque de memoria es solicitado (digamos, cuando un objeto es instanciado), el gestor escoge un área no usada a la que se le asigna el bloque y luego remueve la asignación de memoria en el espacio no usado conocido. Solicitudes posteriores son manejadas del mismo modo hasta que no hayan suficientes áreas libres y grandes para asignar el tamaño de bloque solicitado. Es altamente improbable en este punto que toda la memoria asignada de la pila esté todavía en uso. Un ítem por referencia en la pila únicamente puede ser accedido en la medida que todavía hayan variables por referencia que puedan localizarlo. Si todas las referencias a un bloque de memoria se han ido (es decir, las variables por referencia han sido reasignadas, o hay variables locales que están fuera de alcance) entonces la memoria que ocupan puede ser reasignada en forma segura.
Para determinar cuáles bloques de la pila no están más en uso, el gestor de memoria busca a través de todas las variables por referencia activas y marca los bloques que éstas están señalando como bloques “vivos”. Al final de la búsqueda, cualquier espacio entre los bloques vivos es considerado como vacío por el gestor de memoria y puede ser usado para posteriores asignaciones. Por motivos obvios, el proceso de localizar y liberar memoria no usada es conocido como recolección de basura (o abreviadamente, GC por “Garbage Collection”).
Unity uses the Boehm–Demers–Weiser garbage collector, a stop-the-world garbage collector. Whenever Unity needs to perform garbage collection, it stops running your program code and only resumes normal execution when the garbage collector has finished all its work. This interruption can cause delays in the execution of your game that last anywhere from less than one millisecond to hundreds of milliseconds, depending on how much memory the garbage collector needs to process and on the platform the game is running on. For real-time applications like games, this can become quite a big issue, because you can’t sustain the consistent frame rate that smooth animation require when the garbage collector suspends a game’s execution. These interruptions are also known as GC spikes, because they show as spikes in the Profiler frame time graph. In the next sections you can learn more about how to write your code to avoid unnecessary garbage-collected memory allocations while running the game, so the garbage collector has less work to do.
Garbage collection is automatic and invisible to the programmer but the collection process actually requires significant CPU time behind the scenes. When used correctly, automatic memory management will generally equal or beat manual allocation for overall performance. However, it is important for the programmer to avoid mistakes that will trigger the collector more often than necessary and introduce pauses in execution.
Hay algunos algoritmos famosos que son verdaderas pesadillas para el recolector, aunque parezcan inocentes a primera vista. Un ejemplo clásico es la concatenación repetida de cadenas:-
//C# script example
using UnityEngine;
using System.Collections;
public class ExampleScript : MonoBehaviour {
string ConcatExample(int[] intArray) {
string line = intArray[0].ToString();
for (i = 1; i < intArray.Length; i++) {
line += ", " + intArray[i].ToString();
}
return line;
}
}
El detalle clave aquí es que los nuevos pedazos no están siendo agregados uno por uno a la cadena en el mismo sitio en que está. Lo que realmente ocurre es que cada vez que se repite el ciclo, el contenido previo de la variable line se marca como muerto, y una nueva cadena completa es asignada para que contenga el pedazo original más la nueva parte al final. Dado que la cadena se vuelve más larga a medida que el valor de i se incrementa, la suma del espacio en la pila (también conocido como espacio de almacenamiento dinámico) que está siendo consumido también se incrementa, por lo que fácilmente son empleados cientos de bytes de espacio libre en la pila cada vez que esta función es invocada. Si necesitas concatenar juntas muchas cadenas, una opción mucho mejor es la clase System.Text.StringBuilder de la librería de Mono.
Sin embargo, incluso la concatenación repetida no causará muchos problemas si no es llamada con frecuencia, y en Unity esto usualmente implica la actualización de frames. Algo como:-
//C# script example
using UnityEngine;
using UnityEngine.UI;
using System.Collections;
public class ExampleScript : MonoBehaviour {
public Text scoreBoard;
public int score;
void Update() {
string scoreText = "Score: " + score.ToString();
scoreBoard.text = scoreText;
}
}
…asignará nuevas cadenas cada vez que Update sea invocado, y generará una filtración constante de basura nueva. Gran parte de esto puede ser evitado actualizando el texto sólo cuando el puntaje cambie:-
//C# script example
using UnityEngine;
using UnityEngine.UI;
using System.Collections;
public class ExampleScript : MonoBehaviour {
public Text scoreBoard;
public string scoreText;
public int score;
public int oldScore;
void Update() {
if (score != oldScore) {
scoreText = "Score: " + score.ToString();
scoreBoard.text = scoreText;
oldScore = score;
}
}
}
Otro problema potencial ocurre cuando una función devuelva un valor de array:-
//C# script example
using UnityEngine;
using System.Collections;
public class ExampleScript : MonoBehaviour {
float[] RandomList(int numElements) {
var result = new float[numElements];
for (int i = 0; i < numElements; i++) {
result[i] = Random.value;
}
return result;
}
}
Este tipo de función es muy elegante y conveniente cuando se crea un nuevo array que es ocupado con valores. No obstante, si es llamado repetidamente entonces va a ser asignado un nuevo espacio en la memoria en cada ocasión. Dado que los arrays pueden ser muy grandes, el espacio libre en la pila puede quedar utilizado rápidamente, resultando en frecuentes recolecciones de basura. Una forma de evitar este problema es hacer uso del hecho que un array es un tipo de referencia. Un array pasado a una función en forma de un parámetro puede ser modificado dentro de esta función, y el resultado permanecerá después que la función retorne y concluya. Una función como la de arriba con frecuencia puede ser reemplazado con algo como:-
//C# script example
using UnityEngine;
using System.Collections;
public class ExampleScript : MonoBehaviour {
void RandomList(float[] arrayToFill) {
for (int i = 0; i < arrayToFill.Length; i++) {
arrayToFill[i] = Random.value;
}
}
}
Lo que esto hace es sólo reemplazar el contenido existente del array con valores nuevos. Aunque esto requiere que la asignación inicial del array sea hecho en el código que invoca a la función (que no pareciera ser tan elegante), esta función no generará basura nueva cuando sea ejecutada.
If you are using the Mono or IL2CPP scripting backend, you can avoid CPU spikes during garbage collection by disabling garbage collection at run time. When you disable garbage collection, memory usage never decreases because the garbage collector does not collect objects that no longer have any references. In fact, memory usage can only ever increase when you disable garbage collection. To avoid increased memory usage over time, take care when managing memory. Ideally, allocate all memory before you disable the garbage collector and avoid additional allocations while it is disabled.
For more details on how to enable and disable garbage collection at run time, see the GarbageCollector Scripting API page.
You can also try an Incremental garbage collection option.
As mentioned above, it is best to avoid allocations as far as possible. However, given that they can’t be completely eliminated, there are two main strategies you can use to minimise their intrusion into gameplay.
Esta estrategia es usualmente mejor para juegos que tengan periodos largos de juego en donde la velocidad de cuadros sea la preocupación principal. Un juego de este tipo típicamente asigna bloques pequeños con frecuencia, pero estos bloques sólo estarán en uso brevemente. El tamaño típico de la pila al usar esta estrategia en iOS es alrededor de los 200KB y la recolección de basura tomará unos 5ms en un iPhone 3G. Si la pila se incrementa a 1MB, la recolección tomará unos 7ms. Por tanto, puede ser ventajoso en ocasiones solicitar una recolección de basura a un intervalo de frames regular. Esto por lo general hace que las recolecciones ocurran más frecuente de lo que estrictamente necesario, pero serán procesadas más rápido y con un efecto mínimo sobre la jugabilidad:-
if (Time.frameCount % 30 == 0)
{
System.GC.Collect();
}
Sin embargo, debes usar esta técnica con precaución y verificar las estadísticas del profiler para asegurarte que realmente se está reduciendo el tiempo de recolección para tu juego.
Esta estrategia funciona mejor en juegos donde las asignaciones (y por tanto, las recolecciones) sean relativamente poco frecuentes y puedan ser manejadas cuando hayan pausas en el ritmo del juego. Es útil que la pila sea tan grande como sea posible, pero sin llegarlo a ser demasiado como para que tu app sea cancelada por el sistema operativo debido a que está agotando la memoria del sistema. Sin embargo, el sistema en tiempo de ejecución de Mono evita en lo posible que el tamaño de la pila se expanda. Puedes expandir la pila en forma manual, pre-asignando un espacio de reserva durante el arranque (es decir, puedes instanciar un objeto “inútil” que es asignado meramente para efectos del gestor de memoria):-
//C# script example
using UnityEngine;
using System.Collections;
public class ExampleScript : MonoBehaviour {
void Start() {
var tmp = new System.Object[1024];
// make allocations in smaller blocks to avoid them to be treated in a special way, which is designed for large blocks
for (int i = 0; i < 1024; i++)
tmp[i] = new byte[1024];
// release reference
tmp = null;
}
}
Una pila suficientemente grande no deberá quedar completamente llena al estar realizando una recolección durante las pausas en el ritmo del juego. Cuando una pausa ocurre, puedes solicitar una recolección de forma explícita:-
System.GC.Collect();
Una vez más, debes tener cuidado al usar esta estrategia, y pon atención a las estadísticas del profiles en lugar de sólo asumir que está teniendo el efecto deseado.
Hay muchos casos donde puedes evitar la producción de basura con sólo reducir el número de objetos que son creados y destruidos. Hay ciertos tipos de objetos en los juegos, tales como proyectiles, que pueden ser encontrados una y otra vez aunque sólo un pequeño número estará siempre en el juego en ese instante. En casos como este, a menudo es posible reutilizar los objetos en vez de destruir los viejos y reemplazarlos con nuevos.
Incremental garbage collection spreads out the process of garbage collection over multiple frames.
Incremental garbage collection is the default garbage collection method Unity uses. This is still the Boehm-Demers-Weiser garbage collector, but Unity runs it in an incremental mode. Instead of doing a full garbage collection each time it runs, Unity splits up the garbage collection workload over multiple frames. This means that instead of one long interruption to your program’s execution to allow the garbage collector to do its work, Unity makes multiple, much shorter interruptions. While this does not make garbage collection faster overall, distributing the workload over multiple frames can significantly reduce the problem of garbage collection “spikes” that break the smoothness of your application.
The following screenshots from the Unity Profiler illustrate how incremental collection reduces framerate hiccups. In these profile traces, the light blue parts of the frame show how much time is used by script operations, the yellow parts show the time remaining in the frame until Vsync (waiting for the next frame to begin), and the dark green parts show the time spent for garbage collection.
The following screenshot displays a frame capture from the Unity Profiler in an application that does not use incremental garbage collection:
Without incremental garbage collection, a spike interrupts the otherwise smooth 60fps frame rate. This spike pushes the frame in which garbage collection occurs well over the 16 millisecond limit required to maintain 60FPS (this example drops more than one frame because of garbage collection.)
The following screenshot displays a frame capture from the Unity Profiler in an application that does use incremental garbage collection:
With incremental garbage collection enabled (above), the same project keeps its consistent 60fps frame rate, as the garbage collection operation is broken up over several frames, using only a small time slice of each frame (the darker green fringe just above the yellow Vsync trace).
The following screenshot displays a frame capture in the Unity Profiler from the same project, also running with incremental garbage collection enabled, but this time with fewer scripting operations per frame.
Again, the garbage collection operation is broken up over several frames. The difference is that this time, the garbage collection uses more time each frame, and requires fewer total frames to finish. This is because Unity adjusts the time it allocates to garbage collection based on the remaining available frame time if your application uses Vsync or Application.targetFrameRate. This way, Unity can run the garbage collection in time it would otherwise spend waiting, and therefore carry out garbage collection with a minimal performance impact.
All platforms other than WebGL support incremental garbage collection.
In addition, if you set the VSync Count to anything other than Don’t Sync (in your project’s Quality settings or with the Application.VSync property) or you enable the Application.targetFrameRate property, Unity automatically uses any idle time left at the end of a given frame for incremental garbage collection.
To exercise more precise control over incremental garbage collection behavior, you can use the Scripting.GarbageCollector class. For example, if you do not want to use VSync or a target frame rate, you can calculate the amount of time available before the end of a frame yourself, and provide that time to the garbage collector to use.
In most cases, incremental garbage collection can mitigate the problem of garbage collection spikes. However, in some cases, incremental garbage collection may not prove beneficial in practice.
When incremental garbage collection breaks up its work, it breaks up the marking phase in which it scans all managed objects to determine which objects are still in use and which objects can be cleaned up. Dividing up the marking phase works well when most of the references between objects don’t change between slices of work. When an object reference does change, those objects must be scanned again in the next iteration. Thus, too many changes can overwhelm the incremental garbage collector and cause a situation where the marking pass never finishes because it always has more work to do – in this case, the garbage collection falls back to doing a full, non-incremental collection.
Also, when using incremental garbage collection, Unity needs to generate additional code (known as write barriers) to inform the garbage collection whenever a reference has changed (so the garbage collection will know if it needs to rescan an object). This adds some overhead when changing references which can have a measurable performance impact in some managed code.
Still, most typical Unity projects (if there is such a thing as a “typical” Unity project) can benefit from incremental garbage collection, especially if they suffer from garbage collection spikes.
Always use the Profiler to verify that your game or program performs as you expect.
La gestión de memoria es un tema sutil y complejo al cual se le ha dedicado una gran cantidad de esfuerzo académico. Si estás interesado en aprender más sobre esto, la página memorymanagement.org es un excelente recurso que agrupa muchas publicaciones y artículos en línea. Más información sobre pooling de objetos puede ser encontrada en esta página de Wikipedia y también en Sourcemaking.com.