16 abril 2025

Referencias circulares y fugas de memoria: Cómo portar código C# a C++

Una vez que el código ha sido traducido y compilado con éxito, a menudo nos encontramos con problemas en tiempo de ejecución, especialmente relacionados con la gestión de memoria, que no son típicos del entorno C# con su recolector de basura. En este artículo, profundizaremos en problemas específicos de gestión de memoria, como las referencias circulares y la eliminación prematura de objetos, y mostraremos cómo nuestro enfoque ayuda a detectarlos y resolverlos.

Problemas de gestión de memoria

1. Referencias circulares fuertes

En C#, el recolector de basura puede manejar correctamente las referencias circulares detectando y eliminando grupos de objetos inalcanzables. Sin embargo, en C++, los punteros inteligentes utilizan el conteo de referencias. Si dos objetos se referencian mutuamente con referencias fuertes (SharedPtr), sus contadores de referencia nunca llegarán a cero, incluso si ya no existen referencias externas a ellos desde el resto del programa. Esto conduce a una fuga de memoria, ya que los recursos ocupados por estos objetos nunca se liberan.

Consideremos un ejemplo típico:

class Document {
    private Element root;
    public Document()
    {
        root = new Element(this); // Document referencia a Element
    }
}

class Element {
    private Document owner;
    public Element(Document doc)
    {
        owner = doc; // Element referencia de vuelta a Document
    }
}

Este código se convierte en lo siguiente:

class Document : public Object {
    SharedPtr<Element> root;
public:
    Document()
    {
        root = MakeObject<Element>(this);
    }
}

class Element {
    SharedPtr<Document> owner; // Referencia fuerte
public:
    Element(SharedPtr<Document> doc)
    {
        owner = doc;
    }
}

Aquí, el objeto Document contiene un SharedPtr a Element, y el objeto Element contiene un SharedPtr a Document. Se crea un ciclo de referencias fuertes. Incluso si la variable que inicialmente contenía el puntero a Document sale del ámbito, los contadores de referencia para ambos objetos permanecerán en 1 debido a las referencias mutuas. Los objetos nunca serán eliminados.

Esto se resuelve estableciendo el atributo CppWeakPtr en uno de los campos involucrados en el ciclo, por ejemplo, en el campo Element.owner. Este atributo instruye al traductor a usar una referencia débil WeakPtr para este campo, la cual no incrementa el contador de referencias fuertes.

class Document {
    private Element root;
    public Document()
    {
        root = new Element(this);
    }
}

class Element {
    [CppWeakPtr] private Document owner;
    public Element(Document doc)
    {
        owner = doc;
    }
}

El código C++ resultante:

class Document : public Object {
    SharedPtr<Element> root; // Referencia fuerte
public:
    Document()
    {
        root = MakeObject<Element>(this);
    }
}

class Element {
    WeakPtr<Document> owner; // Ahora esta es una referencia débil
public:
    Element(SharedPtr<Document> doc)
    {
        owner = doc;
    }
}

Ahora Element mantiene una referencia débil a Document, rompiendo el ciclo. Cuando el último SharedPtr<Document> externo desaparece, el objeto Document se elimina. Esto desencadena la eliminación del campo root (SharedPtr<Element>), lo que decrementa el contador de referencias de Element. Si no había otras referencias fuertes a Element, también se elimina.

2. Eliminación de objetos durante la construcción

Este problema ocurre si un objeto se pasa mediante SharedPtr a otro objeto o método durante su construcción, antes de que se establezca una referencia fuerte “permanente” a él. En este caso, el SharedPtr temporal creado durante la llamada al constructor podría ser la única referencia. Si se destruye después de que la llamada se complete, el contador de referencias llega a cero, lo que lleva a una llamada inmediata al destructor y a la eliminación del objeto aún no completamente construido.

Consideremos un ejemplo:

class Document {
    private Element root;
    public Document()
    {
        root = new Element(this);
    }
    public void Prepare(Element elm)
    {
        ...
    }
}

class Element {
    public Element(Document doc)
    {
        doc.Prepare(this);
    }
}

El traductor genera lo siguiente:

class Document : public Object {
    SharedPtr<Element> root;
public:
    Document()
    {
        ThisProtector guard(this); // Protección contra eliminación prematura
        root = MakeObject<Element>(this);
    }
    void Prepare(SharedPtr<Element> elm)
    {
        ...
    }
}

class Element {
public:
    Element(SharedPtr<Document> doc)
    {
        ThisProtector guard(this); // Protección contra eliminación prematura
        doc->Prepare(this);
    }
}

Al entrar en el método Document::Prepare, se crea un objeto SharedPtr temporal, que podría entonces eliminar el objeto Element incompletamente construido porque no quedan referencias fuertes a él. Como se mostró en el artículo anterior, este problema se resuelve añadiendo una variable local ThisProtector guard al código del constructor de Element. El traductor hace esto automáticamente. El constructor del objeto guard incrementa el contador de referencias fuertes para this en uno, y su destructor lo decrementa nuevamente, sin causar la eliminación del objeto.

3. Doble eliminación de un objeto cuando un constructor lanza una excepción

Consideremos una situación en la que el constructor de un objeto lanza una excepción después de que algunos de sus campos ya han sido creados e inicializados, los cuales, a su vez, podrían contener referencias fuertes de vuelta al objeto que se está construyendo.

class Document {
    private Element root;
    public Document()
    {
        root = new Element(this);
        throw new Exception("Failed to construct Document object");
    }
}

