¿Cómo evitan los estudios de juegos las pérdidas de memoria en los juegos complejos de C ++?

Te puedo decir cómo lo hago. Tengo un juego complejo de aproximadamente un cuarto de millón de líneas de código (kronosaurio / Trascendencia) y utilicé algunas técnicas diferentes para evitar pérdidas de memoria:

  1. La técnica más común es confiar en destructores . Asignar memoria en un constructor; desasignar en un destructor. Luego, asegúrese de manejar copiar y asignar semántica. Esto es particularmente útil si regresa desde el medio de una función (particularmente con excepciones).
  2. Si usa punteros para estructuras asignadas, asegúrese de inicializarlas (a NULL) en un constructor . Cuando establezca el puntero, verifique para asegurarse de que sea NULL. Si no, está apuntando a alguna memoria asignada. O necesita liberarlo antes de configurar el puntero o tal vez tiene un error (si no esperaba que fuera NULL).
  3. En equipos más grandes, uno comienza a tener problemas porque la semántica de sistemas particulares no siempre es clara. Por ejemplo, cuando obtienes un puntero de un subsistema, ¿se supone que debes liberarlo o no? Estas reglas son diferentes para cada subsistema y es fácil perder la noción. Ayuda a establecer algunas convenciones para ayudar a las personas a recordar. Por ejemplo, si una función devuelve un puntero que necesita liberar, debe tener la palabra “alloc” (p. Ej., “AllocTexture”). De lo contrario, debería usar la palabra “get” (por ejemplo, “GetTexture”). Este tipo de convenciones simples ahorran mucho tiempo.
  4. A veces no puede confiar en los destructores porque no puede controlar la vida útil. Por ejemplo, puede compartir una estructura asignada entre sistemas y nunca estar seguro de si alguien más todavía tiene un puntero. En ese caso, debe usar recuentos de ref . Simplemente aumente un recuento de referencias cada vez que distribuya la estructura y disminuya al liberar. Cuando el recuento es 0, puedes liberar. Lo mejor de todo es que puede ajustar sus referencias a la estructura en un objeto. Luego puede usar la regla # 1 para asegurarse de que sus referencias sean correctas: agregue una referencia en un constructor; desreferencia en un destructor. [Este es el patrón de puntero inteligente .]
  5. Otra técnica es tener una clase singleton que gestiona / almacena en caché un recurso. Las personas que llaman siempre pasan por la clase singleton para acceder al recurso. Por ejemplo, imagina un singleton que asigna todas las texturas utilizadas en el juego. Cuando un objeto necesita una textura (para pintar), le pide al subsistema un puntero. Como todos siempre pasan por el singleton, nadie se aferra a ningún puntero. El singleton es el único código que debe preocuparse por la asignación / liberación. Esto también le permite implementar la semántica de caché.
  6. Por último, la mejor defensa contra las pérdidas de memoria es la misma que la defensa contra los errores en general. Asegúrese de que sus contratos / interfaces sean claros y estén bien definidos. Asegúrese de tener pautas consistentes y comente con firmeza. Y, por último, asegúrese de tener grandes personas en su equipo comprometidas con la excelencia.

Yo uso punteros inteligentes. Si no sabe cuáles son, déjeme darle una breve descripción:

Hay tres tipos de punteros inteligentes. Son:

  • shared_ptr
  • débil_ptr
  • unique_ptr

shared_ptr

Shared_ptr es un tipo de puntero inteligente. Se declaran de la siguiente manera:

  vacío foo () {
   // crea el shared_ptr y asigna un objeto para administrar
   shared_ptr  mySmartObject (nuevo MyObject ());
 }

Hay dos cualidades que hacen interesante un shared_ptr:

  1. Puede compartir un objeto con otros punteros de compartir.
  2. Aumenta su recuento cada vez que se comparte un objeto. Cuando este recuento cae a cero, el objeto se elimina automáticamente.

Por ejemplo, el siguiente código muestra cómo un share_ptr puede compartir un objeto con otro shared_ptr:

  vacío foo () {
 // crea el shared_ptr y asigna un objeto para administrar
 shared_ptr  mySmartPointer (nuevo MyObject ());

 // crea un segundo shared_ptr y comparte el objeto
 shared_ptr  anotherSmartObject = mySmartPointer; 
 }

En el ejemplo anterior, el recuento de la propiedad compartida de shared_ptr de MyObject es 2.

