Обновить

Как мы заставили код, портированный с C#, работать с моделью памяти C++

Программирование.NETC++C#
Привет, Хабр. В прошлой статье я рассказывал о том, как мы создали фреймворк для перевода кода C# на (неуправляемый) C++, чтобы выпускать свои библиотеки, изначально разработанные для платформы .Net, и под C++ тоже. В этой статье я расскажу о том, как нам удалось согласовать модели памяти этих двух языков, добившись работы портированного кода в необычном для него окружении.

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

Модель работы с памятью в C#


Код C# выполняется в управляемой среде со сборкой мусора. Для наших целей это означает, прежде всего, то, что программист C#, в отличие своего коллеги из числа разработчиков C++, освобождён от необходимости заботиться о возвращении системе выделенной на куче памяти, которая более не используется. За него это делает сборщик мусора (GC) — компонент среды CLR, периодически проверяющий, какие из объектов ещё используются в программе, и очищающий те, на которые больше нет активных ссылок.

Активной считается ссылка:

  1. Расположенная на стеке (локальная переменная, аргумент метода);
  2. Расположенная в области статических данных (статические поля и свойства);
  3. Расположенная в объекте, на который есть активные ссылки.

Алгоритм работы сборщика мусора обычно описывают как прохождение по графу объектов, начиная со ссылок, расположенных на стеке и в статических полях, с последующим удалением всего, что не было достигнуто на предыдущем этапе. Чтобы защититься от инвалидации графа связей во время работы GC, выполняется остановка мира. Это описание может быть упрощённым, однако для наших целей его достаточно.

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

Типы данных в C# делятся на ссылочные и значимые. Экземпляры значимых типов всегда располагаются на стеке (либо в статической области памяти, либо непосредственно в полях структур и объектов), в то время как экземпляры ссылочных типов всегда создаются в куче, оставляя на стеке (в статической памяти, в полях) лишь ссылки (адреса). К значимым типам относятся структуры и примитивные арифметические значения (а также ссылки), а к ссылочным — классы и делегаты (исторически). Исключение из этого правила составляют разве что случаи боксинга, когда структура или другой значимый тип копируются в кучу для использования в специфическом контексте (на что существует целая категория каверзных задачек).

Момент уничтожения объекта не определён — язык гарантирует лишь, что он не настанет раньше, чем будет удалена последняя активная ссылка на объект. Если в портированном коде объект будет уничтожаться сразу в момент удаления последней активной ссылки, это никак не нарушит работы программы.

Ещё один важный момент связан с поддержкой обобщённых (generic) типов и методов в C#. C# позволяет писать дженерики один раз и затем использовать их как со ссылочными, так и со значимыми типами-параметрами. Как будет показано далее, этот момент для нас важен.

Модель отображения типов


Несколько слов о том, как мы отображаем типы C# на типы C++. Поскольку важным требованием для нас является как можно более точное воспроизведение API оригинального проекта (т. к. мы поставляем библиотеки, а не приложения), мы превращаем классы, интерфейсы и структуры C# в классы C++, наследующие соответствующие базовые типы.

Например, рассмотрим следующий код:

interface I1 {}
interface I2 {}
interface I3 : I2 {}
class A {}
class B : A, I1 {}
class C : B, I2 {}
class D : C, I3 {}
class Generic<T> { public T value; }
struct S {}

Он будет портирован так:

class I1 : public virtual System::Object {};
class I2 : public virtual System::Object {};
class I3 : public virtual I2 {};
class A : public virtual System::Object {};
class B : public A, public virtual I1 {};
class C : public B, public virtual I2 {};
class D : public C, public virtual I3 {};
template <typename T> class Generic { public: T value; };
class S : public System::Object {};

Класс System::Object является системным и объявлен в библиотеке, внешней по отношению к портированному проекту. Классы и интерфейсы наследуются от него виртуально (чтобы избежать проблемы ромба). Незначимое виртуальное наследование может быть опущено. Структуры в портированном коде наследуются от System::Object, в то время как в C# они наследуются от него через System::ValueType (лишнее наследование убрано с целью оптимизации). Обобщённые типы и методы транслируются в шаблонные классы и методы соответственно.

Для экземпляров портированных типов должны выполняться те же гарантии, которые давал C#. Экземпляры классов должны создаваться на куче, и время их жизни должно определяться временем жизни активных ссылок на них. Экземпляры структур должны создаваться на стеке (за исключением случаев боксинга). Делегаты являются особым и достаточно тривиальным случаем, выходящим за рамки данной статьи.

