Hola Habr! En este artículo trataré de decir qué gestión de memoria en programas / aplicaciones es desde el punto de vista de un programador de aplicaciones. Esta no es una guía o manual exhaustivo, sino simplemente una descripción general de los problemas existentes y algunos enfoques para resolverlos.
¿Por qué es esto necesario? Un programa es una secuencia de instrucciones de procesamiento de datos (en el caso más general). Estos datos deben almacenarse , cargarse , transferirse , etc. de alguna manera. Todas estas operaciones no ocurren instantáneamente, por lo tanto, afectan directamente la velocidad de su aplicación final. La capacidad de administrar de manera óptima los datos en el proceso de trabajo le permitirá crear programas muy triviales y que requieren muchos recursos.
Nota: la mayor parte del material se presenta con ejemplos de juegos / motores de juegos (ya que este tema es más interesante para mí personalmente), sin embargo, la mayor parte del material se puede aplicar a servidores de escritura, aplicaciones de usuario, paquetes de gráficos, etc.

Es imposible tener todo en mente. Pero si no lograste cargarlo, obtendrás jabón
De buenas a primeras
Sucedió en la industria que los grandes proyectos de juegos AAA se desarrollan principalmente en motores escritos con C ++. Una de las características de este lenguaje es la necesidad de una gestión manual de la memoria. Java / C # etc. Cuentan con recolección de basura (GarbageCollection / GC): la capacidad de crear objetos y aún no liberar la memoria usada a mano. Este proceso simplifica y acelera el desarrollo, pero también puede causar algunos problemas: un recolector de basura activado periódicamente puede matar todo el tiempo blando en tiempo real y agregar congelaciones desagradables al juego.
Sí, en proyectos como "Minecraft", el GC puede no ser notable, ya que generalmente no exigen los recursos de la computadora, pero los juegos como "Red Dead Redemption 2", "God of War", "Last of Us" funcionan "casi" en la cima del rendimiento del sistema y, por lo tanto, no solo necesitan grandes la cantidad de recursos, pero también en su distribución competente.
Además, al trabajar en un entorno con asignación automática de memoria y recolección de basura, puede encontrar una falta de flexibilidad en la administración de recursos. No es ningún secreto que Java oculta todos los detalles de implementación y aspectos de su trabajo, por lo que en la salida solo tiene la interfaz instalada para interactuar con los recursos del sistema, pero puede que no sea suficiente para resolver algunos problemas. Por ejemplo, iniciar un algoritmo con un número no constante de asignaciones de memoria en cada cuadro (esto puede ser una búsqueda de rutas para AI, verificar la visibilidad, la animación, etc.) inevitablemente conduce a una caída catastrófica en el rendimiento.
Cómo se ven las asignaciones en el código
Antes de continuar la discusión, me gustaría mostrar cómo el trabajo con memoria en C / C ++ ocurre directamente con un par de ejemplos. En general, la interfaz estándar y más simple para asignar memoria de proceso está representada por las siguientes operaciones:
// size void* malloc(size_t size); // p void free(void* p);
Aquí puede agregar funciones adicionales que le permiten asignar una pieza de memoria alineada:
// C11 - , * alignment void* aligned_alloc(size_t size, size_t alignment); // Posix - // address (*address = allocated_mem_p) int posix_memalign(void** address, size_t alignment, size_t size);
Tenga en cuenta que las diferentes plataformas pueden admitir diferentes estándares de función, disponibles por ejemplo en macOS y no disponibles en win.
Mirando hacia el futuro, pueden ser necesarias áreas de memoria especialmente alineadas para que pueda acceder a la línea de caché del procesador y para realizar cálculos utilizando un conjunto extendido de registros ( SSE , MMX , AVX , etc.).
Un ejemplo de un programa de juguete que asigna memoria e imprime valores de búfer, interpretándolos como enteros con signo:
/* main.cpp */ #include <cstdio> #include <cstdlib> int main(int argc, char** argv) { const int N = 10; int* buffer = (int*) malloc(sizeof(int) * N); for(int i = 0; i < N; i++) { printf("%i ", buffer[i]); } free(buffer); return 0; }
En macOS 10.14, este programa se puede construir y ejecutar con el siguiente conjunto de comandos:
$ clang++ main.cpp -o main $ ./main
Nota: en adelante no quiero cubrir las operaciones de C ++ como new / delete, ya que es más probable que se usen para construir / destruir objetos directamente, pero usan las operaciones habituales con memoria como malloc / free.
Problemas de memoria
Existen varios problemas que surgen cuando se trabaja con la RAM de la computadora. Todos ellos, de una forma u otra, son causados no solo por las características del sistema operativo y el software, sino también por la arquitectura del hierro en el que funciona todo esto.
1. Cantidad de memoria
Desafortunadamente, la memoria es físicamente limitada. En PlayStation 4, esto es 8 GiB GDDR5, 3.5 GiB de los cuales el sistema operativo se reserva para sus necesidades . La memoria virtual y el intercambio de páginas no ayudarán mucho, ya que el intercambio de páginas en el disco es una operación muy lenta (dentro de N marcos fijos por segundo, si hablamos de juegos).
También vale la pena señalar el limitado " presupuesto ": alguna limitación artificial en la cantidad de memoria utilizada, creada para ejecutar la aplicación en varias plataformas. Si está creando un juego para una plataforma móvil y desea admitir no solo uno, sino también una línea completa de dispositivos, tendrá que limitar su apetito en aras de proporcionar un mercado de ventas más amplio. Esto se puede lograr simplemente limitando el consumo de RAM y la capacidad de configurar esta restricción según el dispositivo en el que se inicia realmente el juego.
2. Fragmentación
Un efecto desagradable que aparece durante el proceso de asignaciones múltiples de piezas de memoria de varios tamaños. Como resultado, obtienes un espacio de direcciones fragmentado en muchas partes separadas. La combinación de estas partes en bloques individuales de mayor tamaño no funcionará, ya que parte de la memoria está ocupada y no podemos moverla libremente.

