16 апреля 2025

Циклические ссылки и утечки памяти: Как портировать код C# в C++

После того как код успешно транслирован и скомпилирован, мы часто сталкиваемся с проблемами времени выполнения, особенно связанными с управлением памятью, которые не характерны для среды C# с её сборщиком мусора. В этой статье мы углубимся в конкретные проблемы управления памятью, такие как циклические ссылки и преждевременное удаление объектов, и покажем, как наш подход помогает их выявлять и устранять.

Проблемы при работе с памятью

1. Циклические сильные ссылки

В C# сборщик мусора способен корректно обрабатывать циклические ссылки, обнаруживая и удаляя группы недостижимых объектов. Однако в C++ умные указатели, используют подсчет ссылок. Если два объекта ссылаются друг на друга сильными ссылками (SharedPtr), их счетчики ссылок никогда не достигнут нуля, даже если на них больше нет внешних ссылок из остальной программы. Это приводит к утечке памяти, так как ресурсы, занятые этими объектами, никогда не освобождаются.

Рассмотрим типичный пример:

class Document {
    private Element root;
    public Document()
    {
        root = new Element(this); // Document ссылается на Element
    }
}

class Element {
    private Document owner;
    public Element(Document doc)
    {
        owner = doc; // Element ссылается обратно на Document
    }
}

Этот код преобразуется в следующий:

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

class Element {
    SharedPtr<Document> owner; // Сильная ссылка
public:
    Element(SharedPtr<Document> doc)
    {
        owner = doc;
    }
}

Здесь объект Document содержит SharedPtr на Element, а объект Element содержит SharedPtr на Document. Создается цикл сильных ссылок. Даже если переменная, изначально содержавшая указатель на Document, выйдет из области видимости, счетчики ссылок у обоих объектов останутся равными 1 из-за взаимных ссылок. Объекты никогда не будут удалены.

Это решается установкой атрибута CppWeakPtr на одно из полей, участвующих в цикле, например, на поле Element.owner. Этот атрибут указывает транслятору использовать слабую ссылку WeakPtr для данного поля, которая не увеличивает счетчик сильных ссылок.

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

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

Полученный код C++:

class Document : public Object {
    SharedPtr<Element> root; // Сильная ссылка
public:
    Document()
    {
        root = MakeObject<Element>(this);
    }
}

class Element {
    WeakPtr<Document> owner; // Теперь это слабая ссылка
public:
    Element(SharedPtr<Document> doc)
    {
        owner = doc;
    }
}

Теперь Element хранит слабую ссылку на Document, разрывая цикл. Когда исчезает последний внешний SharedPtr<Document>, объект Document удаляется. Это вызывает удаление поля root (SharedPtr<Element>), что уменьшает счетчик ссылок Element. Если других сильных ссылок на Element не было, он также удаляется.

2. Удаление объекта на этапе создания

Эта проблема возникает, если объект передается по SharedPtr другому объекту или методу во время своего конструирования, до того как будет создана “постоянная” сильная ссылка на него. В этом случае временный SharedPtr, созданный в процессе вызова конструктора, может оказаться единственной ссылкой. Если после завершения вызова он уничтожается, счетчик ссылок достигает нуля, что приводит к немедленному вызову деструктора и удалению еще не до конца сконструированного объекта.

Рассмотрим пример:

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);
    }
}

На выходе транслятора получаем:

class Document : public Object {
    SharedPtr<Element> root;
public:
    Document()
    {
        ThisProtector guard(this); // Защита от преждевременного удаления
        root = MakeObject<Element>(this);
    }
    void Prepare(SharedPtr<Element> elm)
    {
        ...
    }
}

class Element {
public:
    Element(SharedPtr<Document> doc)
    {
        ThisProtector guard(this); // Защита от преждевременного удаления
        doc->Prepare(this);
    }
}

При входе в метод Document::Prepare создаётся временный объект SharedPtr, который затем может удалить недоконструированный объект Element, так как на него не остаётся сильных ссылок. Как было показано в прошлой статье, эта проблема решается добавлением локальной переменной ThisProtector guard в код конструктора Element. Транслятор делает это автоматически. Объект guard в своём конструкторе увеличивает число сильных ссылок на this на единицу, а в деструкторе — опять уменьшает, не производя удаление объекта.

3. Двойное удаление объекта при выбрасывании исключения конструктором

Рассмотрим ситуацию, когда конструктор объекта выбрасывает исключение после того, как уже были созданы и проинициализированы некоторые его поля, которые, в свою очередь, могут содержать сильные ссылки обратно на конструируемый объект.

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;
    }
}