class Element {
    private Document owner;
    public Element(Document doc)
    {
        owner = doc;
    }
}

Después de la conversión, obtenemos:

class Document : public Object {
    SharedPtr<Element> root;
public:
    Document()
    {
        ThisProtector guard(this);
        root = MakeObject<Element>(this);
        throw Exception(u"Failed to construct Document object");
    }
}

class Element {
    SharedPtr<Document> owner;
public:
    Element(SharedPtr<Document> doc)
    {
        ThisProtector guard(this);
        owner = doc;
    }
}

Después de que se lanza la excepción en el constructor del documento y la ejecución sale del código del constructor, comienza el desenrollado de la pila, incluida la eliminación de los campos del objeto Document incompletamente construido. Esto, a su vez, conduce a la eliminación del campo Element::owner, que contiene una referencia fuerte al objeto que se está eliminando. Esto resulta en la eliminación de un objeto que ya está en proceso de deconstrucción, lo que lleva a diversos errores en tiempo de ejecución.

Establecer el atributo CppWeakPtr en el campo Element.owner resuelve este problema. Sin embargo, hasta que se colocan los atributos, la depuración de tales aplicaciones es difícil debido a terminaciones impredecibles. Para simplificar la solución de problemas, existe un modo especial de compilación de depuración donde el contador de referencias interno del objeto se mueve al heap y se complementa con un indicador. Este indicador se establece solo después de que el objeto está completamente construido, a nivel de la función MakeObject, después de salir del constructor. Si el puntero se destruye antes de que se establezca el indicador, el objeto no se elimina.

4. Eliminación de cadenas de objetos

class Node {
    public Node next;
}
class Node : public Object {
public:
    SharedPtr<Node> next;
}

La eliminación de cadenas de objetos se realiza recursivamente, lo que puede llevar a un desbordamiento de pila si la cadena es larga (varios miles de objetos o más). Este problema se soluciona añadiendo un finalizador, traducido a un destructor, que elimina la cadena mediante iteración.

Búsqueda de referencias circulares

Corregir el problema de las referencias circulares es sencillo: añadir un atributo al código C#. La mala noticia es que el desarrollador responsable de lanzar el producto para C++ típicamente no sabe qué referencia específica debería ser débil, ni siquiera que existe un ciclo.

Para facilitar la búsqueda de ciclos, hemos desarrollado un conjunto de herramientas que operan de manera similar. Se basan en dos mecanismos internos: un registro global de objetos y la extracción de información sobre los campos de referencia de un objeto.

El registro global contiene una lista de los objetos que existen actualmente. El constructor de la clase System::Object coloca una referencia al objeto actual en este registro, y el destructor la elimina. Naturalmente, el registro existe solo en un modo especial de compilación de depuración, para no afectar el rendimiento del código convertido en modo de lanzamiento.

La información sobre los campos de referencia de un objeto se puede extraer llamando a la función virtual GetSharedMembers(), declarada a nivel de System::Object. Esta función devuelve una lista completa de los punteros inteligentes contenidos en los campos del objeto y sus objetos de destino. En el código de biblioteca, esta función se escribe manualmente, mientras que en el código generado, es incrustada por el traductor.

Existen varias formas de procesar la información proporcionada por estos mecanismos. El cambio entre ellos se realiza mediante el uso de opciones apropiadas del traductor y/o constantes del preprocesador.

  1. Cuando se llama a la función correspondiente, el grafo completo de los objetos existentes actualmente, incluida la información sobre tipos, campos y relaciones, se guarda en un archivo. Este grafo puede visualizarse posteriormente utilizando la utilidad graphviz. Típicamente, este archivo se crea después de cada prueba para rastrear las fugas convenientemente.
  2. Cuando se llama a la función correspondiente, se guarda en un archivo un grafo de los objetos existentes actualmente que tienen dependencias circulares, donde todas las referencias involucradas son fuertes. Por lo tanto, el grafo contiene solo información relevante. Los objetos que ya han sido analizados se excluyen del análisis en llamadas posteriores a esta función. Esto facilita mucho ver exactamente qué se filtró de una prueba específica.
  3. Cuando se llama a la función correspondiente, se imprime en la consola información sobre las islas de aislamiento existentes actualmente: conjuntos de objetos donde todas las referencias a ellos son mantenidas por otros objetos dentro del mismo conjunto. Los objetos referenciados por variables estáticas o locales no se incluyen en esta salida. La información sobre cada tipo de isla de aislamiento, es decir, el conjunto de clases que crean una isla típica, se imprime solo una vez.
  4. El destructor de la clase SharedPtr recorre las referencias entre objetos, comenzando desde el objeto cuya vida útil gestiona, e imprime información sobre todos los ciclos detectados: todos los casos en los que se puede alcanzar nuevamente el objeto inicial siguiendo referencias fuertes.

Otra herramienta de depuración útil es verificar que después de que la función MakeObject llama al constructor de un objeto, el contador de referencias fuertes para ese objeto sea cero. Si no lo es, esto indica un problema potencial: un ciclo de referencias, comportamiento indefinido si se lanza una excepción, etc.

Resumen

A pesar de la incompatibilidad fundamental entre los sistemas de tipos de C# y C++, logramos construir un sistema de punteros inteligentes que permite que el código convertido se ejecute con un comportamiento cercano al original. Al mismo tiempo, la tarea no se resolvió en un modo completamente automático. Hemos creado herramientas que simplifican significativamente la búsqueda de problemas potenciales.

Noticias relacionadas

Videos relacionados

Artículos relacionados