Fragmentación por el ejemplo de asignaciones secuenciales y lanzamientos de bloques de memoria
Como resultado: podemos tener suficiente memoria libre cuantitativamente, pero no cualitativamente. Y la siguiente solicitud, por ejemplo, "asignar espacio para la pista de audio", el asignador no podrá satisfacer, porque simplemente no hay una sola pieza de memoria de este tamaño.
3. caché de la CPU

Jerarquía de memoria de la computadora
El caché de un procesador moderno es un enlace intermedio que conecta la memoria principal (RAM) y el procesador se registra directamente. Sucedió que el acceso de lectura / escritura a la memoria es una operación muy lenta (si hablamos de la cantidad de ciclos de reloj de la CPU necesarios para ejecutar). Por lo tanto, existe cierta jerarquía de caché (L1, L2, L3, etc.), que permite, por así decirlo, "según algunas predicciones", cargar datos desde la RAM o introducirlos lentamente en una memoria más lenta.
Colocar objetos del mismo tipo en una fila en la memoria le permite acelerar "significativamente" el proceso de procesamiento (si el procesamiento se produce secuencialmente), ya que en este caso es más fácil predecir qué datos se necesitarán a continuación. Y por "significativo" se entiende ganancias de productividad a veces. Los desarrolladores del motor de Unity han hablado repetidamente sobre esto en sus informes en el GDC .
4. Multihilo
Garantizar el acceso seguro a la memoria compartida en un entorno de subprocesos múltiples es uno de los principales problemas que tendrá que resolver al crear su propio motor de juego / juego / cualquier otra aplicación que use múltiples hilos para lograr un mayor rendimiento. Las computadoras modernas están dispuestas de una manera muy no trivial. Tenemos una estructura de caché compleja y varios núcleos de calculadora. Todo esto, si se usa incorrectamente, puede conducir a situaciones en las que los datos compartidos de su proceso se dañarán como resultado de varios subprocesos (si intentan trabajar simultáneamente con estos datos sin control de acceso). En el caso más simple, se verá así:

