Бөлісу құралы:


Лучшие практики для небезопасного кода

В этой статье содержатся подробные рекомендации по конкретным небезопасным шаблонам, рискам, которые они влечет за собой, и способам устранения этих рисков. Эти рекомендации предназначены для всех разработчиков, которые пишут или просматривают небезопасный код в C#. Другие языки .NET, такие как F# и Visual Basic, находятся вне области этой статьи, хотя некоторые рекомендации также могут применяться к этим языкам.

Glossary

  • AVE — исключение нарушения доступа.
  • Byref — управляемый указатель (ref T t), похожий на неуправляемый указатель, но отслеживаемый GC. Обычно указывает на произвольные части объектов или стека. Ссылка — это фактически управляемый указатель со смещением +0.
  • CVE — общедоступные уязвимости кибербезопасности.
  • JIT — компилятор точно вовремя (RyuJIT в CoreCLR и NativeAOT).
  • PGO — оптимизация с помощью профиля.
  • Неуправляемый указатель (или необработанный указатель) — указатель (T* p), указывающий на произвольное расположение памяти и не управляемый или отслеживаемый GC.

Другие термины см. в глоссарии среды выполнения .NET.

Распространенные ненадежные шаблоны

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

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

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

Классический пример повреждения кучи возникает, когда GC теряет отслеживание ссылки на объект или обрабатывает недопустимые указатели как ссылки на кучу. Это часто приводит к недетерминированным сбоям или повреждению памяти. Ошибки повреждения кучи особенно сложны для диагностики и воспроизведения, так как:

  • Эти проблемы могут оставаться скрытыми в течение длительного времени и только проявляются после изменения несвязанного кода или обновления среды выполнения.
  • Они часто требуют того, чтобы их воспроизвести с точностью во времени, например, когда GC прерывает выполнение в конкретной точке программы и запускает сжатие кучи, что является редким и непредсказуемым событием.

В следующих разделах описываются распространенные небезопасные шаблоны с рекомендациями ✔️ DO и ❌ DON'T.

1. Неконтролируемые управляемые указатели (Unsafe.AsPointer и подобные)

Невозможно преобразовать управляемый (отслеживаемый) указатель в неуправляемый (неотслеживаемый) указатель в безопасной среде C#. Когда возникает такая необходимость, может возникнуть соблазн использовать Unsafe.AsPointer<T>(T) , чтобы избежать накладных расходов на fixed оператор. Хотя существуют допустимые варианты использования, он представляет риск создания неуправляемых указателей на перемещаемые объекты. Пример:

unsafe void UnreliableCode(ref int x)
{
    int* nativePointer = (int*)Unsafe.AsPointer(ref x);
    nativePointer[0] = 42;
}

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

unsafe void UnreliableCode(ref int x)
{
    int* nativePointer = (int*)Unsafe.AsPointer(ref x);
    // <-- GC happens here between the two lines of code and updates `x` to point to a new location.
    // However, `nativePointer` still points to the old location as it's not reported to the GC
    
    nativePointer[0] = 42; // Potentially corrupting write, access violation, or other issue.
}

После возобновления выполнения метода GC он записывает 42 в старое расположение x, которое может привести к неожиданному исключению, общему повреждению глобального состояния или завершению процесса через нарушение доступа.

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

unsafe void ReliableCode(ref int x)
{
    fixed (int* nativePointer = &x) // `x` cannot be relocated for the duration of this block.
    {
        nativePointer[0] = 42;
    }
}

Recommendations

  1. ❌ Не используйте ref X аргументы с неявным контрактом, который X всегда выделяется в стеке, закреплён или не может быть перемещён сборщиком мусора (GC). То же самое относится к обычным объектам и диапазонам — не вводит неясные контракты на основе вызывающего объекта о времени существования в сигнатурах методов. Рекомендуется вместо этого принимать аргумент структура ref или изменить аргумент на необработанный тип указателя (X*).
  2. ❌ НЕ используйте указатель из Unsafe.AsPointer<T>(T), если он может существовать дольше исходного объекта, на который ссылается. В соответствии с документацией ПО API вызывающий объект Unsafe.AsPointer<T>(T) должен гарантировать, что GC не может переместить ссылку. Убедитесь, что четко видно кодовым рецензентам, что вызывающий сторона выполнил это предварительное условие.
  3. ✔️ Используйте GCHandle или fixed для создания явных ограничений вместо Unsafe.AsPointer<T>(T), чтобы определить области для неуправляемых указателей и гарантировать, что объект всегда закреплён.
  4. ✔️ Используйте неуправляемые указатели (с fixed) вместо байтов, когда необходимо выровнять массив на определенную границу. Это гарантирует, что GC не переместит объект и отменяет какие-либо предположения выравнивания, на которые может полагаться ваша логика.

2. Открытие указателей за пределами fixed контекста

Хотя ключевое слово фиксированное определяет область для указателя, полученного из закрепленного объекта, этот указатель все равно может выйти за пределы fixed области и вызывать ошибки, поскольку C# не предоставляет никакой защиты владения и жизненного цикла для него. Типичным примером является следующий фрагмент кода:

unsafe int* GetPointerToArray(int[] array)
{
    fixed (int* pArray = array)
    {
        _ptrField = pArray; // Bug!

        Method(pArray);     // Bug if `Method` allows `pArray` to escape,
                            // perhaps by assigning it to a field.

        return pArray;      // Bug!

        // And other ways to escape the scope.
    }
}

В этом примере массив закреплён правильно с помощью fixed ключевого слова (гарантируя, что GC не может переместить его в fixed блоке), но указатель используется за пределами fixed блока. При этом создается висячий указатель, разыменовывание которого приведет к неопределённому поведению.

Recommendations

  1. ✔️ Убедитесь, что указатели в fixed блоках не покидают определенную область.
  2. ✔️ Рекомендуется использовать безопасные примитивы низкого уровня со встроенным анализом escape-адресов, например ref struct в C#. Дополнительные сведения см. в разделе "Улучшения структуры низкого уровня".

3. Внутренние сведения о реализации среды выполнения и библиотек

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

Recommendations

  1. ❌ Не изменяйте или не считывайте какие-либо части заголовка объекта.

    • Заголовки объектов могут отличаться в разных средах выполнения.
    • В CoreCLR невозможно обеспечить безопасный доступ к заголовку объекта без предварительного закрепления объекта.
    • Никогда не изменяйте тип объекта, изменив указатель MethodTable.
  2. ❌ Не сохраняйте данные в дополнительных байтах объекта. Не предполагайте, что содержимое области заполнения будет сохранено или что область заполнения всегда инициализируется нулями по умолчанию.

  3. ❌ Не делайте предположения о размерах и смещениях ничего другого, кроме примитивов и структур с последовательным или эксплицитным макетом. Даже тогда существуют исключения, такие как при использовании дескрипторов GC.

  4. ❌ Не вызывайте непубличные методы, не обращайтесь к непубличным полям и не изменяйте поля только для чтения в типах BCL с помощью рефлексии или небезопасного кода.

  5. ❌ Не предполагайте, что какой-либо недоступный член в BCL всегда будет присутствовать или иметь конкретную структуру. Команда .NET иногда изменяет или удаляет неопубликованные API в выпусках обслуживания.

  6. ❌ Не изменяйте static readonly поля с помощью отражения или небезопасного кода, так как они считаются константой. Например, RyuJIT обычно встраивает их как явные константы.

  7. ❌ Не просто предполага́йте, что ссылка не перемещаема. Это руководство относится к строковым и UTF-8 ("..."u8) литералам, статическим полям, полям RVA, объектам LOH и т. д.

    • Это сведения о реализации среды выполнения, которые могут храниться для некоторых сред выполнения, но не для других.
    • Неуправляемые указатели на такие объекты могут не препятствовать выгрузке сборок, что приводит к висячим указателям. Используйте fixed области для обеспечения правильности.
    ReadOnlySpan<int> rva = [1, 2, 4, 4];
    int* p = (int*)Unsafe.AsPointer(ref MemoryMarshal.GetReference(rva));
    // Bug! The assembly containing the RVA field might be unloaded at this point
    // and `p` becomes a dangling pointer.
    int value = p[0]; // Access violation or other issue.
    
  8. ❌ Не создавайте код, который зависит от сведений о реализации конкретной среды выполнения.

4. Недопустимые управляемые указатели (даже если они никогда не используются для разыменования)

Некоторые категории кода в конечном итоге опираются на манипуляцию указателем и арифметику, и такой код часто имеет выбор между использованием неуправляемых указателей (T* p) и управляемых указателей (ref T p). Эти указатели можно управлять произвольно, например с помощью операторов на неуправляемых указателях (p++) и с помощью Unsafe методов управляемых указателей (p = ref Unsafe.Add(ref p, 1)). Оба считаются "небезопасным кодом", и можно создать ненадежные шаблоны с обоими. Однако для некоторых алгоритмов существует большая вероятность случайного создания небезопасных для GC шаблонов при работе с управляемыми указателями. Так как неуправляемые указатели не отслеживаются сборщиком мусора, значение, которое они содержат, имеет значение только при разыменовании в коде разработчика. В отличие от этого, значение управляемого указателя имеет значение не только при разоменовании кода разработчика, но и при его изучении в GC. Таким образом, разработчик может создавать недопустимые неуправляемые указатели без последствий, пока они не разыменовываются, но создание любого недопустимого управляемого указателя является ошибкой. Пример:

unsafe void UnmanagedPointers(int[] array)
{
    fixed (int* p = array)
    {
        int* invalidPtr = p - 1000;
        // invalidPtr is pointing to an undefined location in memory
        // it's ok as long as it's not dereferenced.

        int* validPtr = invalidPtr + 1000; // Returning back to the original location
        *validPtr = 42; // OK
    }
}

Однако аналогичный код с помощью byrefs (управляемых указателей) недопустим.

void ManagedPointers_Incorrect(int[] array)
{
    ref int invalidPtr = ref Unsafe.Add(ref array[0], -1000); // Already a bug!
    ref int validPtr = ref Unsafe.Add(ref invalidPtr, 1000);
    validPtr = 42; // possibly corrupting write
}

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