El único momento en que se lanzará el objeto es cuando este recuento desciende a 0. Por lo tanto, asignar un nullptr a uno de shared_ptr no liberará el objeto porque el recuento ahora sería 1. Para liberar el objeto, ambos shared_ptr tienen que ser establecido en nullptr.

  vacío foo () {
   // crea el shared_ptr y asigna un objeto para administrar
   shared_ptr  mySmartPointer (nuevo MyObject ());
  
   // crea un segundo shared_ptr y comparte el objeto
   shared_ptr  anotherSmartObject = mySmartPointer; 
  
   // contar hasta ahora es 2
   // establece mySmartPointer en nullptr
   mySmartPointer = nullptr; 
  
   // cuenta ahora es 1, por lo que el objeto no se lanzará
   // establece otroSmartObject en nullptr
   anotherSmartObject = nullptr;
  
   // cuenta ahora es cero, por lo tanto, el objeto será lanzado
   //... Haz otras cosas aquí
 }

débil_ptr

Hay otro tipo de punteros inteligentes conocidos como weak_ptr . A diferencia de shared_ptr, estos punteros solo observan la vida del objeto que se administra.

Cuando declaras un débil_ptr, apunta a NADA. Puede apuntar un débil_ptr a un objeto solo mediante copia o asignación desde un compartido_ptr o un débil_ptr existente. Por ejemplo:

  vacío foo () {
   // crea el shared_ptr y asigna un objeto para administrar
   shared_ptr  mySmartPointer (nuevo MyObject ());
  
   // construye un puntero débil a partir de un shared_ptr
   débil_ptr  myWeakPointer (mySmartPointer);
  
   // Un punto débil_ptr vacío apunta a NADA
   débil_ptr  mySecondWeakPointer;
  
   // Ahora apunta a algo
   mySecondWeakPointer = mySmartPointer;
  
   //... Haz otras cosas aquí
 }

Debilidad_ptr solo observa el objeto gestionado. Es muy poco lo que puedes hacer con un débil_ptr. No puede desreferenciar el objeto administrado como lo haría con shared_ptr.

Entonces, ¿cuál es el propósito de un débil_ptr?

Imagine que desea llamar a un objeto administrado pero no está seguro de si el objeto todavía existe. Aquí es donde un punto débil es útil. Le permite probar si un objeto administrado aún está vivo antes de acceder a sus miembros.

Los pasos para hacerlo son simples. Primero obtienes un shared_ptr de weak_ptr llamando al método lock () en weak_ptr. Básicamente, esto crea un shared_ptr a partir de weak_ptr. Luego pruebe si el objeto administrado está vivo. Por ejemplo:

  vacío foo () {
   // crea el shared_ptr y asigna un objeto para administrar
   shared_ptr  mySmartPointer (nuevo MyObject ());
  
   // construye un puntero débil a partir de un shared_ptr
   débil_ptr  myWeakPointer (mySmartPointer);
  
   //... Haz otras cosas aquí

   // Quizás se haya asignado un nullptr a shared_ptr pero no estamos seguros
  
   // prueba si el objeto gestionado sigue vivo
   // obtener un shared_ptr del débil_ptr
   shared_ptr  p1 = myWeakPointer.lock ()
  
   // prueba si el objeto gestionado todavía existe
   si (p1) {
  
   //... El objeto sigue vivo
  
   }más{
  
   //..No, el objeto se fue hace mucho, amigo mío.
  
   }
 }

unique_ptr

Un unique_ptr es un tipo de puntero inteligente que exige que los objetos sean propiedad de un único unique_ptr. Esto está en completo contraste con shared_ptr, donde un objeto puede ser administrado por varios shared_ptr. Esta propiedad única se aplica al no permitir la construcción de copias y la asignación de copias. Por lo tanto, no puede copiar o asignar un unique_ptr a otro unique_ptr.

Entonces, ¿cómo declaras un unique_ptr? Aquí hay un ejemplo:

  vacío foo () {
   // crea el unique_ptr y asigna un objeto para administrar
   unique_ptr  p1 (nuevo MyObject ());
  
   // La construcción de copia no está permitida
   unique_ptr  p2 (p1);
  
   // Copiar asignación tampoco está permitido
   unique_ptr  p3;
   p3 = p1;
 }