No quiero profundizar en el tema de la programación multiproceso, ya que muchos de sus aspectos van mucho más allá del alcance del artículo o incluso de todo el libro.
5. Malloc / gratis
Las operaciones de asignación / liberación no ocurren instantáneamente. En los sistemas operativos modernos, si hablamos de Windows / Linux / MacOS, se implementan bien y funcionan rápidamente en la mayoría de las situaciones . Pero potencialmente esta es una operación que consume mucho tiempo. No solo se trata de una llamada al sistema, sino que, dependiendo de la implementación, puede llevar un tiempo encontrar una memoria adecuada (primer ajuste, mejor ajuste, etc.) o encontrar un lugar para insertar y / o fusionar el área liberada.
Además, es posible que la memoria recién asignada no se asigne realmente a páginas físicas reales, lo que también puede llevar algún tiempo en el primer acceso.
Estos son detalles de implementación, pero ¿qué pasa con la aplicabilidad? Malloc / new no tiene idea de dónde, cómo o por qué los llamaste. Asignan memoria (en el peor de los casos) de 1 KiB y 100 MiB por igual ... igualmente malo. Directamente, la estrategia de uso se deja al programador o al que implementó el tiempo de ejecución de su programa.
6. corrupción de la memoria
Como dice la wiki , este es uno de los errores más impredecibles que aparece solo durante el curso del programa, y con mayor frecuencia es causado directamente por errores en la redacción de este programa. ¿Pero cuál es este problema? Afortunadamente (o desafortunadamente), no está relacionado con la corrupción de su computadora. Más bien, muestra una situación en la que está intentando trabajar con memoria que no le pertenece . Explicaré ahora:
- Esto puede ser un intento de leer / escribir en una parte de la memoria no asignada.
- Ir más allá de los límites del bloque de memoria que se le proporcionó. Este problema es un tipo de caso especial del problema (1), pero es peor porque el sistema le dirá que superó los límites solo cuando deja la página mostrada. Es decir, potencialmente, este problema es muy difícil de detectar, porque el sistema operativo solo puede responder si deja los límites de las páginas virtuales que se le muestran. Puede estropear la memoria del proceso y obtener un error muy extraño del lugar desde el que no se esperaba en absoluto.
- Liberar una memoria ya liberada (suena extraño) o aún no asignada
- etc.
En C / C ++, donde hay aritmética de puntero, te encontrarás con esto una o dos veces. Sin embargo, en Java Runtime, tienes que esforzarte bastante para obtener este tipo de error (no lo he intentado yo mismo, pero creo que esto es posible, de lo contrario la vida sería demasiado simple).
7. Fugas de memoria
Es un caso especial de un problema más general que ocurre en muchos lenguajes de programación. La biblioteca estándar de C / C ++ proporciona acceso a los recursos del sistema operativo. Pueden ser archivos, sockets, memoria, etc. Después de su uso, el recurso debe estar correctamente cerrado y
el recuerdo ocupado por él debería ser liberado. Y si hablamos específicamente sobre la liberación de memoria, las fugas acumuladas como resultado del programa pueden provocar un error de "falta de memoria" cuando el sistema operativo no podrá satisfacer la próxima solicitud de asignación. A menudo, el desarrollador simplemente olvida liberar la memoria usada por una razón u otra.
Aquí vale la pena agregar sobre el cierre correcto y la liberación de recursos en la GPU, porque los primeros controladores no permitieron continuar trabajando con la tarjeta de video si la sesión anterior no se completó correctamente. Solo reiniciar el sistema podría resolver este problema, lo cual es muy dudoso: obligar al usuario a reiniciar el sistema después de ejecutar su aplicación.
8. Puntero colgando
Un puntero colgante es una jerga que describe una situación en la que un puntero se refiere a un valor no válido. Una situación similar puede surgir fácilmente cuando se utilizan punteros clásicos de estilo C en un programa C / C ++. Suponga que asignó memoria, guardó la dirección en el puntero p y luego liberó la memoria (vea el ejemplo de código):
// void* p = malloc(size); // ... - // free(p); // p? // *p == ?
El puntero almacena algún valor, que podemos interpretar como la dirección del bloque de memoria. Sucedió que no podemos decir si este bloque de memoria es válido o no. Solo un programador, basado en ciertos acuerdos, puede operar con un puntero. Comenzando con C ++ 11, se introdujeron una serie de punteros adicionales de "punteros inteligentes" en la biblioteca estándar, que permiten de alguna manera debilitar el control de recursos por parte del programador mediante el uso de metainformación adicional dentro de ellos (más sobre esto más adelante).
Como solución parcial, puede usar el valor especial del puntero, que nos indicará que no hay nada en esta dirección. En C, la macro NULL se usa como el valor de este valor, y en C ++, se usa la palabra clave del lenguaje nullptr. La solución es parcial porque:
- El valor del puntero debe establecerse manualmente, por lo que el programador simplemente puede olvidarse de hacerlo.
- nullptr o solo 0x0 se incluye en el conjunto de valores aceptados por el puntero, lo que no es bueno cuando el estado especial de un objeto se expresa a través de su estado habitual. Este es algún tipo de legado y, por acuerdo, el sistema operativo no le asignará un trozo de memoria cuya dirección comience con 0x0.
Código de muestra con nulo:
// - p free(p); p = nullptr; // p == nullptr ,
Puede automatizar este proceso hasta cierto punto:
void _free(void* &p) { free(p); p = nullptr; } // - p _free(p); // p == nullptr, //
9. Tipo de memoria
RAM es una memoria de acceso aleatorio de propósito general común, cuyo acceso a través del bus central tiene todos los núcleos de su procesador y dispositivos periféricos. Su volumen varía, pero a menudo estamos hablando de N gigabytes, donde N es 1,2,4,8,16 y así sucesivamente. Las llamadas malloc / free buscan colocar el bloque de memoria que desea en la RAM de la computadora.
VRAM (memoria de video): memoria de video, suministrada con la tarjeta de video / acelerador de video de su PC. Como regla, es más pequeño que la RAM (aproximadamente 1.2.4 GiB), pero tiene una alta velocidad. La distribución de este tipo de memoria es manejada por el controlador de la tarjeta de video, y la mayoría de las veces no tiene acceso directo a ella.
No existe tal separación en la PlayStation 4, y toda la RAM está representada por un solo 8 gigabytes en GDDR5. Por lo tanto, todos los datos para el procesador y el acelerador de video están cerca.
La buena gestión de recursos en el motor del juego incluye una asignación de memoria competente tanto en la RAM principal como en el lado VRAM. Aquí puede encontrar duplicación cuando los mismos datos están allí o allí, o con una transferencia excesiva de datos de RAM a VRAM y viceversa.
Como ilustración de todos los problemas expresados : puede ver los aspectos de las computadoras del dispositivo en el ejemplo de la arquitectura PlayStation 4 (Fig.). Aquí está el procesador central, 8 núcleos, cachés de nivel L1 y L2, buses de datos, RAM, acelerador de gráficos, etc. Para obtener una descripción completa y detallada, consulte "Game Engine Architecture" de Jason Gregory.