Recommendations

  1. ❌ Не создавайте недопустимые управляемые указатели, даже если они не разыменовываются или находятся внутри никогда не выполняемых путей кода.
  2. ✔️ Используйте закрепленные неуправляемые указатели, если алгоритм требует таких манипуляций.

5. Приведение типов типа reinterpret

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

struct S1
{
    string a;
    nint b;
}

struct S2
{
    string a;
    string b;
}

S1 s1 = ...
S2 s2 = Unsafe.As<S1, S2>(ref s1); // Bug! A random nint value becomes a reference reported to the GC.

Хотя макет может быть аналогичным, следует проявлять осторожность при использовании ссылок на GC (поля).

Recommendations

  1. ❌ Не преобразовывайте структуры в классы и наоборот.
  2. ❌ Не используйте Unsafe.As для преобразования структуры в структуру или класса в класс, если вы не уверены, что приведение является допустимым. Дополнительные сведения см. в разделе "Примечания" документации ПО API.Unsafe.As
  3. Предпочитайте более безопасное копирование по полям, использование внешних библиотек, таких как AutoMapper, или генераторов исходного кода для таких преобразований.
  4. Предпочитайте Unsafe.BitCast вместо Unsafe.As, так как BitCast предоставляет базовые проверки использования. Обратите внимание, что эти проверки не предоставляют полных гарантий правильности, то есть по-прежнему BitCast считается небезопасным API.

6. Обход барьера записи и неатомарные операции на ссылки GC

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

unsafe void InvalidCode1(object[] arr1, object[] arr2)
{
    fixed (object* p1 = arr1)
    fixed (object* p2 = arr2)
    {
        nint* ptr1 = (nint*)p1;
        nint* ptr2 = (nint*)p2;

        // Bug! We're assigning a GC pointer to a heap location
        // without going through the Write Barrier.
        // Moreover, we also bypass array covariance checks.
        *ptr1 = *ptr2;
    }
}

Аналогичным образом, следующий код с управляемыми указателями также ненадежн:

struct StructWithGcFields
{
    object a;
    int b;
}

void InvalidCode2(ref StructWithGcFields dst, ref StructWithGcFields src)
{
    // It's already a bad idea to cast a struct with GC fields to `ref byte`, etc.
    ref byte dstBytes = ref Unsafe.As<StructWithGcFields, byte>(ref dst);
    ref byte srcBytes = ref Unsafe.As<StructWithGcFields, byte>(ref src);

    // Bug! Bypasses the Write Barrier. Also, non-atomic writes/reads for GC references.
    Unsafe.CopyBlockUnaligned(
        ref dstBytes, ref srcBytes, (uint)Unsafe.SizeOf<StructWithGcFields>());

    // Bug! Same as above.
    Vector128.LoadUnsafe(ref srcBytes).StoreUnsafe(ref dstBytes);
}

Recommendations

  1. ❌ Не используйте не-атомарные операции для ссылок на GC (например, операции SIMD часто не предоставляют их).
  2. ❌ Не используйте неуправляемые указатели для хранения ссылок на сборку в кучи (опустив барьер записи).

7. Предположения о времени существования объектов (финализаторы, GC.KeepAlive)

Избегайте предположений о времени существования объектов с точки зрения GC. В частности, не предполагайте, что объект по-прежнему жив, если он не может быть. Время существования объектов может различаться в разных средах выполнения или даже между разными уровнями одного и того же метода (Tier0 и Tier1 в RyuJIT). Финализаторы — это распространенный сценарий, в котором такие предположения могут быть неверными.

public class MyClassWithBadCode
{
    public IntPtr _handle;

    public void DoWork() => DoSomeWork(_handle); // A use-after-free bug!

    ~MyClassWithBadCode() => DestroyHandle(_handle);
}

// Example usage:
var obj = new MyClassWithBadCode()
obj.DoWork();

В этом примере DestroyHandle можно вызвать до того, как DoWork завершится или даже до его начала. Поэтому важно не предположить, что объекты, такие как this, будут оставаться в живых до конца метода.

void DoWork()
{
    // A pseudo-code of what might happen under the hood:

    IntPtr reg = this._handle;
    // 'this' object is no longer alive at this point.

    // <-- GC interrupts here, collects the 'this' object, and triggers its finalizer.
    // DestroyHandle(_handle) is called.

    // Bug! 'reg' is now a dangling pointer.
    DoSomeWork(reg);

    // You can resolve the issue and force 'this' to be kept alive (thus ensuring the
    // finalizer will not run) by uncommenting the line below:
    // GC.KeepAlive(this);
}

Поэтому рекомендуется явно продлить время существования объектов, использующих GC.KeepAlive(Object) или SafeHandle.

Другим классическим экземпляром этой проблемы является Marshal.GetFunctionPointerForDelegate<TDelegate>(TDelegate) API:

var callback = new NativeCallback(OnCallback);

// Convert delegate to function pointer
IntPtr fnPtr = Marshal.GetFunctionPointerForDelegate(callback);

// Bug! The delegate might be collected by the GC here.
// It should be kept alive until the native code is done with it.

RegisterCallback(fnPtr);

