16 abril 2025
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.
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.
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.
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.
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.
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.
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.
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.