Arquitectura de PlayStation 4
Enfoques generales
No hay una solución universal. Pero hay un conjunto de algunos puntos en los que debe enfocarse si va a implementar la asignación manual y la administración de memoria en su aplicación. Esto incluye contenedores y asignadores especializados, estrategias de asignación de memoria, diseño de sistemas / juegos, administradores de recursos y más.
Tipos de asignadores
El uso de asignadores de memoria especiales se basa en la siguiente idea: usted sabe qué tamaño, en qué momentos de trabajo y en qué lugar necesitará piezas de memoria. Por lo tanto, puede asignar la memoria necesaria, estructurarla de alguna manera y usarla / reutilizarla. Esta es la idea / concepto general de usar asignadores especiales. Lo que son (por supuesto, no todos) se puede ver más allá:
Asignador lineal
Representa un búfer de espacio de direcciones contiguo. En el curso del trabajo, le permite asignar secciones de memoria de tamaño arbitrario (de modo que quepan en un búfer). Pero puede liberar toda la memoria asignada solo 1 vez. Es decir, una pieza arbitraria de memoria no se puede liberar: permanecerá como ocupada hasta que todo el búfer se marque como limpio. Este diseño proporciona la asignación y liberación de O (1), lo que garantiza una velocidad bajo cualquier condición.

