Небезопасный код

135

Лишь немногие приложения состоят исключительно из управляемого кода. В действительности большинство приложений используют собственные или сторонние библиотеки, реализованные на низкоуровневых языках. Платформа .NET Framework предлагает несколько механизмов взаимодействий с низкоуровневым кодом, поддерживаемых многими распространенными технологиями:

P/Invoke

Обеспечивает возможность взаимодействий с функциями, экспортируемыми библиотеками DLL.

COM Interop

Позволяет обращаться к COM-объектам из управляемого кода, а также экспортировать классы .NET в виде COM-объектов для использования низкоуровневым кодом.

Язык C++/CLI

Поддерживает взаимодействия с кодом на C и C++ посредством использования гибридного языка программирования.

Фактически базовая библиотека классов (Base Class Library, BCL), распространяемая в составе .NET Framework в виде набора библиотек DLL (основной из которых является mscorlib.dll) и содержащая реализацию встроенных типов .NET Framework, использует все вышеупомянутые механизмы. Поэтому можно смело утверждать, что любое мало-мальски сложное управляемое приложение в действительности является гибридным, в том смысле, что вызывает библиотеки, написанные на низкоуровневых языках.

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

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

Однако, в некоторых случаях эти ограничения могут осложнять решение простых задач и отрицательно сказываться на производительности. Например, может возникнуть необходимость прочитать данные из файла в массив byte[], но интерпретировать их как массив значений типа double. В C/C++ для этого достаточно привести указатель типа char к типу double. Но в безопасном коде .NET придется обернуть буфер объектом MemoryStream и использовать поверх его объект BinaryReader для чтения значений double из памяти; другой способ - использовать класс BitConverter. Оба решения вполне работоспособны, но они намного медленнее решения, доступного в неуправляемом коде.

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

Чтобы получить возможность использовать небезопасный код, необходимо сначала включить поддержку компиляции небезопасного кода в настройках проекта C#, в результате чего компилятору C# автоматически будет передаваться параметр /unsafe командной строки:

Включение поддержки небезопасного кода в настройках проекта C# (Visual Studio 2012)

Затем следует выделить области, где допускается использование небезопасного кода или переменных. Такими областями могут быть целые классы или структуры, отдельные методы или фрагменты методов.

Закрепление объектов в памяти и дескрипторы сборщика мусора

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

Закрепить объект в памяти можно либо за счет использования области видимости fixed, либо создавая дескриптор сборщика мусора. Заглушки (stubs) P/Invoke, также закрепляют объекты, подобно тому, как это делает инструкция fixed. Используйте инструкцию fixed, если закрепление может быть ограничено областью видимости функции, так как это более эффективный подход, чем применение дескрипторов сборщика мусора. В противном случае используйте GCHandle.Alloc для создания дескриптора, закрепляющего объект на более продолжительное время (до явного вызова GCHandle.Free). Объекты, размещаемые на стеке (имеющие типы значений), не требуют закрепления, потому что они недоступны сборщику мусора. Указатели на такие объекты можно получать непосредственно, используя оператор получения ссылки - знак амперсанда (&).

// Использование инструкции fixed и приведение типа указателя 
// на буфер с данными
using (var fs = new FileStream(@"C:\file.dat", FileMode.Open))
{
    var buffer = new byte[4096];
    int bytesRead = fs.Read(buffer, 0, buffer.Length);
    unsafe
    {
        double sum = 0.0;
        fixed (byte* pBuff = buffer)
        {
            double* pDblBuff = (double*)pBuff;
            for (int i = 0; i < bytesRead / sizeof(double); i++)
                sum += pDblBuff[i];
        }
    }
}

Указатель, полученный в области видимости инструкции fixed, нельзя использовать за ее пределами, потому что закрепленный объект открепляется после выхода из этой области. Ключевое слово fixed может применяться к массивам типов значений, к строкам и к полям управляемого класса, имеющим тип значения. Не забывайте указывать организацию памяти в структурах.

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