Далее я расскажу, как было принято решение о способе управления временем жизни объектов, аллоцированных на куче, и как этот подход был реализован.

C++: умные указатели или ...?


При разработке фреймворка для портирования кода C# на Java проблем с удалением неиспользуемых объектов не было: Java предоставляет механизм сборки мусора, в достаточной мере похожий на таковой в C#, и портированный код, использующий классы, просто собирается и работает. Отличия проявляются уже в других аспектах — например, при портировании структур (которые приходится отображать на классы и следить за тем, чтобы они копировались в нужных местах).

C++ — иной случай. Очевидно, отображение ссылок на «голые» указатели не приведёт к нужным результатам, поскольку такой портированный код не будет удалять ничего (а программисты C#, привыкшие к работе в среде с GC, будут продолжать писать код, создающий множество временных объектов).

Для того, чтобы обеспечить своевременное удаление объектов в портированном коде, нам пришлось выбирать из трёх вариантов:

  1. Использовать подсчёт ссылок на объекты (например, через умные указатели);
  2. Использовать реализацию сборщика мусора для C++ (например, Boehm GC);
  3. Использовать статический анализ для определения мест, в которых производится удаление объектов.

Третий вариант отпал сразу: сложность алгоритмов в наших проектах оказалась слишком высокой, к тому же, анализировать нужно было бы также и клиентский код, использующий наши библиотеки.

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

Таким образом, мы пришли к последнему оставшемуся варианту — к использованию умных указателей с подсчётом ссылок, что является довольно типичным для C++ (не случайно 11-ый стандарт расширил их поддержку). Это, в свою очередь, означало, что для решения проблемы циклических ссылок нам придётся использовать слабые ссылки в дополнение к сильным.

Вид умных указателей


Существует несколько широко известных типов умных указателей. shared_ptr можно было бы назвать самым ожидаемым выбором, однако он имеет тот недостаток, что располагает счётчик ссылок на куче отдельно от объекта даже при использовании enable_shared_from_this, а выделение/высвобождение памяти под счётчик — относительно дорогая операция. intrusive_ptr в этом смысле лучше, поскольку на наших задачах наличие неиспользуемого поля в 4/8 байт внутри структуры является меньшим злом, чем лишняя аллокация при создании каждого временного объекта.

Теперь рассмотрим следующий код:

class Document
{
    private Node node;
    public Document()
    {
        node = new Node(this);
    }
    public void Prepare(Node n) { ... }
}
class Node
{
    private Document document;
    public Node(Document d)
    {
        document = d;
        d.Prepare(this);
    }
}

Этот код будет портирован примерно в следующий (нотация для указателей свободная, поскольку решение о конкретном их типе ещё не принято):

class Document : public virtual System::Object
{
    intrusive_ptr<Node> node;
public:
    Document()
    {
        node = make_shared_intrusive<Node>(this);
    }
    void Prepare(intrusive_ptr<Node> n) { ... }
}
class Node : public virtual System::Object
{
    intrusive_ptr<Document> document;
public:
    Node(intrusive_ptr<Document> d)
    {
        document = d;
        d->Prepare(this);
    }
}

Здесь видны сразу три проблемы:

  1. Необходим способ разорвать циклическую ссылку, сделав в данном случае Node::document слабой ссылкой.
  2. Должен существовать способ преобразования this в intrusive_ptr (аналог shared_from_this). Если вместо этого начать менять сигнатуры (например, заставив Document::Prepare принимать Node* вместо intrusive_ptr<Node>), начнутся проблемы с вызовом тех же методов с передачей уже сконструированных объектов и/или управлением временем жизни объектов.
  3. Преобразование this в intrusive_ptr на этапе создания объекта с последующим уменьшением счётчика ссылок до нуля (как это происходит, например, в конструкторе Node при выходе из Document::Prepare) не должно приводить к немедленному удалению недоконструированного объекта, на который ещё не существует внешних ссылок.

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

Вторая проблема решается элементарно, если intrusive_ptr допускает конверсию из голого указателя (каковым является this). Для реализации из boost это так.

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

С соответствующими правками код до и после портирования выглядел примерно так:

class Document
{
    private Node node;
    public Document()
    {
        node = new Node(this);
    }
    public void Prepare(Node n) { ... }
}
class Node
{
    [CppWeakPtr] private Document document;
    public Node(Document d)
    {
        document = d;
        d.Prepare(this);
    }
}

class Document : public virtual System::Object
{
    intrusive_ptr<Node> node;
public:
    Document()
    {
        System::Details::ThisProtector guard(this);
        node = make_shared_intrusive<Node>(this);
    }
    void Prepare(intrusive_ptr<Node> n) { ... }
}
class Node : public virtual System::Object
{
    weak_intrusive_ptr<Document> document;
public:
    Node(intrusive_ptr<Document> d)
    {
        System::Details::ThisProtector guard(this);
        document = d;
        d->Prepare(this);
    }
}

Таким образом, пока под наши требования попадает любая реализация intrusive_ptr, имеющая дополнение в виде парного класса weak_intrusive_ptr. Последний должен опираться на счётчик ссылок, находящийся на куче вне объекта. Поскольку создание слабой ссылки — довольно редкая операция по сравнению с созданием временных объектов, разделение счётчика ссылок на «сильный», находящийся в самом объекте, и «слабый», находящийся вне объекта, дало выигрыш в производительности на реальном коде.

Шаблоны


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

class MyContainer<T>
{
    public T field;
    public void Set(T val)
    {
        field = val;
    }
}
class MyClass {}
struct MyStruct {}

var a = new MyContainer<MyClass>();
var b = new MyContainer<MyStruct>();

Портирование «в лоб» даёт следующий результат:

template <typename T> class MyContainer : public virtual System::Object
{
public:
    T field;
    void Set(T val)
    {
        field = val;
    }
};
class MyClass : public virtual System::Object {};
class MyStruct : public System::Object {};

auto a = make_shared_intrusive<MyContainer<MyClass>>();
auto b = make_shared_intrusive<MyContainer<MyStruct>>();

Очевидно, этот код будет работать совсем не так, как оригинал, поскольку при инстанциировании MyContainer<MyClass> объект field «переехал» из кучи в поле MyContainer, сломав всю семантику копирования ссылок. В то же время, расположение структуры MyStruct в поле совершенно правильно, поскольку соответствует поведению C#.

Разрешить данную ситуацию можно двумя способами:

  1. Перейдя от семантики MyContainer<MyClass> к семантике MyContainer<intrusive_ptr<MyClass>>:

    auto a = make_shared_intrusive<MyContainer<MyClass>>();
  2. Для каждого шаблонного класса создав две специализации: одну — обрабатывающую случаи, когда аргумент-тип является значимым типом, вторую — для случаев ссылочных типов:

    template <typename T, bool is_T_reference_type = is_reference_type_v<T>> class MyContainer : public virtual System::Object
    {
    public:
        T field;
        void Set(T val)
        {
            field = val;
        }
    };
    template <typename T> class MyContainer<T, true> : public virtual System::Object
    {
    public:
        intrusive_ptr<T> field;
        void Set(intrusive_ptr<T> val)
        {
            field = val;
        }
    };

Помимо многословности, растущей экспоненциально с каждым новым парамтером-типом (кроме случаев, когда через синтаксис where ясно указано, может ли параметр-тип быть только ссылочным или только значимым), второй вариант плох тем, что каждый контекст, в котором используется MyContainer<T>, должен знать, является ли T значимым или ссылочным типом, что во многих случаях нежелательно (например, когда мы хотим иметь минимально возможное количество включаемых заголовков или и вовсе спрятать информацию о неких внутренних типах). Кроме того, выбор типа ссылки (сильная или слабая) возможен лишь один раз на контейнер — то есть, становится невозможно иметь одновременно List сильных ссылок и List слабых ссылок, хотя в коде наших продуктов существовала необходимость в обоих вариантах.

С учётом этих соображений, было решено портировать MyContainer<MyClass> в семантике MyContainer<System::SharedPtr<MyClass>> (либо MyContainer<System::WeakPtr<MyClass>> для случая слабых ссылок). Поскольку наиболее популярные библиотеки не предоставляют указателей с требуемыми характеристиками, нами были разработаны собственные реализации, получившие названия System::SharedPtr (сильная ссылка, использующая счётчик ссылок в объекте) и System::WeakPtr (слабая ссылка, использующая счётчик ссылок вне объекта). За создание объектов в стиле std::make_shared отвечает функция System::MakeObject.

Тип ссылки как часть состояния указателя


С самого начала речь шла о портировании нескольких проектов объёмом до нескольких миллионов строк кода. По сути, техническое задание на фреймворк сводилось к фразе «чтобы всё это портировалось и нормально работало на C++». Работа людей, ответственных за выпуск продуктов для C++, сводится к тому, чтобы портировать код, прогонять тесты, готовить релизные пакеты, и так далее. Проблемы, возникающие при этом, как правило, попадают в одну из нескольких категорий:

  1. Код не портируется (работа портера завершается с ошибкой).
  2. Код портируется, но не компилируется.
  3. Код компилируется, но не линкуется.
  4. Код линкуется и запускается, но тесты не проходят (или происходят падения в рантайме).
  5. Тесты проходят, но при их работе возникают проблемы, не связанные напрямую с функциональностью продукта (утечки памяти, низкая производительность и т. п.).

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

В начале, когда мы исправляли простые случаи утечки памяти, вызванные циклическими зависимостями между объектами, мы навешивали атрибут CppWeakPtr на поля, получая в итоге поля типов WeakPtr. До тех пор, пока WeakPtr может быть преобразован в SharedPtr вызовом метода lock() (или неявно, что удобнее синтаксически), это не вызывает проблем. Единственный нетривиальный сценарий, который приходит в голову, — это передача такой ссылки в качестве ref-аргумента, однако это настолько редкая ситуация, что на нашем коде она не стала проблемой. Далее, однако, нам пришлось также делать слабыми ссылки, содержащиеся в контейнерах, используя специальный синтаксис атрибута CppWeakPtr, и вот тут нас ждала пара неприятных сюрпризов.

Первым звоночком, сообщившим о проблемах с принятым нами подходом, стало то, что с точки зрения C++ MyContainer<SharedPtr<MyClass>> и MyContainer<WeakPtr<MyClass>> — это два разных типа. Соответственно, они не могут быть сохранены в одну и ту же переменную, переданы в один и тот же метод (или возвращены из него), и так далее. Атрибут, предназначенный сугубо для управления способом хранения ссылок в полях объектов, начал появляться во всё более странных контекстах, затрагивая возвращаемые значения, аргументы, локальные переменные, и так далее. Код портера, отвечающий за его обработку, становился сложнее день ото дня.

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

Очевидно, эти две проблемы не решались в рамках существующей парадигмы, и типы указателей были вновь пересмотрены. Результатом пересмотра подхода стал класс SmartPtr, имеющий метод set_Mode(), принимающий одно из двух значений: SmartPtrMode::Shared и SmartPtrMode::Weak. Те же значения принимают все конструкторы SmartPtr. В итоге каждый экземпляр указателя может находиться в одном из двух состояний:

  1. Сильная ссылка, счётчик ссылок инкапсулирован в объект;
  2. Слабая ссылка, счётчик ссылок находится вне объекта.

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

Полный список функций, поддерживаемых нашим указателем, выглядит так:

  1. Хранение сильной ссылки: управление временем жизни объекта с подсчётом ссылок.
  2. Хранение слабой ссылки на объект.
  3. Семантика intrusive_ptr: любое количество указателей, созданных на один и тот же объект, будут разделять один счётчик ссылок.
  4. Разыменование и оператор «стрелка» для доступа к объекту, на который указывает указатель.
  5. Полный набор конструкторов и операторов присваивания.
  6. Разделение объекта, на который указывает указатель, и объекта, для которого ведётся подсчёт ссылок (aliasing constructor): поскольку наши библиотеки работают с документами, у нас часто бывает ситуация, когда указатель на элемент документа должен держать «живым» весь документ.
  7. Полный набор кастов.
  8. Полный набор операций сравнения.
  9. Присваивание и удаление указателей работают на неполных типах.
  10. Набор методов для проверки и изменения состояния указателя (режим псевдонима, режим хранения ссылки, число ссылок на объект и т. д.).

Класс SmartPtr является шаблонным и не содержит виртуальных методов. Он тесно связан с классом System::Object, который осуществляет хранение счётчика ссылок, и работает исключительно с его дочерними классами.

Существуют отступления от типового поведения указателей:

  1. Перемещение (конструктор перемещения, перемещающий оператор присваивания) изменяет не всё состояние, сохраняя тип ссылки (слабая/сильная).
  2. Доступ к объекту по слабой ссылке не требует локинга (создания временной сильной ссылки), так как подход, при котором оператор «стрелка» возвращает временный объект, слишком сильно просаживает производительность на сильных ссылках.

Для сохранения работоспособности старого кода тип SharedPtr стал псевдонимом SmartPtr. Класс WeakPtr теперь наследуется от SmartPtr, не добавляя каких-либо полей, и лишь переопределяет конструкторы, всегда создавая слабые ссылки.

Контейнеры теперь всегда портируются в семантике MyContainer<SmartPtr<MyClass>>, а тип хранимых ссылок выбирается в рантайме. Для контейнеров, написанных вручную на базе структур данных из STL (в первую очередь, контейнеров из пространства имён System), тип ссылки по умолчанию задаётся при помощи кастомного аллокатора, при этом для отдельных элементов контейнера остаётся возможность изменения режима. Для портированных контейнеров необходимый код переключения режима хранения ссылок генерируется портером.

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

Подготовка кода к портированию


Наш способ портирования требует, чтобы в исходном коде были вручную расставлены атрибуты, отмечающие, в каких случаях ссылки должны быть слабыми. Код, в котором эти атрибуты не были расставлены должным образом, после портирования будет вызывать утечки памяти и — в некоторых случаях — другие ошибки. Код с расставленными атрибутами выглядит примерно так:

struct S {
    MyClass s; // Сильная ссылка на объёкт
    [CppWeakPtr] MyClass w; // Слабая ссылка на объект
    MyContainer<MyClass> s_s; // Сильная ссылка на контейнер сильных ссылок
    [CppWeakPtr] MyContainer<MyClass> w_s; // Слабая ссылка на контейнер сильных ссылок
    [CppWeakPtr(0)] MyContainer<MyClass> s_w; // Сильная ссылка на контейнер слабых ссылок
    [CppWeakPtr(1)] Dictionary<MyClass, MyClass> s_s_w; // Сильная ссылка на контейнер, в котором ключи хранятся по сильным ссылкам, а значения - по слабым
    [CppWeakPtr, CppWeakPtr(0)] Dictionary<MyClass, MyClass> w_w_s; // Слабая ссылка на контейнер, в котором ключи хранятся по слабым ссылкам, а значения - по сильным
}

В некоторых случаях требуется вручную вызвать aliasing конструктор класса SmartPtr или его метод, задающий тип хранимой ссылки. Мы стараемся избегать правок портированного кода, поскольку их приходится внедрять заново после каждого запуска портера. Вместо этого мы стараемся держать такой код в исходниках C#. Для этого у нас есть две возможности:

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

    class Service {
        public static void SetWeak<T>(T arg) {}
    }

    class Service {
    public:
        template <typename T> static void SetWeak<T>(SmartPtr<T> &arg)
        {
            arg.set_Mode(SmartPtrMode::Weak);
        }
    };
  2. Мы можем размещать в коде C# специальным образом оформленные комментарии, которые портер преобразует в код C++:

    class MyClass {
        private Dictionary<string, object> data;
        public void Add(string key, object value)
        {
            data.Add(key, value);
            //CPPCODE: if (key == u"Parent") data->data()[key].set_Mode(SmartPtrMode::Weak);
        }
    }

    Здесь метод data() в System::Collections::Generic::Dictionary возвращает ссылку на std::unordered_map, лежащую в основе данного контейнета.

Проблемы


Теперь поговорим о проблемах, относящихся к работе с памятью в нашем проекте.

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


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

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

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 и Element после их создания. Это решается установкой атрибута CppWeakPtr на поле Element.owner.

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

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

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


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. Данная функция возвращает полный список указателей, находящихся в полях объекта, и их значений. В библиотечном коде данная функция пишется вручную, а в генерированный код она встраивается портером.

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

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

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

Резюме


Несмотря на кардинальное несоответствие систем типов C# и C++, нам удалось построить систему умных указателей, позволяющую выполнять портированный код так, чтобы его поведение было близким к оригинальному. В то же время, задача не была решена в полностью автоматическом режиме. Нами были созданы инструменты, существенно упрощающие поиск потенциальных проблем.
Теги:C#C++.Netкросс-платформенная разработкапортированиеporting
Хабы: Программирование .NET C++ C#
Рейтинг +21
Количество просмотров 6,1k Добавить в закладки 55
Комментарии
Комментарии 25

Похожие публикации

Лучшие публикации за сутки