16 апреля 2025
После того как код успешно транслирован и скомпилирован, мы часто сталкиваемся с проблемами времени выполнения, особенно связанными с управлением памятью, которые не характерны для среды C# с её сборщиком мусора. В этой статье мы углубимся в конкретные проблемы управления памятью, такие как циклические ссылки и преждевременное удаление объектов, и покажем, как наш подход помогает их выявлять и устранять.
В 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
не было, он также удаляется.
Эта проблема возникает, если объект передается по 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
на единицу, а в деструкторе — опять уменьшает, не производя удаление объекта.
Рассмотрим ситуацию, когда конструктор объекта выбрасывает исключение после того, как уже были созданы и проинициализированы некоторые его поля, которые, в свою очередь, могут содержать сильные ссылки обратно на конструируемый объект.
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
, уже после выхода из конструктора. Если указатель уничтожается до выставления флага, удаление объекта не производится.
class Node {
public Node next;
}
class Node : public Object {
public:
SharedPtr<Node> next;
}
Удаление цепочек объектов производится рекурсивно, что может вести к переполнению стека при большой длине цепочки — от нескольких тысяч объектов. Данная проблема решается добавлением финализатора, транслируемого в деструктор, который удаляет цепочку путём итерации.
Исправление проблемы циклических ссылок производится элементарно — добавлением атрибута к коду C#. Плохая новость состоит в том, что разработчик, ответственный за выпуск продукта для языка C++, по умолчанию не знает о том, какая именно ссылка должна быть слабой, равно как и о том, что цикл вообще существует.
Для облегчения поиска циклов нами был разработан ряд инструментов, работающих по похожей схеме. Они опираются на два внутренних механизма: глобальный реестр объектов и извлечение информации о ссылочных полях объекта.
Глобальный реестр содержит список объектов, существующих в данный момент. Конструктор класса System::Object
помещает ссылку на текущий объект в данный реестр, а деструктор — удаляет. Разумеется, реестр существует лишь в специальном отладочном режиме сборки, чтобы не влиять на производительность конвертированного кода в пользовательском режиме.
Информация о ссылочных полях объекта может быть извлечена вызовом виртуальной функции GetSharedMembers()
, объявленной на уровне System::Object
. Данная функция возвращает полный список указателей, находящихся в полях объекта, и их значений. В библиотечном коде данная функция пишется вручную, а в генерированный код она встраивается транслятором.
Существует несколько способов обработки информации, предоставляемой данными механизмами. Переключение между ними осуществляется путём использования соответствующих опций транслятороа и/или констант препроцессора.
SharedPtr
проходит по ссылкам между объектами, начиная с объекта, временем жизни которого он управляет, и выводит информацию обо всех найденных циклах — обо всех случаях, когда от текущего объекта по сильным связям можно дойти до него же.Ещё одним полезным отладочным инструментом является проверка того, что после вызова конструктора некоторого класса функцией MakeObject
счётчик сильных ссылок на данный объект равен нулю. Если это не так, это означает потенциальную проблему — цикл ссылок, неопределённое поведение при вылете исключения и тому подобное.
Несмотря на кардинальное несоответствие систем типов C# и C++, нам удалось построить систему умных указателей, позволяющую выполнять конвертированный код так, чтобы его поведение было близким к оригинальному. В то же время, задача не была решена в полностью автоматическом режиме. Нами были созданы инструменты, существенно упрощающие поиск потенциальных проблем.