Aunque el constructor de copia y las asignaciones de copia no están definidas en unique_ptr, el constructor de movimiento y las asignaciones de movimiento están definidas. Por lo tanto, un unique_ptr puede transferir la propiedad de un objeto administrado.

Después de una construcción de movimiento, el unique_ptr recién creado posee el objeto y el unique_ptr original no posee nada.

Esto es útil cuando un método devuelve un unique_ptr. Dado que el valor devuelto de un método es un valor r, la presencia de construcción de movimiento y asignación de movimiento significa que podemos devolver un unique_ptr de un método y asignarlo a otro unique_ptr. Por ejemplo:

  unique_ptr  createPointer () {

   // crea un puntero local único
   unique_ptr  p1 (nuevo MyObject ());
   volver p1;  // p1 cederá la propiedad

 }

 vacío principal(){
   unique_ptr  a1;  // unique_ptr apunta a nada
   a1 = createPointer ();  // ahora a1 posee el objeto
 }

Entonces ahí lo tienes. Espero que esta breve descripción sea útil.

Una variedad de formas diferentes.

Otras personas han mencionado punteros inteligentes. Recomiendo aprender a usarlos lo antes posible si trabaja con C ++.

A veces puede ser bueno tener un “grupo” fijo de objetos, con alguna información sobre el próximo “libre”. La desventaja de esto es que está desperdiciando memoria cuando no se usa, pero la ventaja es que es rápida (sin asignación / desasignación) y sin posibilidad de pérdidas de memoria. Bueno para objetos pequeños como balas y partículas, donde muchos se crean y destruyen todo el tiempo.

Las fábricas / gerentes también ayudan a controlar las cosas. Tener “gerentes” dedicados que manejen una lista de otros objetos significa que la responsabilidad de su vida útil está en un solo lugar. Algo así como TroopManager, con un método público como addTroop (equipo, tipo, posición) y con update () y draw () que se llamará una vez por cuadro que maneja todos los alloc / dealloc internamente. La lista real de objetos debe ser privada y solo se puede interactuar a través de una interfaz pública estricta.

También puede anular nuevo y eliminar. NUNCA haga esto globalmente, excepto por razones de depuración muy específicas. Pero se puede usar para que los objetos implementen su propio sistema de conteo de referencias si necesita algún comportamiento / registro específico para su juego.

Buen uso de depuradores. Aprenda a usar el depurador en cualquier IDE o entorno que use. Es realmente importante

Estructura de código buena / clara. Su código y jerarquía de objetos deberían dificultar la filtración. Esto se relaciona con las fábricas / punto de gerente. Es difícil explicar esto y proviene de la experiencia, pero cada vez que escribes un código, debes tener la mitad de tu cerebro pensando en cómo podría ser mal usado o maltratado, y cuándo podrían ocurrir problemas y cómo protegerte contra ellos.

Buenas prácticas de codificación. Por ejemplo, el “nuevo” operador siempre activa una voz en mi oído: “ADVERTENCIA: ¡posible fuga de memoria entrante que le causará varios días frustrantes de pérdida de productividad al tratar de localizarlo!” Este capricho proviene solo de la experiencia. Entonces, cada vez que escribo “nuevo”, pienso inmediatamente en la vida útil del objeto y quién es responsable de su destrucción, por lo que la “eliminación” correspondiente es SIEMPRE lo siguiente que escribo, teniendo cuidado con la lógica de cuándo se llamará .

Nadie está exento de encontrar errores en su código, pero en estos días nunca sufro pérdidas de memoria. *toco madera*

Las compañías de juegos C ++ emplean tradicionalmente a los programadores más conscientes del rendimiento.

La recolección de basura está fuera de la ventana, porque no puede saber cuándo va a suceder o cuánto tiempo llevará, lo que lleva a tartamudear las velocidades de cuadros. Además, C ++ no es basura recolectada. shared_ptr es la forma moderna de lidiar con esto, pero hay algunos gastos generales de rendimiento debido al recuento de referencias.

Las asignaciones dinámicas de objetos de corta duración en la memoria también se evitan generalmente cuando es posible. Para hacer esto, asigne un vector contiguo de objetos y solo use los que están ‘vivos’. Esto generalmente se hace almacenando un solo entero y haciendo que todos los objetos vivos se ordenen juntos al comienzo del grupo. Esto desperdicia memoria ya que tiene objetos asignados que no se están utilizando actualmente, pero dado que no está asignando nada en sus bucles internos, no hay nada que perder. Esto también le permite tener un conocimiento directo de la cantidad de memoria que realmente necesita y se utilizará en tiempo de ejecución.