Recommendations

  1. ❌ Не делайте предположения о времени существования объекта. Например, никогда не предполагается, что this всегда жив до конца метода.
  2. ✔️ Используйте SafeHandle для управления собственными ресурсами.
  3. ✔️ Используйте GC.KeepAlive(Object) для продления времени существования объектов при необходимости.

8. Межпотоковый доступ к локальным переменным

Доступ к локальным переменным из другого потока обычно считается плохой практикой. Однако оно становится явным образом неопределенным при использовании управляемых ссылок, как описано в модели памяти .NET.

Пример. Структуру, содержащую ссылки на GC, может быть ноль или перезаписана непотокобезопасным способом в no-GC регионе, а другой поток считывает его, что приводит к неопределенному поведению.

Recommendations

  1. ❌ Не обращаться к локальным ресурсам между потоками (особенно если они содержат ссылки на GC).
  2. ✔️ Вместо этого используйте кучу или неуправляемую память (например, NativeMemory.Alloc).

Удаление небезопасной проверки границ

В C#все идиоматические доступы к памяти включают проверки границ по умолчанию. Компилятор JIT может удалить эти проверки, если это может доказать, что они ненужны, как в приведенном ниже примере.

int SumAllElements(int[] array)
{
    int sum = 0;
    for (int i = 0; i < array.Length; i++)
    {
        // The JIT knows that within this loop body, i >= 0 and i < array.Length.
        // The JIT can reason that its own bounds check would be duplicative and
        // unnecessary, so it opts not to emit the bounds check into the final
        // generated code.
        sum += array[i];
    }
}

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

Рассмотрим пример следующего метода.

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

Чтобы сократить затраты на проверку в горячем коде, вы можете быть соблазнены использовать API, эквивалентные небезопасным (Unsafe и MemoryMarshal):

int FetchAnElement_Unsafe1(int[] array, int index)
{
    // DANGER: The access below is not bounds-checked and could cause an access violation.
    return Unsafe.Add(ref MemoryMarshal.GetArrayDataReference(array), index);
}

Или используйте закрепление и необработанные указатели:

unsafe int FetchAnElement_Unsafe2(int[] array, int index)
{
    fixed (int* pArray = array)
    {
        // DANGER: The access below is not bounds-checked and could cause an access violation.
        return pArray[index];
    }
}

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

Recommendations

  1. ✔️ Убедитесь, что последняя версия .NET по-прежнему не может устранить проверку границ. Если возможно, переписать с использованием безопасного кода. В противном случае отправьте проблему в RyuJIT. Используйте эту проблему отслеживания как хорошую отправную точку.
  2. ✔️ Измеряйте реальное воздействие на производительность. Если прирост производительности незначительный или код не оказывается востребованным за пределами тривиального микробенчмарка, переписывайте с использованием безопасного кода.
  3. ✔️ Добавьте дополнительные указания для JIT, например ручные проверки границ перед циклами и сохранение полей в локальных переменных, так как .NET Memory Model может консервативно запретить JIT удалять проверки границ в некоторых сценариях.
  4. ✔️ Код DO Guard с Debug.Assert границами проверяет, необходим ли небезопасный код. Рассмотрим приведенный ниже пример.
Debug.Assert(array is not null);
Debug.Assert((index >= 0) && (index < array.Length));
// Unsafe code here

Вы даже можете рефакторить эти проверки в многократно используемые вспомогательные методы.

[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. Объединение доступа к памяти

Вы можете заманчиво использовать небезопасный код для объединения доступа к памяти для повышения производительности. Классический пример — следующий код для записи "False" в массив char:

// Naive implementation
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 coalesced implementation
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); // Write "Fals" (4 chars)
    dstSpan[4] = 'e';                                                             // Write "e" (1 char)
}

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

Recommendations

  1. ✔️ Следует предпочитать идиоматичный безопасный код вместо небезопасного для объединения доступа к памяти.
    • Предпочтительны Span<T>.CopyTo и Span<T>.TryCopyTo для копирования данных.
    • Предпочитайте String.Equals и Span<T>.SequenceEqual для сравнения данных (даже при использовании StringComparer.OrdinalIgnoreCase).
    • Предпочитайте использовать Span<T>.Fill для заполнения данных и Span<T>.Clear для их очистки.
    • Помните, что операции записи и чтения для каждого элемента или поля могут быть автоматически объединены JIT.
  2. ✔️ Подайте обращение в dotnet/runtime, если ваш код понятен, но вы заметили, что он не оптимизирован должным образом.
  3. ❌ Не объединяйте доступ к памяти вручную, если вы не уверены в рисках доступа к неправильным адресам памяти, гарантиях атомарности или связанных с этим преимуществах производительности.

11. Неуправляемый доступ к памяти

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

Например, рассмотрим сценарий, в котором вы очищаете два элемента массива одновременно:

uint[] arr = _arr;
arr[i + 0] = 0;
arr[i + 1] = 0;

Предположим, предыдущие значения в этих локациях были uint.MaxValue и 0xFFFFFFFF оба. Модель памяти .NET гарантирует, что обе записи являются атомарными, поэтому все остальные потоки в процессе будут наблюдать только новое значение 0 или старое значение 0xFFFFFFFF, никогда не наблюдаются 'разорванные' значения, как 0xFFFF0000.

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