Caso de uso típico: en el proceso de actualización del estado del proceso (cada cuadro en el juego) puede usar LinearAllocator para asignar buffers tmp para cualquier necesidad técnica: procesamiento de entrada, trabajo con cadenas, análisis de comandos de ConsoleManager en modo de depuración, etc.
Asignador de pila
Modificación de un asignador lineal. Le permite liberar memoria en el orden inverso de asignación, en otras palabras, se comporta como una pila normal de acuerdo con el principio LIFO. Puede ser muy útil para realizar cálculos matemáticos cargados (jerarquía de transformaciones), para implementar el trabajo del subsistema de secuencias de comandos, para cualquier cálculo en el que se conozca de antemano el procedimiento indicado para liberar memoria.

La simplicidad del diseño proporciona O (1) asignación de memoria y velocidad de liberación.
Asignador de piscina
Le permite asignar bloques de memoria del mismo tamaño. Se puede implementar como un búfer de espacio de direcciones continuo, dividido en bloques de un tamaño predeterminado. Estos bloques pueden formar una lista vinculada. Y siempre sabemos qué bloque dar en la próxima asignación. Esta metainformación puede almacenarse en los propios bloques, lo que impone una restricción en el tamaño mínimo del bloque (sizeof (void *)). En realidad, esto no es crítico.

Dado que todos los bloques son del mismo tamaño, no nos importa qué bloque devolver, y por lo tanto, todas las operaciones de asignación / desasignación se pueden realizar en O (1).
Asignador de cuadros
Asignador lineal pero solo con referencia al marco actual: le permite hacer la asignación de memoria tmp y luego liberar automáticamente todo al cambiar el marco. Debe destacarse por separado, ya que esta es una entidad global y única dentro del marco del juego de tiempo de ejecución, y por lo tanto, puede estar hecha de un tamaño muy impresionante, digamos un par de docenas de MiB, que serán muy útiles al cargar recursos y procesarlos.
Asignador de doble marco
Es un asignador de doble marco, pero con algunas características. Le permite asignar memoria en el marco actual y usarla tanto en el marco actual como en el siguiente. Es decir, la memoria que asignó en el cuadro N se liberará solo después del cuadro N + 1. Esto se realiza cambiando el cuadro activo para resaltar al final de cada cuadro.

Pero este tipo de asignador, como el anterior, impone una serie de restricciones en la vida útil de los objetos creados en la memoria asignada a él. Por lo tanto, debe tener en cuenta que al final del marco, los datos simplemente se vuelven inválidos y el acceso repetido a ellos puede causar serios problemas.
Asignador estático
Este tipo de asignador asigna memoria de un búfer, obtenido, por ejemplo, en la etapa de inicio del programa, o capturado en la pila en un marco de función. Por tipo, puede ser absolutamente cualquier asignador: lineal, pool, stack. ¿Por qué se llama estática ? El tamaño del búfer de memoria capturado debe conocerse en la etapa de compilación del programa. Esto impone una limitación significativa: la cantidad de memoria disponible para este asignador no se puede cambiar durante la operación. ¿Pero cuáles son los beneficios? El búfer utilizado se capturará automáticamente y luego se liberará (al finalizar el trabajo o al salir de la función). Esto no carga el montón, lo salva de la fragmentación, le permite asignar rápidamente la memoria en su lugar.
Puede ver el ejemplo del código con este asignador, si necesita dividir la cadena en subcadenas y hacer algo con ellas:

También se puede notar que el uso de memoria de la pila en teoría es mucho más eficiente, porque apilar el marco de la función actual con una alta probabilidad ya estará en el caché del procesador.
Todos estos asignadores de alguna manera resuelven los problemas de fragmentación, con falta de memoria, con la velocidad de recepción y liberación de bloques del tamaño requerido, con la vida útil de los objetos y la memoria que ocupan.
También se debe tener en cuenta que el enfoque correcto para el diseño de la interfaz le permitirá crear una especie de jerarquía de asignadores cuando, por ejemplo: el grupo asigna memoria de la asignación de cuadros, y la asignación de cuadros a su vez asigna memoria de la asignación lineal. Se puede continuar una estructura similar, adaptándose a sus tareas y necesidades.

Veo una interfaz similar para crear jerarquías de la siguiente manera:
class IAllocator { public: virtual void* alloc(size_t size) = 0; virtual void* alloc(size_t size, size_t alignment) = 0; virtual void free (void* &p) = 0; }
malloc/free , . , , . / , .
Smart pointer — C++ ++11 ( boost, ). -, , - , . .
? :
- (/)
:
Unique pointer
1 ( ).
unique pointer , . , .. 1 / .
uniquePtr1 uniquePtr2, uniquePtr1 , . 1 .

Shared pointer
(reference counting). , , . , , , .

. -, , . . -, - .
Weak pointer
. , . ¿Qué significa esto? shared pointer. , shared pointer , . , shared pointer weak pointer. , (shared) , weak pointer shared pointer. — weak pointer , , , .

shared, weak pointer meta-data . - , .. , O(N) overhead , N — - . , . , . .
: . , shared pointer, , ( ) - - - . . meta-info , , . Un ejemplo:
/* */ /* , shared pointer */ Array<TSharedPtr<Object>> objects; objects.add(newShared<Object>(...)); ... objects.add(newShared<Object>(...));
/* ( meta-info ) */ Array<Object> objects; objects.emplace(...); ... objects.emplace(...);
. . Sobre esto más allá.
Unique id
, . (id/identificator), , , -. :
, id. , , , id.
, ( , )
id , , id.
. , id, .
: id, , id, .
id , (Vulkan, OpenGL), (Godot, CryEngine). EntityID CryEngine .
, id : . , ( ), , .
/* */ class ID { uint32 index; uint32 generation; }
/* - / */ class ObjectManager { public: ID create(...); void destroy(ID); void update(ID id, ...); private: Array<uint32> generations; Array<Objects> objects; }
ID , ID . :
generation = generations[id.index]; if (generation == id.generation) then /* */ else /* , */
id generation 1 id ids.
C++ , . std, , . :
- Linked list —
- Array — /
- Queue —
- Stack —
- Map —
- Set —
? memory corruption. / , , , , .
, , . , , / .
, , . , ( ) . , malloc/free , , .
? , (/ ), , , . , , , .

ryEngine Sandbox:
, Unreal, Unity, CryEngine ., , . , , , — , .
Pre-allocating
, / .
: malloc/free . , "run out of memory", . . , (, , .).
. . , - . , malloc/free, : , , .
. : , , , .. .
: , , , . open-source , , . , , — malloc/free.
GDC CD Project Red , , "The Witcher: Blood and Wine" () . , , , , .

Naughty Dog , "Uncharted 4: A Thief's End" , (, ) .

Conclusión
, , , . , . / , , - .. , (, ).