La comprobación de punteros nulos es un antipatrón. Al buscar nulos, comienza a meterse en áreas difíciles donde el compilador optimizará el código que no espera. Es mucho mejor usar referencias (que, por definición, no pueden ser nulas).

Si bien no trabajo en los estudios de juegos, podría dar algunos consejos sobre cómo evitar pérdidas de memoria

  1. Evite usar la ubicación nueva (básicamente, escribir un objeto en un búfer) en cualquier tipo que pueda tener alguna propiedad (lo que significa que posee algún recurso). Si coloca objetos nuevos que tienen propiedad, recuerde llamar a los destructores de todos los objetos que creó usando colocación nueva.
  2. Si puede usar punteros inteligentes con la excepción de auto_ptr. (use unique_ptr en su lugar)
  3. Evite usar new, delete, new [], delete [], malloc y gratis.
  4. Si tiene que usar explícitamente la memoria dinámica, recuerde usar el par coincidente correcto new-delete, new [] – delete [], malloc-free
  5. No lance antes de limpiar la memoria (esto se tiene cuidado si usa punteros inteligentes)
  6. Utilice herramientas de creación de perfiles de memoria y detectores de fugas para localizar y reparar fugas.
  7. Finalmente, use una herramienta de análisis estático como Coverity (sin decir que debe usar Coverity por decir que hay otras herramientas de análisis estático gratuitas y de código abierto, pero Coverity es la más conocida)

La respuesta corta es que no lo hacemos. Existen estrategias para minimizar las fugas, pero todos cometen errores. Use una combinación de estrategias y buenas herramientas para encontrar las fugas cuando ocurran. Sin embargo, las fugas generalmente no son tan difíciles de manejar como la fragmentación.

Para evitar pérdidas de memoria, use un lenguaje con recolección de basura integrada. (Sin embargo, tenga en cuenta que eso puede conducir al problema opuesto: demasiada memoria no liberada esperando la recolección en el garaje).

Sobrecargue los operadores nuevos y eliminados (para la compilación de depuración solo obviamente). Eso le permitirá realizar un seguimiento de las asignaciones y desasignaciones. Si tiene más asignaciones, las desasignaciones después de que se complete la ejecución de lo que tiene una pérdida de memoria.

También utilizamos fábricas para crear grandes objetos de juego (en el montón) que también realizan un seguimiento de los objetos que han creado. Cuando la cantidad de tales objetos es mayor que un recuento razonable esperado, se lanza una afirmación o advertencia.

Se crearon pequeños objetos de corta duración en la pila.

Evitamos el uso de punteros inteligentes de recuento de referencias porque desperdician el rendimiento (al igual que cualquier recolector de basura sofisticado). Básicamente, casi la única situación en la que tiene que usar punteros inteligentes es cuando usa el bloque try catch, porque los punteros inteligentes serían la única forma de evitar fugas. Sin embargo, el intento de captura generalmente no se usa en el motor del juego. Solo lo necesitas realmente cuando trabajas con una biblioteca de terceros.

Pruebas, pruebas, prueba.

La mejor manera de evitar pérdidas de memoria es no asignar cosas.

Cada asignación de memoria es un posible punto de falla y debe tratarse como tal (especialmente si está apuntando a consolas). Luego está el problema de la fragmentación de la memoria. Incluso en un mundo perfecto donde no se crean pérdidas de memoria, estos problemas siguen siendo ciertos.

Existen muchas estrategias para minimizar (si no eliminar) la asignación dinámica, como el uso de áreas de rascado preasignadas para objetos de corta duración (me viene a la mente la vida de “nivel” y “nivel”).

No programo, pero trabajo con programadores (de juegos) como … bueno, es mi trabajo 🙂

Como la pregunta dice “estudios de juegos”, daré una respuesta gerencial que funciona de manera integral en el negocio de los juegos:

1- Haga la misma pregunta en posibles entrevistas a candidatos a programadores.

2- Haga una lista de las mejores prácticas que enfatizan estos o los enfoques de la compañía para evitar pérdidas de memoria.

3- Hágalo parte de sus pautas de codificación y haga que los programadores principales revisen y modifiquen según sea necesario durante las revisiones de código.

4- Si hay un senior que asesora a un junior, la transferencia de know-how para evitar memleaks tiene que suceder.