Еще один риск нерасположенного доступа к памяти — это возможность сбоя в работе приложения в определенных сценариях. Хотя некоторые среды выполнения .NET полагаются на ОС для коррекции неправильно выровненных доступов, существуют некоторые сценарии на некоторых платформах, где неправильно выровненный доступ может привести к DataMisalignedException (или SEHException). Ниже приведены некоторые примеры.

  • Interlocked операции над некорректно выровненной памятью на некоторых платформах (например).
  • Неправильные операции с плавающей запятой в ARM.
  • Доступ к специальной памяти устройства с особыми требованиями по выравниванию (не полностью поддерживается .NET).

Recommendations

  1. ❌ Не используйте неуправляемый доступ к памяти в алгоритмах без блокировки и других сценариях, где атомарность важна.
  2. ✔️ При необходимости выравнивайте данные вручную, но учитывайте, что GC может перемещать объекты в любое время, эффективно изменяя выравнивание динамически. Это особенно важно для различных StoreAligned/LoadAligned API в SIMD.
  3. ✔️ Используйте явные невыравненные API чтения и записи, Unsafe.ReadUnaligned/Unsafe.WriteUnaligned а не выровненные, такие как Unsafe.Read<T>(Void*)/Unsafe.Write<T>(Void*, T) или Unsafe.As<TFrom,TTo>(TFrom), если данные могут быть неправильно настроены.
  4. ✔️ Имейте в виду, что различные API обработки памяти, такие как Span<T>.CopyTo(Span<T>) также не предоставляют гарантии атомарности.
  5. ✔️ Ознакомьтесь с документацией по модели памяти .NET (см. ссылки) для получения дополнительных сведений о гарантиях атомарности.
  6. ✔️ Измеряйте производительность на всех целевых платформах, так как некоторые платформы накладывают значительное наказание за производительность при невыравненных доступах к памяти. Вы можете найти, что на этих платформах наивный код работает лучше, чем умный код.
  7. ✔️ Имейте в виду, что существуют сценарии и платформы, в которых неуправляемый доступ к памяти может привести к исключению.

12. Двоичная (де)сериализация структур с выравниванием или неблиттабельными элементами

Будьте осторожны при использовании различных API, подобных сериализации, для копирования структур в массивы байтов или чтения из них. Если в структуре содержатся заполнители или неблиттабельные элементы (например, bool или поля GC), то классические небезопасные операции с памятью, такие как Fill, CopyTo и SequenceEqual, могут непреднамеренно копировать конфиденциальные данные из стека в заполнители или обрабатывать мусорные данные как значительные во время сравнения, создавая редко воспроизводимые ошибки. Распространенный антипаттерн может выглядеть следующим образом:

T UnreliableDeserialization<TObject>(ReadOnlySpan<byte> data) where TObject : unmanaged
{
    return MemoryMarshal.Read<TObject>(data); // or Unsafe.ReadUnaligned
    // BUG! TObject : unmanaged doesn't guarantee that TObject is blittable and contains no paddings.
}

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

Recommendations

  1. ❌ Никогда не используйте небезопасный код для копирования, загрузки или сравнения структур с заполнением или неуправляемыми членами. Нагрузки из ненадежных входных данных проблематичны даже для базовых типов, таких как bool или decimal. В то же время хранилища данных могут случайно сериализовать конфиденциальную информацию из стека, попадающую в промежутки и заполнения структуры.
  2. ❌ Не полагаться на T : unmanaged ограничения, RuntimeHelpers.IsReferenceOrContainsReferences, или аналогичные API, чтобы гарантировать безопасность выполнения побитовых операций с универсальным типом. На момент написания этих рекомендаций нет надежного программматического способа определить, законно ли выполня́ть произвольные побитовые операции на заданном типе.
    • Если необходимо выполнить такую побитовую манипуляцию, сделайте это только с тем жестко закодированным списком типов и учитывайте порядок байт текущей машины:
      • Примитивные целочисленные типыByte, SByteInt16, UInt16, Int32, , UInt32Int64и UInt64;
      • Поддерживается Enum одним из перечисленных выше примитивных целочисленных типов;
      • Char, Int128, UInt128, HalfSingleDoubleIntPtrUIntPtr.
  3. ✔️ Вместо этого используйте пошаговую загрузку/сохранение и (де)сериализацию. Рекомендуется использовать популярные и безопасные библиотеки для сериализации и десериализации данных.

13. Нулевые управляемые ссылки

Как правило, byrefs (управляемые указатели) редко имеют значение NULL, и единственный безопасный способ создать значение NULL byref на сегодняшний день — это инициализировать ref struct с помощью default. Затем все его ref поля являются указателями, управляемыми nullми:

RefStructWithRefField s = default;
ref byte nullRef = ref s.refFld;

Однако существует несколько небезопасных способов создания null byrefs. Ниже приведены некоторые примеры:

// Null byref by calling Unsafe.NullRef directly:
ref object obj = ref Unsafe.NullRef<object>();

// Null byref by turning a null unmanaged pointer into a null managed pointer:
ref object obj = ref Unsafe.AsRef<object>((void*)0);