После преобразования получаем:

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;
    }
}

После вылета исключения в конструкторе документа и выхода из кода конструктора начинается раскрутка стека, в том числе — удаление полей недоконструированного объекта Document. В свою очередь, это приводит к удалению поля Element::owner, содержащего сильную ссылку на удаляемый объект. Происходит удаление объекта, уже находящегося в составе деконструирования, что заканчивается различными ошибками времени выполнения.

Установка атрибута CppWeakPtr на поле Element.owner решает эту проблему. Однако до тех пор, пока атрибуты не расставлены, отладка таких приложений затруднена из-за непредсказуемых завершений. Для упрощения поиска проблем существует особый отладочный режим сборки кода, в котором внутриобъектный счётчик ссылок переносится на кучу и дополняется флагом. Этот флаг выставляется только после полного конструирования объекта — на уровне функции MakeObject, уже после выхода из конструктора. Если указатель уничтожается до выставления флага, удаление объекта не производится.

4. Удаление цепочек объектов

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

Удаление цепочек объектов производится рекурсивно, что может вести к переполнению стека при большой длине цепочки — от нескольких тысяч объектов. Данная проблема решается добавлением финализатора, транслируемого в деструктор, который удаляет цепочку путём итерации.

Поиск циклических ссылок

Исправление проблемы циклических ссылок производится элементарно — добавлением атрибута к коду C#. Плохая новость состоит в том, что разработчик, ответственный за выпуск продукта для языка C++, по умолчанию не знает о том, какая именно ссылка должна быть слабой, равно как и о том, что цикл вообще существует.

Для облегчения поиска циклов нами был разработан ряд инструментов, работающих по похожей схеме. Они опираются на два внутренних механизма: глобальный реестр объектов и извлечение информации о ссылочных полях объекта.

Глобальный реестр содержит список объектов, существующих в данный момент. Конструктор класса System::Object помещает ссылку на текущий объект в данный реестр, а деструктор — удаляет. Разумеется, реестр существует лишь в специальном отладочном режиме сборки, чтобы не влиять на производительность конвертированного кода в пользовательском режиме.

Информация о ссылочных полях объекта может быть извлечена вызовом виртуальной функции GetSharedMembers(), объявленной на уровне System::Object. Данная функция возвращает полный список указателей, находящихся в полях объекта, и их значений. В библиотечном коде данная функция пишется вручную, а в генерированный код она встраивается транслятором.

Существует несколько способов обработки информации, предоставляемой данными механизмами. Переключение между ними осуществляется путём использования соответствующих опций транслятороа и/или констант препроцессора.

  1. При вызове соответствующей функции в файл сохраняется полный граф существующих на данный момент объектов, включая информацию о типах, полях и связях. Этот граф может затем быть визуализирован при помощи утилиты graphviz. Как правило, данный файл создаётся после каждого теста, чтобы было удобно отслеживать утечки.
  2. При вызове соответствующей функции в файл сохраняется граф существующих на данный момент объектов, между которыми существуют циклические связи — все ссылки которых являются сильными. Таким образом, граф содержит лишь значащую информацию. Объекты, которые уже были проанализированы, исключаются из анализа при следующем вызове данной функции. Таким образом, видеть, что именно утекло из конкретного теста, становится гораздо проще.
  3. При вызове соответствующей функции в консоль выводится информация о существующих на данный момент островах изоляции — наборах объектов, все ссылки на которые находятся в полях других объектов набора. Объекты, на которые ссылаются статические либо локальные переменные, не попадают в данный вывод. Информация о каждом типе острова изоляции, то есть о наборе классов, создающих типовой остров, выводится только один раз.
  4. Деструктор класса SharedPtr проходит по ссылкам между объектами, начиная с объекта, временем жизни которого он управляет, и выводит информацию обо всех найденных циклах — обо всех случаях, когда от текущего объекта по сильным связям можно дойти до него же.

Ещё одним полезным отладочным инструментом является проверка того, что после вызова конструктора некоторого класса функцией MakeObject счётчик сильных ссылок на данный объект равен нулю. Если это не так, это означает потенциальную проблему — цикл ссылок, неопределённое поведение при вылете исключения и тому подобное.

Резюме

Несмотря на кардинальное несоответствие систем типов C# и C++, нам удалось построить систему умных указателей, позволяющую выполнять конвертированный код так, чтобы его поведение было близким к оригинальному. В то же время, задача не была решена в полностью автоматическом режиме. Нами были созданы инструменты, существенно упрощающие поиск потенциальных проблем.

Связанные новости

Связанные видео

Связанные статьи