Всего существует четыре разновидности дескрипторов сборщика мусора, определяемые значениями перечисления GCHandleType: Weak, WeakTrackRessurection, Normal и Pinned. Типы Normal и Pinned предотвращают утилизацию объекта сборщиком мусора, даже если на него не осталось ни одной ссылки. Кроме того, тип Pinned дает возможность не только закрепить объект, но и получить его адрес в памяти. Типы Weak и WeakTrackResurrection не препятствуют утилизации объекта, но позволяют получить обычную (сильную) ссылку на него, если объект еще не был утилизирован. Дескрипторы этих типов используются типом WeakReference.

// Использование GCHandle и приведение типа указателя на буфер с данными
using (var fs = new FileStream(@"C:\file.dat", FileMode.Open))
{
    var buffer = new byte[4096];
    int bytesRead = fs.Read(buffer, 0, buffer.Length);

    GCHandle gch = GCHandle.Alloc(buffer, GCHandleType.Pinned);

    unsafe
    {
        double sum = 0.0;
        double* pDblBuff = (double*)(void*)gch.AddrOfPinnedObject();

        for (int i = 0; i < bytesRead / sizeof(double); i++)
            sum += pDblBuff[i];

        gch.Free();
    }
}

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

Управление жизненным циклом

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

Выделение неуправляемой памяти

Управляемые объекты, занимающие в памяти более 85 000 байт (обычно массивы байтов и строки), помещаются в кучу больших объектов (Large Object Heap, LOH), которая обслуживается сборщиком мусора вместе с областью поколения 2 и требует значительных вычислительных затрат. Куча больших объектов также часто оказывается фрагментированной из-за того, что она никогда не сжимается, а свободное пространство между объектами используется, только когда это возможно.

Обе эти проблемы увеличивают расход памяти и вычислительной мощности процессора. Поэтому гораздо эффективнее использовать пулы памяти или выделять подобные буферы в неуправляемой памяти (например, вызовом Marshal.AllocHGlobal()). Если позднее потребуется получить доступ к неуправляемому буферу из управляемого кода, используйте прием на основе «потоков», копируя небольшие фрагменты из неуправляемого буфера в управляемую память и обрабатывая их по одному. Чтобы упростить работу, используйте System.UnmanagedMemoryStream и System.UnmanagedMemoryAccessor.

Использование пулов памяти

При интенсивном использовании буферов для взаимодействий с неуправляемым кодом, их можно выделять в динамической памяти сборщика мусора или в неуправляемой памяти. Первый подход недостаточно эффективен из-за высоких накладных расходов операции выделения памяти, когда буферы имеют маленький размер. Кроме того, управляемые буферы необходимо закреплять, что увеличивает фрагментацию памяти. Второй подход также имеет свои недостатки, потому что в большинстве случаев управляемый код работает с буферами, размещенными в управляемой памяти (byte[]), а не с указателями. Нельзя преобразовать указатель на управляемый массив, минуя копирование, а это отрицательно сказывается на производительности.

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

Предлагаемая схема организации пула памяти

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

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

Объект-обертка хранит массив byte[] сегмента, смещение начала буфера в сегменте, размер буфера и неуправляемый указатель. Фактически, объект-обертка - это окно в сегмент. Он также ссылается на сегмент, чтобы уменьшить счетчик ссылок после его освобождения. Объект-обертка может поддерживать вспомогательные методы, например, чтобы проверить значение смещения и выход за пределы буфера.

Так как разработчики приложений для .NET привыкли считать, что данные всегда начинаются с индекса 0 и простираются до самого конца массива, вам придется изменить код, написанный исходя из таких предположений, и обеспечить учет дополнительных параметров, определяющих смещение и длину, которые должны передаваться вместе с буфером. Большинство методов в базовой библиотеке классов .NET, выполняющих операции с буферами, имеют перегруженные версии, принимающие смещение и длину явно.

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

Пройди тесты
Лучший чат для C# программистов