Риск возникновения проблем безопасности памяти низкий, и любая попытка разыменовать нулевой byref приведет к четко определенной NullReferenceException. Однако компилятор C# предполагает , что разыменовывание byref всегда выполняется успешно и не создает наблюдаемый побочный эффект. Таким образом, это допустимая оптимизация — опустить любое разыменование указателя, результат которого сразу отбрасывается. См. статью dotnet/runtime#98681это связанное примечание) для примера исправленной ошибки в .NET, в которой код библиотеки неверно полагался на разыменование, вызывая побочный эффект, не зная, что компилятор C# фактически производил короткое замыкание задуманной логики.

Recommendations

  1. ❌ Не создавайте нулевые ссылки (byref) в C#, если это не обязательно. Рекомендуется использовать обычные управляемые ссылки, шаблон Null Object или пустые диапазоны.
  2. ❌ Не отменяйте результат разыменования byref, так как он может быть оптимизирован и привести к потенциальным ошибкам.

14. stackalloc

stackalloc исторически использовался для создания небольших массивов, не выводимых из области действия, на стеке, уменьшая давление GC. В будущем анализ выходов JIT может начать оптимизировать выделения памяти без выхода, преобразовывая массивы в объекты стека, что может сделать stackalloc избыточным. До тех пор stackalloc остается полезным для выделения небольших буферов в стеке. Для больших или переполняющихся буферов часто используется совместно с ArrayPool<T>.

Recommendations

  1. ✔️ Всегда используйте stackalloc, потребляя ReadOnlySpan<T>/Span<T> в левой части выражения для проверки границ.

    // Good:
    Span<int> s = stackalloc int[10];
    s[2] = 0;  // Bounds check is eliminated by JIT for this write.
    s[42] = 0; // IndexOutOfRangeException is thrown
    
    // Bad:
    int* s = stackalloc int[10];
    s[2] = 0;
    s[42] = 0; // Out of bounds write, undefined behavior.
    
  2. ❌ Не используйте stackalloc внутри циклов. Пространство стека не освобождается до возврата метода, поэтому включение stackalloc внутри цикла может привести к прекращению процесса из-за переполнения стека.

  3. ❌ Не используйте большие длины для stackalloc. Например, 1024 байта можно считать разумной верхней границой.

  4. ✔️ Проверьте диапазон значений переменных, используемых в качестве длин stackalloc.

    void ProblematicCode(int length)
    {
        Span<int> s = stackalloc int[length]; // Bad practice: check the range of `length`!
        Consume(s);
    }
    

    Исправлена версия:

    void BetterCode(int length)
    {
        // The "throw if length < 0" check below is important, as attempting to stackalloc a negative
        // length will result in process termination.
        ArgumentOutOfRangeException.ThrowIfLessThan(length, 0, nameof(length));
        Span<int> s = length <= 256 ? stackalloc int[length] : new int[length];
        // Or:
        // Span<int> s = length <= 256 ? stackalloc int[256] : new int[length];
        // Which performs a faster zeroing of the stackalloc, but potentially consumes more stack space.
        Consume(s);
    }
    
  5. ✔️ Используйте современные функции C#, такие как литералы коллекции (Span<int> s = [1, 2, 3];), params Span<T>и встроенные массивы, чтобы избежать ручного управления памятью, когда это возможно.

15. Буферы фиксированного размера

Буферы фиксированного размера были полезны для сценариев взаимодействия с источниками данных из других языков или платформ. Затем они были заменены более безопасными и удобными встроенными массивами. Пример буфера фиксированного размера (требуется unsafe контекст) является следующим фрагментом кода:

public struct MyStruct
{
    public unsafe fixed byte data[8];
    // Some other fields
}

MyStruct m = new();
ms.data[10] = 0; // Out-of-bounds write, undefined behavior.

Современная и безопасная альтернатива — встроенные массивы:

[System.Runtime.CompilerServices.InlineArray(8)]
public struct Buffer
{
    private int _element0; // can be generic
}

public struct MyStruct
{
    public Buffer buffer;
    // Some other fields
}

MyStruct ms = new();
ms.buffer[i] = 0; // Runtime performs a bounds check on index 'i'; could throw IndexOutOfRangeException.
ms.buffer[7] = 0; // Bounds check elided; index is known to be in range.
ms.buffer[10] = 0; // Compiler knows this is out of range and produces compiler error CS9166.

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

Recommendations

  1. ✔️ Предпочтительно заменять буферы фиксированного размера встроенными массивами или атрибутами маршалинга IL, если это возможно.

16. Передача смежных данных в качестве указателей + длины (или использование нулевого завершения)

Избегайте определения API, которые принимают неуправляемые или управляемые указатели на континуальные данные. Вместо этого используйте Span<T> или ReadOnlySpan<T>:

// Poor API designs:
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

// Better API designs:
void Consume(Span<byte> data);
void Consume(Memory<byte> data);
void Consume(byte[] data);
void Consume(byte[] data, int offset, int length);

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