5- Esté atento a los nuevos trucos que pueda haber en relación con este tema: aprenda, aprenda, aprenda (aunque, para ser justos, estos son bastante conocidos en este momento :)).

No escribo juegos, pero escribo suficiente código C ++ sensible al rendimiento que creo que puedo responder. En la mayoría de los programas de C ++, puede evitar casi todas las pérdidas de memoria combinando cuatro técnicas:

  1. Definir la propiedad de todos los punteros. Si es posible, use punteros administrados como std :: unique_ptr, std :: shared_ptr y std :: auto_ptr para pasar punteros siempre que se desee transferir la propiedad.
  2. Tenga una cobertura de código perfecta en las pruebas unitarias y haga que la prueba unitaria se ejecute limpiamente en un verificador de memoria como valgrind memcheck.
  3. Nunca use la API de administración de memoria C como malloc, calloc y free.
  4. Utilice punteros de ámbito como C ++ std :: unique_ptr, boost :: scoped_ptr y std :: auto_ptr para eliminar la eliminación más explícita. Use punteros contados de referencia como C ++ std :: shared_ptr o boost :: shared_ptr para eliminar las eliminaciones explícitas restantes donde no sabe quién es responsable de liberar la memoria.

Con (3) y (4) su programa debería estar casi libre de novedades. y completamente libre de borrar, malloc, calloc y gratis. Casi todas las memorias son administradas por alguna clase de administrador y, por lo tanto, no pueden tener pérdidas. En este punto, las pérdidas de memoria son lo suficientemente superficiales como para que las pérdidas de memoria dentro del módulo puedan ser capturadas principalmente por (2). Con (1) elimina también las pérdidas de memoria entre módulos. Puede haber un par de palabras clave desnudas “nuevas” o “eliminar” en su código, pero tienden a estar bien localizadas y son fáciles de verificar manualmente.

Pero esto solo se trata de fugas. Es más difícil evitar el acceso a punteros colgantes, especialmente si se pasan entre hilos. Espero que el próximo GSL facilite la prevención del código incorrecto que utiliza punteros colgados, al menos para el código sin subprocesos, en poco tiempo.

Principalmente se trata de escribir un buen código, y que otras personas que lo noten si revisaron algo incorrecto lo revisen.

Sin embargo, una de las mejores formas de lidiar con las fugas es simplemente detectarlas. Usamos un asignador de memoria personalizado que nos permite rastrear cada asignación y luego reportar fugas en el apagado. Cualquier persona que se olvidó de eliminar algo recibe información muy rápidamente.

Hacer esto no es suficiente para resolver todos los problemas; por ejemplo, puede obtener una fuga a corto plazo que causa fragmentación, pero se elimina limpiamente si se cierra y, por lo tanto, no se detecta.

Otra técnica es usar grupos de memoria que deben vaciarse por completo, por ejemplo, cuando ingresa a un nivel, inicia un nuevo grupo y todos los datos para ese nivel van a ese grupo, luego, cuando lo abandona, puede verificar si algo se ha quedado atrás y lanza una advertencia si es así.

Finalmente, también trabajé (brevemente) en un sistema en el que todo debía encadenarse en una jerarquía, y en el momento en que salió un nivel, una sola eliminación al objeto de nivel superior eliminó todo. Personalmente no me gustó este método, pero ciertamente simplificó las cosas de alguna manera.

usa un motor de juego profesional y escribe tu lógica en un lenguaje de script

permita que los errores sean resueltos por ingenieros de bajo nivel, sea parte del proceso si lo desea utilizando un motor de juego de código abierto

Un motor 3D de código abierto gratuito
Unidad – Game Engine
¿Qué es Unreal Engine 4?
jMonkeyEngine – Desarrollo de juegos en 3D para desarrolladores Java

Hay mejores respuestas que las mías, pero mantengo la memoria de código C ++ libre de fugas con punteros inteligentes, el método más simple con diferencia.

http://stackoverflow.com/questio

No estoy familiarizado con los desarrollos del juego. Pero cuando se trata de proyectos complejos de C / C ++, he leído varios códigos de código abierto: kernel (C), QEMU (C), AOSP (C ++), OpenCV (C ++), que liberan memorias manualmente.

xcode tiene herramientas que ayudan a detectar pérdidas de memoria. Supongo que otros IDEs tienen algo similar.