ДИСКЛЕЙМЕР: Это статья является ручным переводом оригинальной статьи с небольшими пояснениями. Поводом для перевода стало слишком частое использование unsafe кода в других статьях о C# на русском языке в том числе тут на хабре. Заранее извиняюсь за англицимзы.
Глоссарий
AVE - Access Violation Exception
Byref/Managed pointer - Управляемый указатель (
ref T t), похожий на неуправляемый указатель, но GC его отслеживает, что налагает определенные ограничения на него. Обычно указывает на произвольные части объектов или стека.Reference - ссылка на объект в куче. Фактически, это Managed Pointer со смещением +0.
Unmanaged pointer (или raw pointer) - Неуправляемый указатель (
T* p), который указывает на произвольное место в памяти (куча, стэк, "нативная" память, и т.п.) и не управляется и не отслеживается GC.
Другие термины см. в .NET Runtime Glossary.
Распространенные проблемы с unsafe кодом в C#
C# предоставляет безопасную среду, где разработчикам не нужно беспокоиться о внутренней работе среды выполнения и GC. Unsafe код позволяет обойти эти проверки безопасности, с соответсвующими рисками. Хотя unsafe может быть полезен в определенных сценариях, его следует использовать с осторожностью и только в случае крайней необходимости. C# и .NET не только не предоставляют инструментов для проверки корректности unsafe кода (как это могут делать различные санитайзеры C/C++), но еще и добавляется специфическое поведение GC (precise scanning, compacting) которое может создавать дополнительные риски в unsafe C#, помимо тех, с которыми могут быть знакомы традиционные разработчики C/C++. По фатку, небезопасный код в C# на порядки более опасный чем изначально небезопасные ЯП как С/С++.
Unsafe код должен писаться с учетом следующих консервативных предположений:
GC может прервать выполнение любого метода в любой момент времени по своему усмотрению на любой (машинной) инструкции, что намного более гранулярно, чем строчки исходного кода C#.
GC может перемещать объекты в памяти и обновлять все отслеживаемые ссылки в регистрах и стеке.
GC точно знает, когда ссылки больше не нужны.
Классический пример повреждения кучи (heap corruption) возникает, когда GC теряет ссылку на объект (under-reporting) или рассматривает невалидные указатели как ссылки на кучу (over-reporting). Такое, как правило, просходит из-за неаккуратного использования unsafe кода. Ошибки повреждения кучи особенно сложны для диагностики и воспроизведения, потому что:
Эти проблемы могут оставаться скрытыми долгое время и проявляться только после абсолютно несвязанного изменения кода или обновления среды выполнения (и вот уже виноват Microsoft, а не изначально некорректный код).
Они часто требуют точного тайминга для воспроизведения, например, прерывания выполнения GC в определенном месте и начала компактирования кучи, что является редким и недетерминированным событием.
ВАЖНО: если вы не используете unsafe код, вы защищены от всех проблем, описанных в этой статье. Однако, понятие "unsafe C#" код планируется сильно расширить в .NET 11 и .NET 12 аннотированием многих публичных API как требующих unsafe контекста.
В следующих разделах описываются распространенные unsafe паттерны с рекомендациями ✔️ DO (ДЕЛАТЬ) и ❌ DON'T (НЕ ДЕЛАТЬ).
1. Неотслеживаемые управляемые указатели (Unsafe.AsPointer и его друзья)
В безопасном C# невозможно преобразовать управляемый (отслеживаемый) указатель в неуправляемый (неотслеживаемый). Когда возникает такая необходимость, часто используют самое опасное из всех API System.Runtime.CompilerServices.Unsafe.AsPointer<T>(ref T), дабы избежать оверхеда от оператора fixed (или необходимости ограничивать скоуп). Хотя для этого есть валидные сценарии использования, это создает риск создания неотслеживаемых указателей на перемещаемые объекты.
Пример:
unsafe void UnreliableCode(ref int x)
{
int* nativePointer = (int*)Unsafe.AsPointer(ref x);
nativePointer[0] = 42;
}
Если GC прервет выполнение метода UnreliableCode сразу после чтения указателя (адреса, на который ссылается x) и переместит объект, на который ссылается x, GC корректно обновит местоположение, хранящееся в x, но ничего не будет знать о nativePointer и не обновит значение, которое он содержит. В этот момент запись в nativePointer — это запись в произвольную память.
unsafe void UnreliableCode(ref int x)
{
int* nativePointer = (int*)Unsafe.AsPointer(ref x);
// <-- GC срабатывает здесь между двумя строками кода
// и обновляет `x`, чтобы он указывал на новое место.
// Однако `nativePointer` все еще указывает на старое
// место, так как о нем не сообщено GC.
nativePointer[0] = 42; // Потенциально повреждающая
// запись, access violation или другая проблема.
}
Как только GC возобновит выполнение метода, он запишет 42 в старое местоположение x, что может привести к неожиданному исключению, общему повреждению глобального состояния или завершению процесса из-за access violation.
Рекомендуемое решение — использовать ключевое слово fixed и оператор взятия адреса &, чтобы гарантировать, что GC не сможет переместить ссылку в течение операции.
unsafe void ReliableCode(ref int x)
{
fixed (int* nativePointer = &x) // `x` больше не может быть перемещен
{
nativePointer[0] = 42;
}
}
Рекомендации
❌ НЕ используйте аргументы
ref Xс неявным контрактом, чтоXвсегда размещен в стеке, запинен (pinned) или иным образом не перемещаем GC. То же самое относится к обычным объектам и Span-ам — не вводите неочевидные контракты, основанные на вызывающем коде, относительно их времени жизни в сигнатурах методов. Рассмотрите вместо этого использование аргументаref structили изменение аргумента на тип сырого указателя (X*).❌ НЕ используйте указатель из
System.Runtime.CompilerServices.Unsafe.AsPointer<T>(ref T), если он может пережить исходный объект, на который он указывает. Согласно документации API, вызывающийSystem.Runtime.CompilerServices.Unsafe.AsPointer<T>(ref T)должен гарантировать, что GC не сможет переместить ссылку. Убедитесь, что для ревьюверов кода очевидно, что вызывающий выполнил это предварительное условие.✔️ ИСПОЛЬЗУЙТЕ
GCHandleили областиfixedвместоSystem.Runtime.CompilerServices.Unsafe.AsPointer<T>(ref T)для определения явных областей видимости для неуправляемых указателей и гарантии того, что объект всегда запинен.✔️ ИСПОЛЬЗУЙТЕ неуправляемые указатели (с
fixed) вместо byrefs, когда вам нужно выровнять массив по определенной границе. Это гарантирует, что GC не переместит объект и не нарушит любые предположения о выравнивании, на которые может полагаться ваша логика.
2. Раскрытие указателей за пределы области fixed
Хотя ключевое слово fixed определяет область видимости для указателя, полученного из запиненного объекта, все еще возможно, что этот указатель выйдет за пределы области fixed что приведет к багам, так как C# не предоставляет никакой защиты владения/жизненного цикла для него (никаких гарантий для любетелей unsafe кода).
Типичный пример — следующий фрагмент:
unsafe int* GetPointerToArray(int[] array)
{
fixed (int* pArray = array)
{
_ptrField = pArray; // Баг!
Method(pArray); // Баг, если `Method` позволяет `pArray` "убежать",
// возможно, присвоив его полю.
return pArray; // Баг!
// И другие способы выхода за пределы области видимости.
}
}
В этом примере массив правильно запинен с использованием ключевого слова fixed (гарантируя, что GC не сможет переместить его внутри блока fixed), но затем указатель раскрывается за пределы блока fixed. Это создает висячий указатель (dangling pointer), разыменование которого приведет к неопределенному поведению.
Рекомендации
✔️ УБЕДИТЕСЬ, что указатели в блоках
fixedне покидают определенную область видимости.✔️ ПРЕДПОЧИТАЙТЕ безопасные низкоуровневые примитивы со встроенным escape-анализом, такие как ref struct в C#. Для получения дополнительной информации см. Low-level struct improvements.
3. Внутренние детали реализации среды выполнения и библиотек
Хотя доступ к внутренним деталям реализации является плохой практикой в целом, стоит упомянуть конкретные часто встречающиеся случаи. Это не исчерпывающий список всего, что может пойти не так, когда код ненадлежащим образом полагается на внутреннюю деталь реализации.
Рекомендации
❌ НЕ изменяйте и не читайте никакие части заголовка объекта.
- Заголовки объектов могут отличаться в разных средах выполнения.
- В CoreCLR к заголовку объекта нельзя безопасно получить доступ без предварительного пиннинга объекта. Любые библиотеки которые зачем-то это делают гарантируют себе (и пользователям) неочевидные редковоспроизводимые проблемы.
- Никогда не меняйте тип объекта, изменяя указатель MethodTable.❌ НЕ храните никаких данных в паддинге (padding) объекта. Не предполагайте, что содержимое паддинга будет сохранено или что паддинг всегда обнулен по умолчанию.
❌ НЕ делайте предположений о размерах и смещениях чего-либо, кроме примитивов и структур с последовательной (sequential) или явной (explicit) компоновкой. Даже тут существуют исключения, например, когда задействованы поля ссылочных типов.
❌ НЕ вызывайте непубличные методы, не обращайтесь к непубличным полям и не изменяйте поля
readonlyв типах BCL (а лучше и вообще везде) с помощью рефлексии или unsafe кода.❌ НЕ предполагайте, что любой данный непубличный член в BCL всегда будет присутствовать или иметь определенную форму. Команда .NET иногда изменяет или удаляет непубличные API в сервисных релизах.
-
❌ НЕ предполагайте просто так, что ссылка является неперемещаемой. Это особенно относится к строковым и UTF-8 (
"..."u8) литералам, статическим полям, полям RVA, объектам LOH и так далее.
- Это детали реализации среды выполнения, которые могут быть верны для одних сред выполнения, но не для других.
- Неуправляемые указатели на такие объекты могут не предотвратить выгрузку сборок (Unloadable ALC), что приведет к тому, что указатели станут висячими. Используйте областиfixedдля обеспечения корректности.ReadOnlySpan<int> rva = [1, 2, 4, 4]; int* p = (int*)Unsafe.AsPointer(ref MemoryMarshal.GetReference(rva)); // Баг! Сборка, содержащая поле RVA, может быть выгружена в этот момент, // и `p` станет висячим указателем. int value = p[0]; // Access violation или другая проблема. ❌ НЕ пишите код, который полагается на детали реализации конкретной среды выполнения.
4. Невалидные управляемые указатели (даже если они никогда не разыменовываются)
Определенные категории кода опираются на манипуляции с указателями и арифметику, и такой код часто имеет выбор между использованием неуправляемых указателей (T* p) и управляемых указателей (ref T p).
Этими указателями можно манипулировать произвольно, например, с помощью операторов над неуправляемыми указателями (p++) и с помощью методов Unsafe над управляемыми указателями (p = ref Unsafe.Add(ref p, 1)). Оба варианта считаются "unsafe кодом", и с обоими можно сильно накосячить. Однако для определенных алгоритмов может быть проще случайно создать GC-unsafe паттерны при манипулировании управляемыми указателями. Поскольку неуправляемые указатели не отслеживаются GC, совершенно не важно куда они указывают, пока они не разыменовываются. Это не является проблемой и иногда используется намеренно. Это совершенно не допустимо для управляемых указателей, т.к. даже если вы нигде явно не разыменовываете невалидный управляемый указатель, Runtime/GC могут в любой момент сделать это за вас с неопределенными последствиями.
unsafe void UnmanagedPointers(int[] array)
{
fixed (int* p = array)
{
int* invalidPtr = p - 1000;
// invalidPtr указывает на неопределенное место в памяти
// это нормально, пока он не разыменован.
int* validPtr = invalidPtr + 1000; // Возвращаемся к исходному месту
*validPtr = 42; // OK
}
}
Однако аналогичный код с использованием byrefs (управляемых указателей) является невалидным.
void ManagedPointers_Incorrect(int[] array)
{
ref int invalidPtr = ref Unsafe.Add(ref array[0], -1000); // Уже баг!
ref int validPtr = ref Unsafe.Add(ref invalidPtr, 1000);
validPtr = 42; // возможно повреждающая запись
}
Хотя управляемая реализация здесь избегает небольшого оверхеда на пиннинг, она некорректна, потому что invalidPtr может стать внешним указателем (exterior pointer), в то время как фактический адрес array[0] обновляется GC.
Такие баги сложно диагностировать, и даже .NET сталкивался с ними во время разработки.
Рекомендации
❌ НЕ создавайте невалидные управляемые указатели, даже если они не разыменовываются или находятся внутри никогда не выполняемых путей кода. Для получения дополнительной информации о том, что составляет валидный управляемый указатель.
✔️ ИСПОЛЬЗУЙТЕ запиненные неуправляемые указатели, если алгоритм требует таких манипуляций.
5. Приведения типов в стиле reinterpret_cast
Хотя все виды приведений struct-to-class или class-to-struct являются неопределенным поведением по определению, также возможно столкнуться с проблемами при преобразованиях struct-to-struct или class-to-class.
Типичный пример:
struct S1
{
string a;
nint b;
}
struct S2
{
string a;
string b;
}
S1 s1 = ...
S2 s2 = Unsafe.As<S1, S2>(ref s1); // Баг! Случайное значение
// nint становится ссылкой, сообщаемой GC.
И даже если лейаут похож, вам все равно следует быть осторожными, когда задействованы ссылки GC (поля), т.к. фактически лейаут становится Auto даже если указан Sequential.
Рекомендации
❌ НЕ приводите структуры к классам или наоборот.
❌ НЕ используйте
Unsafe.Asдля преобразований struct-to-struct или class-to-class, если вы не абсолютно уверены, что приведение законно. Для получения дополнительной информации см. раздел Remarks в документацииUnsafe.AsAPI.✔️ ПРЕДПОЧИТАЙТЕ более безопасное копирование поле-за-полем, внешние библиотеки, такие как AutoMapper, или Source Generators для таких преобразований.
✔️ ПРЕДПОЧИТАЙТЕ
Unsafe.BitCastвместоUnsafe.As, так какBitCastпредоставляет некоторые элементарные проверки использования. Обратите внимание, что эти проверки не обеспечивают полных гарантий корректности, что означает, чтоBitCastвсе еще считается unsafe API.
6. Обход Write Barrier и неатомарные операции над ссылками GC
Обычно все виды записи или чтения ссылок GC всегда атомарны. Кроме того, все попытки присвоить ссылку GC (или byref на структуру с полями GC) в потенциальное место в куче проходят через GC Write Barrier (барьер записи GC), который гарантирует, что GC знает о новых связях между объектами.
Однако unsafe код позволяет нам обойти эти гарантии и ввести ненадежные паттерны. Пример:
unsafe void InvalidCode1(object[] arr1, object[] arr2)
{
fixed (object* p1 = arr1)
fixed (object* p2 = arr2)
{
nint* ptr1 = (nint*)p1;
nint* ptr2 = (nint*)p2;
// Баг! Мы присваиваем указатель GC в место в куче
// не проходя через Write Barrier.
// Более того, мы также обходим проверки ковариантности массивов.
*ptr1 = *ptr2;
}
}
Аналогично, следующий код с управляемыми указателями также ненадежен:
struct StructWithGcFields
{
object a;
int b;
}
void InvalidCode2(ref StructWithGcFields dst, ref StructWithGcFields src)
{
// Это уже плохая идея приводить структуру с полями GC к `ref byte` и т.д.
ref byte dstBytes = ref Unsafe.As<StructWithGcFields, byte>(ref dst);
ref byte srcBytes = ref Unsafe.As<StructWithGcFields, byte>(ref src);
// Баг! Обходит Write Barrier. Также неатомарные записи/чтения для ссылок GC.
Unsafe.CopyBlockUnaligned(
ref dstBytes, ref srcBytes, (uint)Unsafe.SizeOf<StructWithGcFields>());
// Баг! То же самое, что и выше.
Vector128.LoadUnsafe(ref srcBytes).StoreUnsafe(ref dstBytes);
}
Рекомендации
❌ НЕ используйте неатомарные операции над ссылками GC (например, SIMD операции часто не предоставляют их).
❌ НЕ используйте неуправляемые указатели для сохранения ссылок GC в места в куче (пропуская Write Barrier).
7. Предположения о времени жизни объектов (финализаторы, GC.KeepAlive)
Избегайте предположений о времени жизни объектов с точки зрения GC.
В частности, не предполагайте, что объект все еще жив, когда это может быть не так. Время жизни объектов может варьироваться в разных средах выполнения или даже между разными уровнями (Tiers) одного и того же метода (Tier0 и Tier1 в RyuJIT).
Финализаторы — распространенный сценарий, где такие предположения могут быть неверными.
public class MyClassWithBadCode
{
public IntPtr _handle;
public void DoWork() => DoSomeWork(_handle); // Баг use-after-free!
~MyClassWithBadCode() => DestroyHandle(_handle);
}
// Пример использования:
var obj = new MyClassWithBadCode()
obj.DoWork();
В этом примере DestroyHandle может быть вызван до завершения DoWork или даже до его начала.
Поэтому крайне важно не предполагать, что объекты, такие как this, останутся живыми до конца метода.
void DoWork()
{
// Псевдокод того, что может произойти "под капотом":
IntPtr reg = this._handle;
// Объект 'this' больше не жив в этот момент.
// <-- GC прерывает здесь, собирает объект 'this' и запускает его финализатор.
// Вызывается DestroyHandle(_handle).
// Баг! 'reg' теперь висячий указатель.
DoSomeWork(reg);
// Вы можете решить проблему и принудительно сохранить 'this' живым (тем самым гарантируя, что
// финализатор не запустится), раскомментировав строку ниже:
// GC.KeepAlive(this);
}
Поэтому рекомендуется явно продлевать время жизни объектов с помощью GC.KeepAlive(object) или System.Runtime.InteropServices.SafeHandle.
Другой классический пример этой проблемы — API System.Runtime.InteropServices.Marshal.GetFunctionPointerForDelegate<TDelegate>(TDelegate):
var callback = new NativeCallback(OnCallback);
// Преобразование делегата в указатель на функцию
IntPtr fnPtr = Marshal.GetFunctionPointerForDelegate(callback);
// Баг! Делегат может быть собран GC здесь.
// Он должен поддерживаться живым, пока нативный код не закончит с ним работу.
RegisterCallback(fnPtr);
Рекомендации
❌ НЕ делайте предположений о времени жизни объектов. Например, никогда не предполагайте, что
thisвсегда жив до конца метода.✔️ ИСПОЛЬЗУЙТЕ
System.Runtime.InteropServices.SafeHandleдля управления нативными ресурсами. Поля типаIntPtr _handleилиvoid* _handleв классах с финализаторами - это стойкий запах потенциальных проблем в вашем коде (часто используется в интеропе).✔️ ИСПОЛЬЗУЙТЕ
GC.KeepAlive(object)для продления времени жизни объектов при необходимости, однакоSafeHandleобычно является лучшим решением.
8. Доступ к локальным переменным из разных потоков
Доступ к локальным переменным из другого потока обычно считается плохой практикой. Однако это становится явным UB, когда задействованы управляемые ссылки, как описано в .NET Memory Model.
Пример: Структура, содержащая ссылки GC, может быть обнулена или перезаписана небезопасным для потоков образом внутри региона без GC (no-GC region), в то время как другой поток читает ее, что приводит к неопределенному поведению.
Рекомендации
❌ НЕ обращайтесь к локальным переменным из разных потоков (особенно если они содержат ссылки GC).
✔️ ИСПОЛЬЗУЙТЕ кучу или неуправляемую память (например,
System.Runtime.InteropServices.NativeMemory.Alloc) вместо этого.
9. Удаление проверок границ (bounds check)
В C# все идиоматические обращения к памяти включают проверки границ по умолчанию.
JIT-компилятор может удалить эти проверки, если сможет доказать, что они не нужны, как в примере ниже.
int SumAllElements(int[] array)
{
int sum = 0;
for (int i = 0; i < array.Length; i++)
{
// JIT знает, что внутри этого тела цикла i >= 0 и i < array.Length.
// JIT может предположить, что его собственная проверка границ
// будет дублирующей и ненужной, поэтому он решает не
// генерировать проверку границ в финальном сгенерированном коде.
sum += array[i];
}
}
Хотя JIT постоянно улучшается в распознавании таких паттернов, все еще существуют сценарии, где он оставляет проверки на месте, потенциально влияя на производительность в горячем коде. В таких случаях у вас может возникнуть соблазн использовать unsafe код для ручного удаления этих проверок, не до конца понимая риски или точно не оценивая преимущества производительности.
Рассмотрим, например, следующий метод.
int FetchAnElement(int[] array, int index)
{
return array[index];
}
Если JIT не может доказать, что index всегда легально находится в границах array, он перепишет метод примерно так:
int FetchAnElement_AsJitted(int[] array, int index)
{
if (index < 0 || index >= array.Length)
throw new IndexOutOfBoundsException();
return array.GetElementAt(index);
}
Чтобы уменьшить накладные расходы от этой проверки в горячем коде, может возникнуть соблазн использовать unsafe-эквивалентные API (Unsafe и MemoryMarshal):
int FetchAnElement_Unsafe1(int[] array, int index)
{
// Доступ ниже не проверяется на границы и может вызвать access violation.
return Unsafe.Add(ref MemoryMarshal.GetArrayDataReference(array), index);
}
Или использовать пиннинг и unmanaged указатели:
unsafe int FetchAnElement_Unsafe2(int[] array, int index)
{
fixed (int* pArray = array)
{
// Доступ ниже не проверяется на границы и может вызвать access violation.
return pArray[index];
}
}
Это может привести к случайным сбоям или повреждению состояния, если index находится за пределами границ array.
Такие unsafe преобразования могут иметь преимущества в производительности на очень горячих путях, но эти преимущества часто временны, так как каждый релиз .NET улучшает способность JIT устранять ненужные проверки границ, когда это безопасно.
Рекомендации
✔️ ПРОВЕРЯЙТЕ, может ли последняя версия .NET все еще не устранить проверку границ. Если может, перепишите, используя безопасный код. В противном случае создайте issue в dotnet/runtime. Используйте этот issue как отправную точку.
✔️ ИЗМЕРЯЙТЕ реальное влияние на производительность. Если прирост производительности незначителен или код не доказал свою "горячесть" за пределами тривиального микробенчмарка, перепишите, используя безопасный код.
✔️ ПРЕДОСТАВЛЯЙТЕ дополнительные подсказки JIT, такие как ручные проверки границ перед циклами и сохранение полей в локальные переменные, так как .NET Memory Model может консервативно предотвращать удаление проверок границ JIT-ом в некоторых сценариях.
✔️ ЗАЩИЩАЙТЕ код с помощью проверок границ
Debug.Assert, если unsafe код все еще необходим. Рассмотрите пример ниже.
Debug.Assert(array is not null);
Debug.Assert((index >= 0) && (index < array.Length));
// Unsafe код здесь
Вы даже можете отрефакторить эти проверки в переиспользуемые вспомогательные методы.
[MethodImpl(MethodImplOptions.AggressiveInlining)]
static T UnsafeGetElementAt<T>(this T[] array, int index)
{
Debug.Assert(array is not null);
Debug.Assert((index >= 0) && (index < array.Length));
return Unsafe.Add(ref MemoryMarshal.GetArrayDataReference(array), index);
}
Включение Debug.Assert не обеспечивает никаких проверок корректности для Release сборок, но может помочь обнаружить потенциальные баги в Debug сборках.
10. Объединение доступа к памяти (Memory access coalescing)
Иногда используют unsafe код для объединения обращений к памяти с целью повышения производительности. Классический пример — этот код для записи "False" в массив char:
// Наивная реализация
static void WriteToDestination_Safe(char[] dst)
{
if (dst.Length < 5) { throw new ArgumentException(); }
dst[0] = 'F';
dst[1] = 'a';
dst[2] = 'l';
dst[3] = 's';
dst[4] = 'e';
}
// Unsafe оптимизированная реализация
static void WriteToDestination_Unsafe(char[] destination)
{
Span<char> dstSpan = destination;
if (dstSpan.Length < 5) { throw new ArgumentException(); }
ulong fals_val = BitConverter.IsLittleEndian ?
0x0073006C00610046ul :
0x00460061006C0073ul;
MemoryMarshal.Write(MemoryMarshal.AsBytes(
dstSpan.Slice(0, 4)), in fals_val); // "Fals" (4 chars)
dstSpan[4] = 'e'; // "e" (1 char)
}
В предыдущих версиях .NET unsafe версия с использованием MemoryMarshal была заметно быстрее, чем простая безопасная версия. Однако современные версии .NET содержат значительно улучшенный JIT, который производит эквивалентный код для обоих случаев. На момент выхода .NET 10 код для x64 выглядит так:
; WriteToDestination_Safe
cmp eax, 5
jl THROW_NEW_ARGUMENTEXCEPTION
mov rax, 0x73006C00610046
mov qword ptr [rdi+0x10], rax
mov word ptr [rdi+0x18], 101
; WriteToDestination_Unsafe
cmp edi, 5
jl THROW_NEW_ARGUMENTEXCEPTION
mov rdi, 0x73006C00610046
mov qword ptr [rax], rdi
mov word ptr [rax+0x08], 101
Существует еще более простая и читаемая версия кода:
"False".CopyTo(dst);
На момент .NET 10 этот вызов производит идентичный код, как и выше. У него даже есть дополнительное преимущество: он подсказывает JIT, что строгие поэлементные записи не обязаны быть атомарными. JIT может объединить эту подсказку с другими контекстными знаниями, чтобы обеспечить еще больше оптимизаций, выходящих за рамки обсужденного здесь.
Рекомендации
-
✔️ ПРЕДПОЧИТАЙТЕ идиоматический безопасный код вместо unsafe для объединения доступа к памяти:
Предпочитайте
Span<T>.CopyToиSpan<T>.TryCopyToдля копирования данных.Предпочитайте
String.EqualsиSpan<T>.SequenceEqualдля сравнения данных (даже при использованииStringComparer.OrdinalIgnoreCase).Предпочитайте
Span<T>.Fillдля заполнения данных иSpan<T>.Clearдля очистки данных.Помните, что поэлементные или пополевые записи/чтения могут быть объединены JIT-ом автоматически.
✔️ СОЗДАЙТЕ issue в dotnet/runtime, если вы пишете идиоматический код и наблюдаете, что он не оптимизируется так, как ожидалось.
❌ НЕ объединяйте обращения к памяти вручную, если вы не уверены в рисках невыровненного доступа к памяти, гарантиях атомарности или связанных с этим преимуществах производительности.
11. Невыровненный доступ к памяти
Объединение доступа к памяти, описанное выше, часто приводит к явным или неявным невыровненным чтениям/записям. Хотя это обычно не вызывает серьезных проблем (кроме потенциальных штрафов производительности из-за пересечения границ кэша процессора и страниц), это все же создает некоторые реальные риски.
Например, рассмотрим сценарий, где вы очищаете два элемента массива одновременно:
uint[] arr = _arr;
arr[i + 0] = 0;
arr[i + 1] = 0;
Допустим, предыдущие значения в этих местах были оба uint.MaxValue (0xFFFFFFFF).
.NET Memory Model гарантирует, что обе записи атомарны, поэтому все другие потоки в процессе будут наблюдать только новое значение 0 или старое значение 0xFFFFFFFF, никогда не "разорванные" (torn) значения, такие как 0xFFFF0000.
Однако предположим, что используется следующий unsafe код для обхода проверки границ и обнуления обоих элементов одной 64-битной записью:
ref uint p = ref Unsafe.Add(ref MemoryMarshal.GetArrayDataReference(arr), i);
Unsafe.WriteUnaligned<ulong>(ref Unsafe.As<uint, byte>(ref p), 0UL);
Этот код имеет побочный эффект удаления гарантии атомарности. Разорванные значения могут наблюдаться другими потоками, что приведет к неопределенному поведению.
Чтобы такая объединенная запись была атомарной, память должна быть выровнена по размеру записи (8 байт в данном случае). Если вы попытаетесь вручную выровнять память перед операцией, вы должны учитывать, что GC может переместить (и, фактически, изменить выравнивание) массива в любое время, если он не запинен. См. документацию .NET Memory Model для получения более подробной информации.
Другой риск невыровненного доступа к памяти — потенциальный сбой приложения в определенных сценариях.
Хотя некоторые среды выполнения .NET полагаются на ОС для исправления невыровненных доступов, все еще существуют сценарии на некоторых платформах, где невыровненный доступ может привести к System.DataMisalignedException (или System.Runtime.InteropServices.SEHException).
Примеры:
Операции
Interlockedна невыровненной памяти на некоторых платформах (пример).Невыровненные операции с плавающей точкой на ARM (Linux OS не делает автоматическое исправление для некоторых из этих случаев в ARM32)
Доступ к специальной памяти устройства с определенными требованиями к выравниванию (этот сценарий не особо поддерживается .NET в целом).
Рекомендации
❌ НЕ используйте невыровненные доступы к памяти в lock-free алгоритмах и других сценариях, где важна атомарность.
✔️ ВЫРАВНИВАЙТЕ данные вручную при необходимости, но помните, что GC может перемещать объекты в любое время, по сути меняя ваше выравнивание динамически. Это особенно важно для различных API
StoreAligned/LoadAlignedв SIMD, которые требуют строгого выравнивания.✔️ ИСПОЛЬЗУЙТЕ явные API невыровненного чтения/записи, такие как
Unsafe.ReadUnaligned/Unsafe.WriteUnaligned, вместо выровненных, таких какUnsafe.Read/Unsafe.WriteилиUnsafe.As, если данные могут быть не выровнены.✔️ ПОМНИТЕ, что различные API манипуляции памятью, такие как
Span<T>.CopyTo, также не предоставляют гарантий атомарности.✔️ ПРОКОНСУЛЬТИРУЙТЕСЬ с документацией .NET Memory Model для получения более подробной информации о гарантиях атомарности.
✔️ ИЗМЕРЯЙТЕ производительность на всех ваших целевых платформах, так как некоторые платформы накладывают значительный штраф производительности за невыровненный доступ к памяти. На этих платформах наивный код может работать лучше, чем оптимизированный unsafe код с невыровненным доступом.
✔️ ПОМНИТЕ, что существуют сценарии и платформы, где невыровненный доступ к памяти может привести к падению приложения.
12. Бинарная (де)сериализация структур с паддингами или non-blittable членами
Будьте осторожны при использовании различных API, похожих на сериализацию, для копирования или чтения структур в или из байтовых массивов.
Если структура содержит паддинги или non-blittable члены (например, bool или поля GC), то классические unsafe операции с памятью, такие как Fill, CopyTo и SequenceEqual, могут случайно скопировать чувствительные данные из стека в паддинги или рассматривать мусорные данные как значимые при сравнении, создавая редко воспроизводимые баги. Распространенный анти-паттерн может выглядеть так:
T UnreliableDeserialization<TObject>(ReadOnlySpan<byte> data) where TObject : unmanaged
{
return MemoryMarshal.Read<TObject>(data); // или Unsafe.ReadUnaligned
// БАГ! TObject : unmanaged не гарантирует,
// что TObject является blittable и не содержит паддингов.
}
Единственный правильный подход — использовать загрузку/сохранение поле-за-полем, специализированные для каждого входа TObject (или обобщенные с помощью Reflection, Source Generators или библиотек (де)сериализации).
Рекомендации
❌ НЕ используйте unsafe код для копирования/загрузки/сравнения структур с паддингами или non-blittable членами. Загрузки из ненадежных источников проблематичны даже для базовых типов, таких как
boolилиdecimal. В то же время, сохранения могут случайно сериализовать чувствительную информацию из стека в промежутках/паддингах структуры.-
❌ НЕ полагайтесь на ограничение
T : unmanaged,RuntimeHelpers.IsReferenceOrContainsReferencesили подобные API, чтобы гарантировать, что обобщенный тип безопасен для выполнения побитовых операций. На момент написания этих рекомендаций не существует надежного программного способа определить, законно ли выполнять произвольные побитовые операции над данным типом.-
Если вы должны выполнять такие побитовые манипуляции, делайте это только для этого жестко закодированного списка типов и помните о порядке байтов (endianness) текущей машины:
Примитивные целочисленные типы
Byte,SByte,Int16,UInt16,Int32,UInt32,Int64иUInt64;Enum, основанный на одном из вышеуказанных примитивных целочисленных типов;Char,Int128,UInt128,Half,Single,Double,IntPtr,UIntPtr.
-
✔️ ИСПОЛЬЗУЙТЕ (де)сериализацию поле-за-полем вместо этого. Рассмотрите использование популярных и безопасных библиотек для (де)сериализации.
13. Null управляемые указатели
Как правило, byrefs (управляемые указатели) редко бывают null, и единственный безопасный способ создать null byref на сегодняшний день — это инициализировать ref struct значением default. Тогда все его ref поля будут null управляемыми указателями:
RefStructWithRefField s = default;
ref byte nullRef = ref s.refFld;
Однако существует несколько unsafe способов создать null byrefs. Примеры:
// Null byref путем прямого вызова Unsafe.NullRef:
ref object obj = ref Unsafe.NullRef<object>();
// Null byref путем превращения null неуправляемого указателя в null управляемый указатель:
ref object obj = ref Unsafe.AsRef<object>((void*)0);
Риск возникновения проблем с безопасностью памяти низок, и любая попытка разыменовать null byref приведет к четко определенному NullReferenceException.
Однако компилятор C# предполагает, что разыменование byref всегда успешно и не производит наблюдаемого побочного эффекта. Поэтому законной оптимизацией является удаление любого разыменования, результирующее значение которого не используется явно. См. dotnet/runtime#98681 (и этот связанный комментарий) для примера теперь исправленного бага в .NET, где код библиотеки неправильно полагался на то, что разыменование вызовет побочный эффект, не зная, что компилятор C# фактически удалил предполагаемую логику.
Рекомендации
❌ НЕ создавайте null byrefs в C#, если это не необходимо. Рассмотрите использование обычных управляемых ссылок, Null Object Pattern или пустых span-ов вместо этого.
❌ НЕ отбрасывайте результат разыменования byref, так как он может быть оптимизирован Roslyn-ом, что приведет к потенциальным багам.
14. stackalloc
stackalloc исторически использовался для создания небольших, не "убегающих" (non-escaping) массивов в стеке, снижая давление на GC. В будущем Escape Analysis в JIT может начать оптимизировать не убегающие GC аллокации массивов в объекты стека (в .NET 10 в каком-то виде это уже происходит), потенциально делая stackalloc избыточным. До тех пор stackalloc остается полезным для выделения небольших буферов в стеке. Для больших или убегающих буферов он часто комбинируется с ArrayPool<T>.
Рекомендации
-
✔️ ВСЕГДА сохраняйте
stackallocвReadOnlySpan<T>/Span<T>на левой стороне выражения, чтобы обеспечить проверки границ:// Хорошо: Span<int> s = stackalloc int[10]; s[2] = 0; // Проверка границ устраняется JIT для этой записи. s[42] = 0; // Выбрасывается IndexOutOfRangeException // Плохо: int* s = stackalloc int[10]; s[2] = 0; s[42] = 0; // Запись за пределы границ, неопределенное поведение. ❌ НЕ используйте
stackallocвнутри циклов. Место в стеке не освобождается до возврата из метода, поэтому включениеstackallocвнутри цикла может привести к завершению процесса из-за переполнения стека.❌ НЕ используйте большие длины для
stackalloc. Например, 1024 байта можно считать разумной верхней границей, однако рекомендуется использовать гораздо меньшие размеры (например, 256 байт или меньше) для того, чтобы избежать долгого зануления памяти.-
✔️ ПРОВЕРЯЙТЕ диапазон переменных, используемых в качестве длины
stackalloc.void ProblematicCode(int length) { Span<int> s = stackalloc int[length]; // Плохая практика: проверьте диапазон `length`! Consume(s); }Исправленная версия:
void BetterCode(int length) { // Проверка "throw if length < 0" ниже важна, так как попытка stackalloc отрицательной // длины приведет к завершению процесса. ArgumentOutOfRangeException.ThrowIfLessThan(length, 0, nameof(length)); Span<int> s = length <= 256 ? stackalloc int[length] : new int[length]; // Или: // Span<int> s = length <= 256 ? stackalloc int[256] : new int[length]; // Что выполняет более быстрое обнуление stackalloc, но потенциально потребляет больше места в стеке. Consume(s); } ✔️ ИСПОЛЬЗУЙТЕ современные фичи C#, такие как литералы коллекций (
Span<int> s = [1, 2, 3];),params Span<T>и inline arrays, чтобы избежать ручного управления памятью, когда это возможно.
15. Буферы фиксированного размера (Fixed-size buffers)
Буферы фиксированного размера были полезны для сценариев интеропа с источниками данных из других языков или платформ. Затем они были заменены более безопасными и удобными inline arrays.
Пример буфера фиксированного размера (требует контекста unsafe) — следующий фрагмент:
public struct MyStruct
{
public unsafe fixed byte data[8];
// Другие поля
}
MyStruct m = new();
ms.data[10] = 0; // Запись за пределы границ, неопределенное поведение.
Современная и более безопасная альтернатива — inline arrays:
[System.Runtime.CompilerServices.InlineArray(8)]
public struct Buffer
{
private int _element0; // может быть generic
}
public struct MyStruct
{
public Buffer buffer;
// Другие поля
}
MyStruct ms = new();
ms.buffer[i] = 0; // Runtime выполняет проверку границ для индекса 'i';
ms.buffer[7] = 0; // Проверка границ опущена; известно, что индекс в диапазоне.
ms.buffer[10] = 0; // Компилятор знает, что это вне диапазона, и выдает ошибку компилятора CS9166.
Еще одна причина избегать буферов фиксированного размера в пользу inline arrays, которые всегда инициализируются нулями по умолчанию, заключается в том, что буферы фиксированного размера могут иметь ненулевое содержимое в определенных сценариях.
Рекомендации
✔️ ПРЕДПОЧИТАЙТЕ замену буферов фиксированного размера на inline arrays или атрибуты маршалинга IL, где это возможно.
16. Передача непрерывных данных как указатели + длины
Избегайте определения API, которые принимают неуправляемые или управляемые указатели на непрерывные данные. Вместо этого используйте Span<T> или ReadOnlySpan<T>:
// Плохой дизайн API:
void Consume(ref byte data, int length);
void Consume(byte* data, int length);
void Consume(byte* data); // zero-terminated
void Consume(ref byte data); // zero-terminated
// Хороший дизайн API:
void Consume(Span<byte> data);
void Consume(Memory<byte> data);
void Consume(byte[] data);
void Consume(byte[] data, int offset, int length);
Zero-termination (завершение нулем) особенно рискованно, потому что не все буферы завершаются нулем, и чтение за пределами любого нулевого терминатора может привести к раскрытию информации, повреждению данных или завершению процесса из-за access violation.
Рекомендации
❌ НЕ создавайте методы, аргументы которых являются типами указателей (неуправляемые указатели
T*или управляемые указателиref T), когда эти аргументы предназначены для представления буферов. Используйте безопасные типы буферов, такие какSpan<T>илиReadOnlySpan<T>, вместо этого.❌ НЕ используйте неявные контракты для аргументов byref, такие как требование ко всем вызывающим аллоцировать входные данные в стеке. Если такой контракт необходим, рассмотрите использование
ref structвместо этого.-
❌ НЕ предполагайте, что буферы завершаются нулем, если сценарий явно не документирует, что это валидное предположение. Например, хотя .NET гарантирует, что экземпляры
stringи литералы"..."u8завершаются нулем, то же самое не относится к другим типам буферов, таким какReadOnlySpan<char>илиchar[].unsafe void NullTerminationExamples(string str, ReadOnlySpan<char> span, char[] array) { Debug.Assert(str is not null); Debug.Assert(array is not null); fixed (char* pStr = str) { // OK: Строки всегда гарантированно имеют нулевой терминатор. // Это присвоит значение '\0' переменной 'ch'. char ch = pStr[str.Length]; } fixed (char* pSpan = span) { // НЕКОРРЕКТНО: Spans не гарантируют завершение нулем. // Это может выбросить исключение, присвоить мусорные // данные 'ch' или вызвать AV и краш. char ch = pSpan[span.Length]; } fixed (char* pArray = array) { // НЕКОРРЕКТНО: Массивы не гарантируют завершение нулем. // Это может выбросить исключение, присвоить мусорные // данные 'ch' или вызвать AV и краш. char ch = pArray[array.Length]; } } ❌ НЕ передавайте запиненный
Span<char>илиReadOnlySpan<char>через границу p/invoke, если вы также не передали явный аргумент длины. В противном случае код на другой стороне границы p/invoke может неправильно полагать, что буфер завершается нулем.
unsafe static extern void SomePInvokeMethod(char* pwszData);
unsafe void IncorrectPInvokeExample(ReadOnlySpan<char> data)
{
fixed (char* pData = data)
{
// НЕКОРРЕКТНО: Поскольку 'data' — это span и не гарантируется,
// что он завершается нулем, получатель может попытаться
// продолжить чтение за пределами конца буфера,
// что приведет к неопределенному поведению.
SomePInvokeMethod(pData);
}
}
Чтобы решить эту проблему, используйте альтернативную сигнатуру p/invoke, которая принимает и указатель на данные, и длину, если это возможно. В противном случае, если получатель не имеет возможности принять отдельный аргумент длины, убедитесь, что исходные данные преобразованы в string перед пиннингом и передачей через границу p/invoke.
unsafe static extern void SomePInvokeMethod(char* pwszData);
unsafe static extern void SomePInvokeMethodWhichTakesLength(char* pwszData, uint cchData);
unsafe void CorrectPInvokeExample(ReadOnlySpan<char> data)
{
fixed (char* pData = data)
{
// OK: Поскольку получатель принимает явный аргумент
// длины, он сигнализирует нам, что он не ожидает,
// что указатель будет указывать на буфер, завершающийся нулем.
SomePInvokeMethodWhichTakesLength(pData, (uint)data.Length);
}
// Альтернативно, если получатель не принимает явный
// аргумент длины, используйте ReadOnlySpan<T>.ToString
// для преобразования данных в строку, завершающуюся нулем, перед
// пиннингом и отправкой через границу p/invoke.
fixed (char* pStr = data.ToString())
{
// OK: Строки гарантированно завершаются нулем.
SomePInvokeMethod(pStr);
}
}
17. Мутации строк
Строки в C# иммутабельны и любая попытка изменить их с помощью unsafe кода может привести к неопределенному поведению. Пример:
string s = "Hello";
fixed (char* p = s)
{
p[0] = '_';
}
// где-то в другом месте
Console.WriteLine("Hello"); // печатает "_ello" вместо "Hello"
Изменение интернированной строки (все строковые литералы таковыми и являются, но и не литералы могут быть интернированы по разным причинам) изменит значение для всех других использований. Даже без интернирования строк, запись в только что созданную строку должна быть заменена на более безопасный API String.Create:
// Плохо:
string s = new string('\n', 4); // неинтернированная строка
fixed (char* p = s)
{
// Копирование данных в только что созданную строку
}
// Хорошо:
string s = string.Create(4, state, (chr, state) =>
{
// Копирование данных в только что созданную строку
});
Рекомендации
❌ НЕ изменяйте строки. Используйте API
String.Createдля создания новой строки, если необходима сложная логика копирования. В противном случае используйте.ToString(),StringBuilder,new string(...)или синтаксис интерполяции строк.
18. Ручной IL код (например, System.Reflection.Emit и Mono.Cecil)
Вставка ручного IL кода (либо через System.Reflection.Emit, сторонние библиотеки, такие как Mono.Cecil, либо написание IL кода напрямую) по определению обходит все гарантии безопасности, предоставляемые C#.
Избегайте использования таких техник, т.к. по сути это самый опасный вид memory unsafe кода.
Рекомендации
❌ НЕ пишите IL код. С ним легко внести проблемы с безопасностью типов и т.п.. Как и другие техники динамической генерации кода, вставки IL также ломают AOT, если они не выполняется во время сборки.
✔️ ИСПОЛЬЗУЙТЕ Source Generators вместо этого, если возможно.
✔️ ПРЕДПОЧИТАЙТЕ
[UnsafeAccessor]вместо вставок IL для написания кода сериализации с низким оверхедом для приватных мемберов, если возникает такая необходимость.✔️ СОЗДАЙТЕ API Proposal в dotnet/runtime, если какой-то API отсутствует, и вы вынуждены использовать IL код вместо этого.
✔️ ИСПОЛЬЗУЙТЕ
ilverifyили подобные инструменты для проверки эмитированного IL кода, если вы должны использовать IL.
19. Неинициализированные локальные переменные [SkipLocalsInit] и Unsafe.SkipInit
[SkipLocalsInit] был введен в .NET 5.0, чтобы позволить JIT пропускать обязательное обнуление локальных переменных (в том числе stackalloc) в методах, либо для всего модуля. Эта фича часто использовалась, чтобы помочь JIT устранить избыточные инициализации нулями, например, для stackalloc. Однако это может привести к неопределенному поведению, если локальные переменные не инициализируются явно перед использованием. С недавними улучшениями в способности JIT устранять инициализации нулями и выполнять векторизацию, необходимость в [SkipLocalsInit] и Unsafe.SkipInit значительно снизилась.
Рекомендации
❌ НЕ используйте
[SkipLocalsInit]иUnsafe.SkipInit, если не наблюдается преимуществ в производительности в горячем коде или вы не уверены в рисках, которые они вносят.✔️ ИМЕЙТЕ ввиду что
GC.AllocateUninitializedArrayиArrayPool<T>.Shared.Rentи подобные API могут возвращать неинициализированные буферы.
20. ArrayPool.Shared и похожие пулы объектов
ArrayPool<T>.Shared — это общий пул массивов, используемый для снижения давления на GC в горячем коде. Он часто используется для выделения временных буферов для операций ввода-вывода или других короткоживущих сценариев. Хотя API прост и по своей сути не содержит unsafe функций, он может привести к багам use-after-free и double-free в C#. Пример:
var buffer = ArrayPool<byte>.Shared.Rent(1024);
_buffer = buffer; // объект буфера "убегает" из области видимости
Use(buffer);
ArrayPool<byte>.Shared.Return(buffer);
Любое использование _buffer после вызова Return является багом use-after-free. Этот минимальный пример легко заметить, но баг становится труднее обнаружить, когда Rent и Return находятся в разных областях видимости или методах.
Рекомендации
✔️ ДЕРЖИТЕ парные вызовы
RentиReturnв одном методе, если возможно, чтобы сузить область потенциальных багов.❌ НЕ используйте паттерн
try-finallyдля вызоваReturnв блокеfinally, если вы не уверены, что упавшая логика закончила использование буфера. Лучше бросить буфер, чем рисковать багом use-after-free из-за неожиданного раннегоReturn. Не возращенный в пул буффер будет освобожден сборщиком мусора, когда на него не останется ссылок и не приведет к утечке памяти.✔️ БУДЬТЕ в курсе, что подобные проблемы могут возникать с другими API пулинга или паттернами, такими как
Microsoft.Extensions.ObjectPool.ObjectPool<T>.✔️ УДОСТОВЕРЬТЕСЬ, что ваш код не сможет дважды вернуть один и тот же буфер в пул, что приведет к багу double-free. Такие багы могут быть трудными для обнаружения и могут привести к серьезным проблемам с безопасностью.
21. Преобразования bool <-> int
Хотя стандарт ECMA-335 определяет Boolean как 0-255, где true — это любое ненулевое значение, лучше избегать любых явных преобразований между целыми числами и булевыми значениями, чтобы избежать введения "денормализованных" значений, так как что-либо, отличное от 0 или 1, вероятно, приведет к ненадежному поведению.
// Плохо:
bool b = Unsafe.As<int, bool>(ref someInteger);
int i = Unsafe.As<bool, int>(ref someBool);
// Хорошо:
bool b = (byte)someInteger != 0;
int i = someBool ? 1 : 0;
JIT, присутствующий в ранних средах выполнения .NET, не полностью оптимизировал безопасную версию этой логики (тернарку), что приводило к тому, что разработчики использовали unsafe конструкции для преобразования между bool и int в путях кода, чувствительных к производительности. Это больше не так, и современные JIT-ы .NET способны эффективно оптимизировать безопасную версию.
Рекомендации
❌ НЕ пишите "branchless" преобразования между целыми числами и булевыми значениями с использованием unsafe кода.
✔️ ИСПОЛЬЗУЙТЕ тернарные операторы (или другую логику ветвления) вместо этого. Современные JIT-ы .NET будут эффективно их оптимизировать.
❌ НЕ читайте
boolс использованием unsafe API, таких какUnsafe.ReadUnalignedилиMemoryMarshal.Cast, если вы не доверяете входу. Рассмотрите использование тернарных операторов или сравнений на равенство вместо этого:
// Плохо:
bool b = Unsafe.ReadUnaligned<bool>(ref byteData);
// Хорошо:
bool b = byteData[0] != 0;
// Плохо:
ReadOnlySpan<byte> byteSpan = ReadDataFromNetwork();
bool[] boolArray = MemoryMarshal.Cast<byte, bool>(byteSpan).ToArray();
// Хорошо:
ReadOnlySpan<byte> byteSpan = ReadDataFromNetwork();
bool[] boolArray = new bool[byteSpan];
for (int i = 0; i < byteSpan.Length; i++) { boolArray[i] = byteSpan[i] != 0; }
22. Интероп (Interop)
Хотя большинство предложений в этом документе применимы и к сценариям интеропа, рекомендуется следовать руководству Native interoperability best practices. Кроме того, рассмотрите использование автоматически генерируемых оберток интеропа, таких как CsWin32 и CsWinRT. Это минимизирует необходимость написания ручного кода интеропа и снижает риск внесения проблем с безопасностью памяти.
23. Потокобезопасность (Thread safety)
Безопасность памяти и потокобезопасность — ортогональные понятия. Код может быть безопасным с точки зрения памяти, но все же содержать гонки данных (data races), разорванные чтения (torn reads) или баги видимости; и наоборот, код может быть потокобезопасным, но при этом вызывать неопределенное поведение через unsafe манипуляции с памятью. Для более широкого руководства см. Managed threading best practices и .NET Memory Model.
24. Unsafe код вокруг SIMD/Векторизации
См. Vectorization guidelines для получения более подробной информации.
В контексте unsafe кода важно помнить:
Операции SIMD имеют сложные требования для обеспечения гарантий атомарности (иногда они их вообще не предоставляют).
Большинство API загрузки/сохранения SIMD не предоставляют проверок границ.
25. Предупреждения компилятора
Как правило, компилятор C# не предоставляет обширной поддержки (предупреждения и анализаторы) касающиеся некорректного использования unsafe кода. Однако существуют некоторые существующие предупреждения, которые могут помочь обнаружить потенциальные проблемы, и их не следует игнорировать или подавлять без тщательного рассмотрения. Примеры:
nint ptr = 0;
unsafe
{
int local = 0;
ptr = (nint)(&local);
}
await Task.Delay(100);
Этот код кидает ворнинг CS9123 ("The '&' operator should not be used on parameters or local variables in async methods"), что подразумевает, что код, вероятно, некорректен.
Рекомендации
✔️ DO обращать внимание на предупреждения компилятора и исправлять первопричины вместо подавления.
❌ DON'T считать, что отсутствие предупреждений означает корректность. У компилятора C# ограниченная (или отсутствующая) поддержка для выявления неправильного unsafe-кода.
Полезные ссылки
What Every CLR Developer Must Know Before Writing Code — продвинутые темы про внутренности CoreCLR и GC.
Заключение
Большинство разработчиков никогда не столкнутся с проблемами описанными в этой статье если не используют unsafe код и не пишут интероп. Однако, это не защищает их от потенциальных проблем если одна из подключаемых библиотек использует unsafe код некорректно, т.к. такие баги очень сложно отловить и найти источник проблемы (например, double-free в ArrayPool внутри сторонней библиотеки). Если же вы используете unsafe код (unsafe/Unsafe/MemoryMarshal/Marshal и прочие) и что-то в этой статье показалось вам новым, лучше всего провести аудит всего unsafe кода в вашем проекте. Команда .NET Runtime планирует расширить понятие unsafe кода в будущих версиях .NET (например, многие API станут требовать unsafe контекст для использования).