Recommendations

  1. ❌ Не предоставляйте методы, аргументы которых являются типами T* указателей (неуправляемые указатели или управляемые указатели ref T), когда эти аргументы предназначены для представления буферов. Используйте безопасные типы буферов, например Span<T> или ReadOnlySpan<T> вместо этого.

  2. ❌ Не используйте неявные контракты для аргументов byref, такие как требование, чтобы все вызывающие выделяли память в стеке. Если такой контракт необходим, рассмотрите возможность использования ref struct.

  3. ❌ Не предполагайте, что буферы завершаются нулевым завершением, если сценарий явно не документирует, что это допустимое предположение. Например, несмотря на то, что .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: Strings are always guaranteed to have a null terminator.
            // This will assign the value '\0' to the variable 'ch'.
            char ch = pStr[str.Length];
        }
    
        fixed (char* pSpan = span)
        {
            // INCORRECT: Spans aren't guaranteed to be null-terminated.
            // This could throw, assign garbage data to 'ch', or cause an AV and crash.
            char ch = pSpan[span.Length];
        }
    
        fixed (char* pArray = array)
        {
            // INCORRECT: Arrays aren't guaranteed to be null-terminated.
            // This could throw, assign garbage data to 'ch', or cause an AV and crash.
            char ch = pArray[array.Length];
        }
    }
    
  4. ❌ НЕ передавайте фиксированный Span<char> или ReadOnlySpan<char> через границу вызова p/invoke, если вы также не передали явное значение длины. В противном случае код на другой стороне границы p/invoke может неправильно поверить, что буфер завершается null.

unsafe static extern void SomePInvokeMethod(char* pwszData);

unsafe void IncorrectPInvokeExample(ReadOnlySpan<char> data)
{
    fixed (char* pData = data)
    {
        // INCORRECT: Since 'data' is a span and is not guaranteed to be null-terminated,
        // the receiver might attempt to keep reading beyond the end of the buffer,
        // resulting in undefined behavior.
        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: Since the receiver accepts an explicit length argument, they're signaling
        // to us that they don't expect the pointer to point to a null-terminated buffer.
        SomePInvokeMethodWhichTakesLength(pData, (uint)data.Length);
    }
    
    // Alternatively, if the receiver doesn't accept an explicit length argument, use
    // ReadOnlySpan<T>.ToString to convert the data to a null-terminated string before
    // pinning it and sending it across the p/invoke boundary.
    
    fixed (char* pStr = data.ToString())
    {
        // OK: Strings are guaranteed to be null-terminated.
        SomePInvokeMethod(pStr);
    }
}

17. Изменения строк

Строки в C# являются неизменяемыми по проектированию, и любая попытка изменить их с помощью небезопасного кода может привести к неопределенному поведению. Пример:

string s = "Hello";
fixed (char* p = s)
{
    p[0] = '_';
}
Console.WriteLine("Hello"); // prints "_ello" instead of "Hello"

Изменение интернированной строки (большинство из строковых литералов) изменит значение для всех остальных использований. Даже без интернинга строк запись в только что созданную строку должна быть заменена более безопасным String.Create API:

// Bad:
string s = new string('\n', 4); // non-interned string
fixed (char* p = s)
{
    // Copy data into the newly created string
}

// Good:
string s = string.Create(4, state, (chr, state) =>
{
    // Copy data into the newly created string
});

Recommendations

  1. ❌ Не изменяйте строки. String.Create Используйте API для создания новой строки, если требуется сложная логика копирования. В противном случае используйте .ToString(), StringBuilder, new string(...) или синтаксис интерполяции строк.

18. Необработанный код IL (например, System.Reflection.Emit и Mono.Cecil)

Генерация необработанного IL (либо через System.Reflection.Emit, сторонние библиотеки, такие как Mono.Cecil, или написание кода IL напрямую) по определению обходит все гарантии безопасности памяти, предоставляемые C#. Избегайте использования таких методов, если это не обязательно.

Recommendations

  1. ❌ Не генерировать сырой IL-код, поскольку это не сопровождается защитными механизмами, и легко приводить к проблемам с безопасностью типов и другим вопросам. Как и другие методы динамической генерации кода, вывод необработанного IL также не совместим с AOT, если он не производится на этапе сборки.
  2. ✔️ По возможности используйте генераторы исходного кода.
  3. ✔️ Предпочитайте [UnsafeAccessor] вместо использования необработанного IL для написания кода сериализации с минимальными затратами для закрытых членов, если возникнет необходимость.
  4. ✔️ DO подать предложение об API на dotnet/runtime, если какой-то API отсутствует и вы вынуждены использовать необработанный IL-код.
  5. ✔️ Используйте ilverify или аналогичные средства для проверки издаваемого кода IL, если необходимо использовать необработанный IL.

19. Неинициализированные локальные переменные [SkipLocalsInit] и Unsafe.SkipInit

[SkipLocalsInit] был представлен в .NET 5.0, чтобы JIT мог пропускать обнуление локальных переменных в методах, как на уровне отдельных методов, так и на уровне всего модуля. Эта возможность часто использовалась для помощи компилятору Just-In-Time в устранении избыточных нулевых инициализаций, таких как те для stackalloc. Однако это может привести к неопределенному поведению, если локальные переменные не инициализированы явно перед использованием. Благодаря недавним улучшениям в способности JIT устранять нулевые инициализации и выполнять векторизацию, необходимость в [SkipLocalsInit] и Unsafe.SkipInit значительно снизилась.

Recommendations

  1. ❌ Не используйте [SkipLocalsInit] и Unsafe.SkipInit если нет преимуществ производительности в горячем коде, или вы не уверены в рисках, которые они представляют.
  2. ✔️ Код DO оборонительно при использовании API,как GC.AllocateUninitializedArray и ArrayPool<T>.Shared.Rent, который может аналогичным образом возвращать неинициализированные буферы.

20. ArrayPool<T>.Shared и аналогичные API для пула ресурсов

ArrayPool<T>.Shared — это общий пул массивов, используемых для снижения давления GC в горячем коде. Он часто используется для выделения временных буферов для операций ввода-вывода или других кратковременных сценариев. Хотя API прост и не содержит небезопасных функций, он может привести к ошибкам использования после освобождения памяти в C#. Пример:

var buffer = ArrayPool<byte>.Shared.Rent(1024);
_buffer = buffer; // buffer object escapes the scope
Use(buffer);
ArrayPool<byte>.Shared.Return(buffer);

Любое использование _buffer после вызова Return является ошибкой использования после освобождения памяти. Этот минимальный пример легко обнаружить, но ошибка становится труднее обнаружить, когда Rent и Return находится в разных областях или методах.

Recommendations

  1. ✔️ При возможности сохраните соответствующие вызовы к Rent и Return в пределах одного метода, чтобы сузить область потенциальных ошибок.
  2. ❌ Не используйте try-finally шаблон для вызова Return в finally блоке, если вы не уверены, что неудавшаяся логика завершила использование буфера. Лучше отказаться от буфера, чем рисковать ошибкой использования после освобождения памяти из-за неожиданной ранней Return.
  3. ✔️ Имейте в виду, что аналогичные проблемы могут возникнуть с другими API-интерфейсами пула или шаблонами, например ObjectPool<T>.

21. bool<—>int преобразования

Хотя стандарт ECMA-335 определяет логическое значение как 0-255, где true любое ненулевое значение, лучше избежать явных преобразований между целыми числами и логическими значениями, чтобы избежать введения "денормализованных" значений, как что-либо, отличное от 0 или 1, скорее всего, приводит к ненадежному поведению.

// Bad:
bool b = Unsafe.As<int, bool>(ref someInteger);
int i = Unsafe.As<bool, int>(ref someBool);

// Good:
bool b = (byte)someInteger != 0;
int i = someBool ? 1 : 0;

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

Recommendations

  1. ❌ Не используйте небезопасный код для преобразований между целыми числами и логическими значениями без ветвлений.
  2. ✔️ Вместо этого используйте тернарные операторы (или другую логику ветвления). Современные JTS .NET эффективно оптимизируют их.
  3. ❌ НЕ считывайте bool используя небезопасные API, такие как Unsafe.ReadUnaligned или MemoryMarshal.Cast, если вы не доверяете входным данным. Вместо этого рекомендуется использовать тернарные операторы или сравнения равенства:
// Bad:
bool b = Unsafe.ReadUnaligned<bool>(ref byteData);

// Good:
bool b = byteData[0] != 0;

// Bad:
ReadOnlySpan<byte> byteSpan = ReadDataFromNetwork();
bool[] boolArray = MemoryMarshal.Cast<byte, bool>(byteSpan).ToArray();

// Good:
ReadOnlySpan<byte> byteSpan = ReadDataFromNetwork();
bool[] boolArray = new bool[byteSpan];
for (int i = 0; i < byteSpan.Length; i++) { boolArray[i] = byteSpan[i] != 0; }

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

22. Интероперабельность

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

23. Безопасность потоков

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

24. Небезопасный код вокруг SIMD/Vectorization

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

  • Операции SIMD имеют сложные требования для предоставления гарантий атомарности (иногда они не предоставляют их вообще).
  • Большинство API загрузки и хранения SIMD не предоставляют проверки границ.

25. Фазз-тестирование

Фаззинг (или "фазз-тестирование") — это автоматизированный метод тестирования программного обеспечения, который включает предоставление недопустимых, непредвиденных или случайных данных в качестве входных для компьютерной программы. Он предоставляет способ обнаружения проблем безопасности памяти в коде, которые могут иметь пробелы в тестовом охвате. Для настройки фазз-тестирования кода .NET можно использовать такие инструменты, как SharpFuzz.

26. Предупреждения компилятора

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

nint ptr = 0;
unsafe
{
    int local = 0;
    ptr = (nint)(&local);
}
await Task.Delay(100);

// ptr is used here

Этот код выдает предупреждение CS9123 (оператор '&' не должен использоваться для параметров или локальных переменных в асинхронных методах), что означает, что код, скорее всего, неверный.

Recommendations

  1. ✔️ Обратите внимание на предупреждения компилятора и исправьте базовые проблемы вместо подавления.
  2. ❌ Не предполагайте, что отсутствие предупреждений компилятора подразумевает правильность кода. Компилятор C# не поддерживает обнаружение неправильного небезопасного использования кода.

Ссылки