Condividi tramite


Procedure consigliate per il codice non sicuro

Questo articolo contiene raccomandazioni dettagliate per modelli non sicuri specifici, i rischi che comportano e come attenuare tali rischi. Queste linee guida sono destinate a tutti gli sviluppatori che scrivono o esaminano codice non sicuro in C#. Altri linguaggi .NET, ad esempio F# e Visual Basic, non rientrano nell'ambito di questo articolo, anche se alcuni consigli potrebbero essere applicabili anche a tali linguaggi.

Glossario

  • AVE - Eccezione di violazione dell'accesso.
  • Byref: puntatore gestito (ref T t) simile al puntatore non gestito ma rilevato dal GC. In genere indirizza a parti casuali dello stack o degli oggetti. Il riferimento funziona sostanzialmente come un puntatore gestito con un offset di +0.
  • CVE: vulnerabilità di cybersecurity divulgate pubblicamente.
  • JIT - compilatore JIT (RyuJIT in CoreCLR e NativeAOT).
  • PGO - Ottimizzazione guidata dal profilo.
  • Puntatore non gestito (o puntatore non elaborato): puntatore (T* p) che punta a una posizione di memoria arbitraria e non viene gestito o monitorato dal GC.

Per altri termini, vedere Glossario del runtime .NET.

Modelli comuni inaffidabili

C# offre un ambiente sicuro in cui gli sviluppatori non devono preoccuparsi dei lavori interni del runtime e del GC. Il codice non sicuro consente di ignorare questi controlli di sicurezza, introducendo potenzialmente modelli inaffidabili che possono causare il danneggiamento della memoria. Sebbene tali modelli possano essere utili in determinati scenari, è consigliabile usarli con cautela e solo quando assolutamente necessario. Non solo C# e .NET non forniscono strumenti per verificare l'integrità del codice non sicuro (poiché potrebbero fornire vari sanificatori C/C++), i comportamenti specifici di GC potrebbero introdurre rischi aggiuntivi in C# non sicuri oltre a quelli con cui gli sviluppatori C/C++ tradizionali potrebbero avere familiarità.

Il codice non sicuro relativo ai riferimenti gestiti deve essere scritto tenendo presenti i presupposti conservativi seguenti:

  • Il processo GC può interrompere l'esecuzione di qualsiasi metodo in qualsiasi momento in qualsiasi istruzione.
  • Il GC può spostare oggetti in memoria e aggiornare tutti i riferimenti rilevati .
  • Il GC sa esattamente quando i riferimenti non sono più necessari.

Un esempio classico di danneggiamento dell'heap si verifica quando il GC perde la traccia di un riferimento a un oggetto o considera i puntatori non validi come riferimenti heap. Questo comporta spesso arresti anomali non deterministici o danneggiamento della memoria. I bug di danneggiamento dell'heap sono particolarmente difficili da diagnosticare e riprodurre perché:

  • Questi problemi possono rimanere nascosti per molto tempo e solo dopo una modifica del codice non correlata o un aggiornamento di runtime.
  • Spesso richiedono tempistiche precise per la riproduzione, come l'interruzione della GC durante l'esecuzione in un momento specifico e l'avvio della compattazione dell'heap, che rappresenta un evento raro e non deterministico.

Le sezioni successive descrivono modelli non sicuri comuni con raccomandazioni DO e ❌ NON.

1. Puntatori gestiti non tracciati (Unsafe.AsPointer e simili)

Non è possibile convertire un puntatore amministrato (tracciato) in un puntatore non amministrato (non tracciato) nel codice sicuro di C#. In caso di necessità, potrebbe essere tentabile usare Unsafe.AsPointer<T>(T) per evitare il sovraccarico di un'istruzione fixed . Anche se esistono casi d'uso validi per questo, introduce un rischio di creazione di puntatori non tracciati per oggetti spostabili. Esempio:

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

Se il GC interrompe l'esecuzione del UnreliableCode metodo subito dopo che il puntatore è stato letto (l'indirizzo a cui xfa riferimento ) e riloca l'oggetto a cui si fa riferimento, il GC aggiornerà correttamente il percorso archiviato in x ma non saprà nulla su nativePointer e non aggiornerà il valore contenuto. A quel punto, scrivere su nativePointer equivale a scrivere su memoria arbitraria.

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

Una volta che il GC riprende l'esecuzione del metodo, scriverà 42 nella vecchia posizione di x, il che potrebbe causare un'eccezione imprevista, un danneggiamento generale dello stato globale o la terminazione del processo tramite una violazione di accesso.

La soluzione consigliata consiste invece nell'usare la fixed parola chiave e & l'operatore address-of per assicurarsi che GC non possa rilocare il riferimento di destinazione per la durata dell'operazione.

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. ❌ NON utilizzare ref X argomenti con un contratto implicito che X sia sempre allocati nello stack, bloccato o altrimenti non rilocabile dal GC. Lo stesso vale per gli oggetti semplici e gli span: non introdurre contratti basati sui chiamanti non ovvi sulla loro durata nelle firme dei metodi. Considera invece l'utilizzo di un argomento ref struct o la trasformazione dell'argomento in un tipo puntatore grezzo (X*).
  2. ❌ NON usare un puntatore da Unsafe.AsPointer<T>(T) se può sopravvivere all'oggetto originale a cui punta. Per la documentazione dell'API, spetta al chiamante di Unsafe.AsPointer<T>(T) garantire che il GC non possa rilocare il riferimento. Assicurarsi che sia chiaramente visibile ai revisori del codice che il chiamante ha soddisfatto questo prerequisito.
  3. ✔️ DO usa GCHandle o fixed ambiti invece di Unsafe.AsPointer<T>(T) definire ambiti espliciti per i puntatori non gestiti e per garantire che l'oggetto sia sempre bloccato.
  4. ✔️ Usare puntatori non gestiti (con fixed) anziché byrefs quando è necessario allineare una matrice a un limite specifico. In questo modo il GC non rilocherà l'oggetto e invaliderà eventuali presupposti di allineamento su cui potrebbe basarsi la logica.

2. Esposizione di puntatori al di fuori dell'ambito fixed

Sebbene la parola chiave fissa definisca un ambito per il puntatore ottenuto dall'oggetto aggiunto, è comunque possibile che tale puntatore eseppi l'ambito fixed e introduca bug, perché C# non fornisce alcuna protezione di proprietà/ciclo di vita per tale oggetto. Un esempio tipico è il frammento di codice seguente:

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

In questo esempio, l'array viene ancorato correttamente usando la parola chiave fixed (assicurando che il GC non possa rilocarlo all'interno del blocco fixed), ma il puntatore viene esposto al di fuori del blocco fixed. In questo modo viene creato un puntatore incerto il cui dereferenziazione comporterà un comportamento non definito.

Recommendations

  1. ✔️ Fai in modo che i puntatori nei blocchi fixed non escano dall'ambito definito.
  2. ✔️ DO preferisce primitive di basso livello sicure con analisi di escape predefinite, ad esempio lo struct di riferimento di C#. Per altre informazioni, vedere Miglioramenti dello struct di basso livello.

3. Dettagli di implementazione interna del runtime e delle librerie

Anche se l'accesso o l'uso dei dettagli di implementazione interna è una cattiva pratica in generale (e non supportato da .NET), vale la pena menzionare specifici casi comunemente osservati. Questo non è progettato per essere un elenco completo di tutti gli elementi possibili che potrebbero andare storti quando il codice si basa in modo inappropriato su un dettaglio interno dell'implementazione.

Recommendations

  1. ❌ NON modificare o leggere parti dell'intestazione di un oggetto.

    • Le intestazioni degli oggetti possono variare in fase di esecuzione.
    • In CoreCLR non è possibile accedere all'intestazione dell'oggetto in modo sicuro senza prima fissare l'oggetto.
    • Non modificare mai il tipo dell'oggetto modificando il puntatore MethodTable.
  2. ❌ NON archiviare dati nella spaziatura interna di un oggetto. Non presupporre che il contenuto della spaziatura interna venga mantenuto o che la spaziatura interna sia sempre zero per impostazione predefinita.

  3. ❌ NON fare ipotesi sulle dimensioni e gli offset di qualsiasi elemento diverso da primitive e struct con layout sequenziale o esplicito. Anche in questo caso, esistono eccezioni, ad esempio quando sono coinvolti handle GC.

  4. ❌ NON invocare metodi non pubblici, accedere a campi non pubblici o modificare campi di sola lettura nei tipi BCL usando la reflection o il codice non sicuro.

  5. ❌ NON presupporre che qualsiasi membro non pubblico nella libreria di classi di base sia sempre presente o avrà una struttura specifica. Il team .NET modifica o rimuove occasionalmente LE API non pubbliche nelle versioni di manutenzione.

  6. ❌ NON modificare static readonly i campi usando la reflection o il codice non sicuro, perché si presuppone che siano costanti. Ad esempio, RyuJIT in genere li inline come costanti esplicite.

  7. ❌ NON presupporre che un riferimento non sia relocabile. Queste indicazioni si applicano ai valori letterali stringa e UTF-8 ("..."u8), ai campi statici, ai campi RVA, agli oggetti LOH e così via.

    • Si tratta di dettagli di implementazione del runtime che potrebbero essere validi per alcuni runtime, ma non per altri.
    • I puntatori non gestiti a tali oggetti potrebbero non impedire lo scaricamento degli assembly, causando lo spuntamento dei puntatori. Utilizzare fixed gli ambiti per garantire la correttezza.
    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. ❌ NON scrivere codice che si basa sui dettagli di implementazione di un runtime specifico.

4. Puntatori gestiti non validi (anche se non vengono mai dereferenziati)

Alcune categorie di codice si appoggiano alla manipolazione dei puntatori e all'aritmetica e spesso il codice ha una scelta tra l'uso di puntatori non gestiti () e puntatori gestiti (T* pref T p). Questi puntatori possono essere modificati arbitrariamente, ad esempio tramite operatori su puntatori non gestiti (p++) e tramite Unsafe metodi sui puntatori gestiti (p = ref Unsafe.Add(ref p, 1)). Entrambi sono considerati "codice non sicuro" ed è possibile creare modelli non affidabili con entrambi. Tuttavia, per determinati algoritmi, può essere più facile creare accidentalmente modelli GC-unsafe durante la modifica dei puntatori gestiti. Poiché i puntatori non gestiti non vengono rilevati dal GC, il valore che contengono è rilevante solo quando viene dereferenziato dal codice dello sviluppatore. Al contrario, il valore di un puntatore gestito è rilevante non solo quando viene dereferenziato dal codice dello sviluppatore, ma anche quando viene esaminato dal GC. Pertanto, uno sviluppatore può creare puntatori non gestiti non validi senza conseguenza, purché non vengano dereferenziati, ma la creazione di un puntatore gestito non valido è un bug. Esempio:

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

Tuttavia, il codice simile che usa byrefs (puntatori gestiti) non è valido.

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
}

Sebbene l'implementazione gestita in questo caso eviti il sovraccarico di blocco secondario, non è valido perché invalidPtrpotrebbe diventare un puntatore esterno mentre l'indirizzo effettivo di array[0] viene aggiornato da GC. Questi bug sono sottili e anche .NET ha avuto problemi con loro durante lo sviluppo.

Recommendations

  1. ❌ NON creare puntatori gestiti non validi, anche se non sono dereferenziati o si trovano all'interno di percorsi di codice mai eseguiti.
  2. ✔️ Usare puntatori non gestiti bloccati se l'algoritmo richiede tali manipolazioni.

5. Cast di tipo simili a reinterpret_cast

Anche se tutti i tipi di cast da struct a classe o da classe a struct sono per definizione un comportamento non definito, è anche possibile riscontrare modelli non affidabili con conversioni da struct a struct o da classe a classe. Un esempio tipico di un modello non affidabile è il codice seguente:

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.

Anche se il layout è simile, è comunque importante fare attenzione quando sono coinvolti i riferimenti GC (campi).

Recommendations

  1. ❌ NON eseguire il cast di struct a classi o viceversa.
  2. ❌ NON usare Unsafe.As per le conversioni da struct a struct o da classe a classe, a meno che non sia assolutamente certo che il cast sia legale. Per altre informazioni, vedere la sezione Osservazioni della documentazione dell'APIUnsafe.As.
  3. ✔️ Preferite la copia sicura campo per campo, librerie esterne come AutoMapper o generatori di origine per tali conversioni.
  4. ✔️ DO preferisce Unsafe.BitCast rispetto Unsafe.Asa , perché BitCast fornisce alcuni controlli di utilizzo rudimentali. Si noti che questi controlli non forniscono garanzie di correttezza complete, il che significa che BitCast è ancora considerata un'API non sicura.

6. Ignorare la barriera di scrittura e le operazioni non atomiche sui riferimenti GC

Tutte le operazioni di scrittura o lettura di riferimenti GC sono sempre atomiche. Inoltre, tutti i tentativi di assegnare un riferimento di GC (o un byref a struct con campi GC) a una posizione potenziale dell'heap passano attraverso la Write Barrier che garantisce che il GC sia informato di nuove connessioni tra oggetti. Tuttavia, il codice non sicuro consente di ignorare queste garanzie e introdurre modelli non affidabili. Esempio:

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

Analogamente, anche il codice seguente con puntatori gestiti non è affidabile:

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. ❌ NON usare operazioni non atomiche sui riferimenti GC( ad esempio, le operazioni SIMD spesso non le forniscono).
  2. ❌ NON usare puntatori non gestiti per archiviare i riferimenti GC in posizioni heap (omettendo la barriera di scrittura).

7. Presupposti sulla durata degli oggetti (finalizzatori, GC.KeepAlive)

Evitare di fare ipotesi sulla durata degli oggetti dal punto di vista di GC. In particolare, non presupporre che un oggetto sia ancora attivo quando potrebbe non essere. La durata degli oggetti può variare in runtime diversi o anche tra livelli diversi dello stesso metodo (Tier0 e Tier1 in RyuJIT). I finalizzatori sono uno scenario comune in cui tali presupposti possono essere errati.

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

In questo esempio DestroyHandle potrebbe essere chiamato prima che DoWork completi o inizi. Pertanto, è fondamentale non presupporre che gli oggetti, ad esempio this, rimangano attivi fino alla fine del metodo.

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

Pertanto, è consigliabile estendere in modo esplicito la durata degli oggetti usando GC.KeepAlive(Object) o SafeHandle.

Un'altra istanza classica di questo problema è Marshal.GetFunctionPointerForDelegate<TDelegate>(TDelegate) l'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. ❌ NON fare ipotesi sulla durata degli oggetti. Ad esempio, non presupporre this che sia sempre attivo fino alla fine del metodo.
  2. ✔️ USARE SafeHandle per la gestione delle risorse native.
  3. ✔️ Utilizzare GC.KeepAlive(Object) per estendere la durata degli oggetti quando necessario.

8. Accesso tra thread alle variabili locali

L'accesso alle variabili locali da un thread diverso è generalmente considerato una procedura non valida. Tuttavia, diventa in modo esplicito un comportamento non definito quando sono coinvolti riferimenti gestiti, come descritto nel modello di memoria .NET.

Esempio: uno struct contenente riferimenti GC potrebbe essere azzerato o sovrascritto in modo non thread-safe all'interno di un'area no-GC mentre un altro thread lo legge, causando un comportamento non definito.

Recommendations

  1. ❌ NON accedere alle variabili locali tra i thread (soprattutto se contengono riferimenti GC).
  2. ✔️ USARE invece l'heap o la memoria non gestita ( ad esempio , NativeMemory.Alloc).

9. Rimozione dei limiti non sicuri

In C#, tutti gli accessi alla memoria idiomatica includono i controlli dei limiti per impostazione predefinita. Il compilatore JIT può rimuovere questi controlli se può dimostrare che non sono necessari, come nell'esempio seguente.

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

Anche se il JIT migliora continuamente nel riconoscimento di tali modelli, esistono ancora scenari in cui lascia i controlli, che potrebbero influire sulle prestazioni nel codice critico. In questi casi, si potrebbe essere tentati di usare codice non sicuro per rimuovere manualmente questi controlli senza comprendere completamente i rischi o valutare accuratamente i vantaggi delle prestazioni.

Si consideri ad esempio il metodo seguente.

int FetchAnElement(int[] array, int index)
{
    return array[index];
}

Se jit non è in grado di dimostrare che index è sempre legalmente all'interno dei limiti di array, riscriverà il metodo per avere un aspetto simile al seguente.

int FetchAnElement_AsJitted(int[] array, int index)
{
    if (index < 0 || index >= array.Length)
        throw new IndexOutOfBoundsException();
    return array.GetElementAt(index);
}

Per ridurre il sovraccarico da tale controllo nel codice critico, si potrebbe essere tentati di usare API non sicure equivalenti (Unsafe e 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);
}

In alternativa, usare il pinning e i puntatori grezzi:

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

Ciò può causare arresti anomali casuali o danneggiamento dello stato se index non rientra nei limiti di array. Tali trasformazioni non sicure possono avere vantaggi in termini di prestazioni su percorsi molto caldi, ma questi vantaggi sono spesso temporanei, poiché ogni versione di .NET migliora la capacità di JIT di eliminare i limiti non necessari quando è sicuro farlo.

Recommendations

  1. ✔️ VERIFICARE se la versione più recente di .NET non può comunque eliminare il controllo dei limiti. Se possibile, riscrivere usando codice sicuro. In caso contrario, inviare un problema a RyuJIT. Usare questo problema di rilevamento come un buon punto di partenza.
  2. Devi misurare l'impatto delle prestazioni nel mondo reale. Se il miglioramento delle prestazioni è trascurabile o il codice non è dimostrato essere critico al di fuori di un microbenchmark semplice, riscrivi usando codice sicuro.
  3. ✔️ DO fornisce suggerimenti aggiuntivi per JIT, ad esempio controlli manuali prima dei cicli e il salvataggio dei campi nelle variabili locali, poiché il modello di memoria .NET potrebbe impedire in modo conservativo la rimozione dei limiti da parte di JIT in alcuni scenari.
  4. ✔️ Proteggi il codice con controlli dei limiti di Debug.Assert se il codice non sicuro è comunque necessario. Si consideri l'esempio seguente.
Debug.Assert(array is not null);
Debug.Assert((index >= 0) && (index < array.Length));
// Unsafe code here

È anche possibile effettuare il refactoring di questi controlli in metodi helper riutilizzabili.

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

L'inclusione di Debug.Assert non fornisce controlli di sonorità per le build di rilascio, ma potrebbe aiutare a rilevare potenziali bug nelle compilazioni di debug.

10. Fusione degli accessi alla memoria

Si potrebbe essere tentati di usare codice non sicuro per unire gli accessi alla memoria per migliorare le prestazioni. Un esempio classico è il codice seguente per scrivere "False" in una matrice 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)
}

Nelle versioni precedenti di .NET, la versione non sicura che usava MemoryMarshal era molto più veloce rispetto alla versione sicura semplice. Tuttavia, le versioni moderne di .NET contengono un JIT molto migliorato che produce codicegeno equivalente per entrambi i casi. A partire da .NET 10, il codegen 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

Esiste una versione ancora più semplice e leggibile del codice:

"False".CopyTo(dst);

A partire da .NET 10, questa chiamata produce codicegen identico a quello precedente. Ha anche un vantaggio aggiuntivo: suggerisce al JIT che le scritture rigide per ogni elemento non devono essere atomiche. JIT potrebbe combinare questo hint con altre conoscenze contestuali per fornire ancora più ottimizzazioni oltre a quanto illustrato qui.

Recommendations

  1. ✔️ DO preferire codice idiomatico sicuro invece di quello non sicuro per l'unione dell'accesso alla memoria:
    • Preferisce Span<T>.CopyTo e Span<T>.TryCopyTo per la copia dei dati.
    • Preferisce String.Equals e Span<T>.SequenceEqual per confrontare i dati (anche quando si usa StringComparer.OrdinalIgnoreCase).
    • Preferire Span<T>.Fill per l’inserimento dei dati e Span<T>.Clear per la cancellazione dei dati.
    • Tenere presente che le operazioni di lettura/scrittura sul livello dell'elemento o del campo potrebbero essere aggregati automaticamente da JIT.
  2. ✔️ Segnalare un problema a dotnet/runtime se si scrive codice idiomatico e si nota che non è ottimizzato come ci si aspetterebbe.
  3. ❌ NON unire manualmente gli accessi alla memoria se non si è certi di rischi di accesso alla memoria non allineati, garanzie di atomicità o i vantaggi delle prestazioni associati.

11. Accesso alla memoria non allineato

L'unione dell'accesso alla memoria descritta in Unione dell'accesso alla memoria comporta spesso letture/scritture esplicite o implicite non allineate. Anche se questo in genere non causa problemi gravi (a parte potenziali penalità delle prestazioni dovute all'attraversamento dei limiti della cache e della pagina), rappresenta comunque alcuni rischi reali.

Si consideri, ad esempio, lo scenario in cui si cancellano due elementi di una matrice contemporaneamente:

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

Si supponga che i valori precedenti in queste posizioni siano entrambi uint.MaxValue (0xFFFFFFFF). Il modello di memoria .NET garantisce che entrambe le scritture siano atomiche, quindi tutti gli altri thread nel processo osserveranno solo il nuovo valore 0 o il valore 0xFFFFFFFFprecedente, non verranno mai "strappati" valori come 0xFFFF0000.

Tuttavia, si supponga che sia usato il seguente codice unsafe per ignorare il controllo dei limiti e azzerare entrambi gli elementi con una singola scrittura a 64 bit.

ref uint p = ref Unsafe.Add(ref MemoryMarshal.GetArrayDataReference(arr), i);
Unsafe.WriteUnaligned<ulong>(ref Unsafe.As<uint, byte>(ref p), 0UL);

Questo codice ha l'effetto collaterale della rimozione della garanzia di atomicità. I valori fratturati potrebbero essere osservati da altri thread, portando a un comportamento indefinito. Perché una scrittura coalescita sia atomica, la memoria deve essere allineata alla dimensione della scrittura (8 byte in questo caso). Se si tenta di allineare manualmente la memoria prima dell'operazione, è necessario considerare che il GC può rilocare (e, in modo efficace, modificare l'allineamento di) la matrice in qualsiasi momento se non è bloccata. Per altri dettagli, vedere la documentazione del modello di memoria .NET .

Un altro rischio di accesso alla memoria non idoneo è il potenziale di un arresto anomalo dell'applicazione in determinati scenari. Anche se alcuni runtime .NET si basano sul sistema operativo per correggere gli accessi non allineati, esistono ancora alcuni scenari in alcune piattaforme in cui l'accesso non allineato può portare a un DataMisalignedException (o SEHException). Alcuni esempi includono:

  • Interlocked operazioni sulla memoria non allineata in alcune piattaforme (ad esempio).
  • Operazioni a virgola mobile non allineate su ARM.
  • Accesso alla memoria speciale del dispositivo con determinati requisiti di allineamento (non supportati in realtà da .NET).

Recommendations

  1. ❌ NON usare accessi alla memoria non allineati negli algoritmi lock-free e in altri scenari in cui l'atomicità è importante.
  2. ✔️ Allinea manualmente i dati, se necessario, ma tieni presente che il GC può rilocare gli oggetti in qualsiasi momento, modificando in modo efficace l'allineamento in modo dinamico. Ciò è particolarmente importante per varie StoreAligned/LoadAligned API in SIMD.
  3. ✔️ DO usare API di lettura/scrittura esplicite, come Unsafe.ReadUnaligned/Unsafe.WriteUnaligned, anziché allineate, come Unsafe.Read<T>(Void*)/Unsafe.Write<T>(Void*, T), o Unsafe.As<TFrom,TTo>(TFrom) se i dati potrebbero non essere allineati.
  4. ✔️ Tenere presente che varie API di manipolazione della memoria, Span<T>.CopyTo(Span<T>) ad esempio, non forniscono garanzie di atomicità.
  5. ✔️ Consultare la documentazione del modello di memoria .NET (vedere i riferimenti) per altre informazioni sulle garanzie di atomicità.
  6. ✔️ Misura le prestazioni su tutte le piattaforme di destinazione, poiché alcune piattaforme impongono una riduzione significativa delle prestazioni per gli accessi alla memoria disallineati. È possibile che in queste piattaforme il codice ingenuo funzioni meglio del codice intelligente.
  7. ✔️ Tenere presente che esistono scenari e piattaforme in cui l'accesso alla memoria non allineato potrebbe causare un'eccezione.

12. Serializzazione binaria (de)serializzazione di struct con spaziatura interna o membri non copiabili

Prestare attenzione quando si usano varie API simili alla serializzazione per copiare o leggere struct da o verso matrici di byte. Se uno struct contiene spaziatura interna o membri non copiabili (ad esempio, bool o campi GC), le operazioni classiche di memoria non sicura, Fillad esempio , CopyToe SequenceEqual potrebbero copiare accidentalmente dati sensibili dallo stack nelle spaziatura interna o trattare i dati di Garbage come significativi durante i confronti creando bug raramente riproducibili. Un anti-pattern comune potrebbe essere simile al seguente:

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

L'unico approccio corretto consiste nell'usare caricamenti/immagazzinamenti di campi specializzati per ogni TObject input (o generalizzato con Reflection, Source Generators, o librerie di (de)serializzazione).

Recommendations

  1. ❌ NON utilizzare codice non sicuro per copiare/caricare/confrontare strutture con riempimenti o membri non blittabili. I caricamenti da input non attendibili sono problematici anche per i tipi di base come bool o decimal. Allo stesso tempo, gli store potrebbero serializzare accidentalmente informazioni riservate dallo stack nei riempimenti delle struct.
  2. ❌ NON basarsi su T : unmanaged vincoli, RuntimeHelpers.IsReferenceOrContainsReferenceso API simili per garantire che un tipo generico sia sicuro per eseguire operazioni bit per bit. Al momento della scrittura di queste linee guida, non esiste un modo affidabile a livello di codice per determinare se è legale eseguire operazioni bit per bit arbitrarie su un determinato tipo.
    • Se è necessario eseguire tale manipolazione bit per bit, eseguirla solo su questo elenco predefinito di tipi specifici e tenere presente l'endianness della macchina attuale.
      • Tipi integrali primitivi Byte, , SByteInt16UInt16, Int32, UInt32, Int64, e UInt64.
      • Oggetto Enum supportato da uno dei tipi integrali primitivi precedenti;
      • Char, Int128, UInt128, Half, Single, Double, IntPtr, UIntPtr.
  3. ✔️ UTILIZZARE al contrario caricamenti/archiviazioni campo per campo per la (de)serializzazione. Si consiglia di usare librerie popolari e sicure per la serializzazione e deserializzazione.

13. Puntatori Null gestiti

In genere, i byrefs (puntatori gestiti) sono raramente Null e l'unico modo sicuro per creare un byref Null a partire da oggi consiste nell'inizializzare un ref struct con default. Tutti i relativi ref campi sono quindi puntatori gestiti null:

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

Esistono tuttavia diversi modi non sicuri per creare byrefs Null. Alcuni esempi includono:

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

Il rischio di introdurre problemi di sicurezza della memoria è basso e qualsiasi tentativo di dereferenziare un byref Null porterà a un'eccezione NullReferenceException ben definita. Tuttavia, il compilatore C# presuppone che la dereferenziazione di un byref abbia sempre esito positivo e non produa alcun effetto collaterale osservabile. Pertanto, è un'ottimizzazione legale per elidere qualsiasi dereferenziazione il cui valore risultante viene immediatamente eliminato. Vedere dotnet/runtime#98681 (e questo commento correlato) per un esempio di bug ora corretto all'interno di .NET in cui il codice della libreria si basava in modo errato sulla dereferenziazione che attiva un effetto collaterale, senza che il compilatore C# cortocircuiti effettivamente la logica desiderata.

Recommendations

  1. ❌ NON creare riferimenti di tipo null in C# se non è necessario. È consigliabile usare i normali riferimenti gestiti, il modello a oggetti Null o gli intervalli vuoti.
  2. ❌ NON scartare il risultato di una de-referenziazione byref, poiché potrebbe essere ottimizzato al punto da essere eliminato, creando potenziali bug.

14. stackalloc

stackalloc è stato storicamente usato per creare matrici di piccole dimensioni non di escape nello stack, riducendo la pressione GC. In futuro, l'analisi di escape di JIT potrebbe iniziare a ottimizzare le allocazioni GC non di escape delle matrici per impilare oggetti, rendendo stackalloc potenzialmente ridondante. Fino ad allora, stackalloc rimane utile per allocare buffer di piccole dimensioni nello stack. Per buffer di dimensioni maggiori o di fuoriuscita, viene spesso combinato con ArrayPool<T>.

Recommendations

  1. ✔️ Assicurati di consumare sempre stackalloc sul lato sinistro dell'espressione ReadOnlySpan<T>/Span<T> per garantire controlli dei limiti:

    // 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. ❌ NON usare stackalloc all'interno dei cicli. Lo spazio dello stack non viene recuperato fino alla restituzione del metodo, quindi l'inclusione di un stackalloc all'interno di un ciclo potrebbe comportare la terminazione del processo a causa dell'overflow dello stack.

  3. ❌ NON usare lunghezze di grandi dimensioni per stackalloc. Ad esempio, 1024 byte possono essere considerati un limite superiore ragionevole.

  4. ✔️ CONTROLLARE l'intervallo di variabili usate come stackalloc lunghezze.

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

    Versione fissa:

    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. ✔️ DO usa funzionalità C# moderne, ad esempio valori letterali di raccolta (Span<int> s = [1, 2, 3];), params Span<T>e matrici inline per evitare la gestione manuale della memoria quando possibile.

15. Buffer a dimensione fissa

I buffer a dimensione fissa sono utili per scenari di interoperabilità con origini dati provenienti da altri linguaggi o piattaforme. Sono stati quindi sostituiti da matrici inline più sicure e più convenienti. Un esempio di buffer a dimensione fissa (richiede unsafe contesto) è il frammento di codice seguente:

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.

Un'alternativa moderna e più sicura è costituito da matrici inline:

[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.

Un altro motivo per evitare buffer a dimensione fissa a favore di matrici inline, che sono sempre inizializzate a zero per impostazione predefinita, è che i buffer a dimensione fissa potrebbero avere contenuti non zero in determinati scenari.

Recommendations

  1. ✔️ DO preferisce sostituire buffer a dimensione fissa con matrici inline o attributi di marshalling IL, se possibile.

16. Passaggio di dati contigui come puntatori + lunghezze (o basandosi sulla terminazione zero)

Evitare di definire API che accettano puntatori gestiti o non gestiti per dati contigui. Usare invece Span<T> o 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);

La terminazione zero è particolarmente rischiosa perché non tutti i buffer sono con terminazione zero e la lettura oltre qualsiasi terminatore zero può causare la divulgazione di informazioni, il danneggiamento dei dati o la terminazione del processo tramite una violazione di accesso.

Recommendations

  1. ❌ DON'T esporre i metodi i cui argomenti sono tipi di puntatore (puntatori T* non gestiti o puntatori ref T gestiti) quando tali argomenti devono rappresentare dei buffer. Usare invece tipi di buffer sicuri come Span<T> o ReadOnlySpan<T> .

  2. ❌ NON usare contratti impliciti per gli argomenti byref, ad esempio richiedere a tutti i chiamanti di allocare l'input nello stack. Se è necessario un contratto di questo tipo, considerare l'uso di ref struct.

  3. ❌ NON presupporre che i buffer siano con terminazione zero a meno che lo scenario non documenti in modo esplicito che si tratti di un presupposto valido. Ad esempio, anche se .NET garantisce che le istanze string e i valori letterali "..."u8 siano terminati con un null, lo stesso non vale per altri tipi di buffer come ReadOnlySpan<char> o 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. ❌ NON passare un Span<char> o ReadOnlySpan<char> bloccato attraverso un confine p/invoke, a meno che non venga passato anche un argomento di lunghezza esplicito. In caso contrario, il codice dall'altro lato del limite p/invoke potrebbe non essere corretto nel credere che il buffer sia con terminazione 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);
    }
}

Per risolvere questo problema, utilizzare una firma p/invoke alternativa che accetta sia il puntatore dei dati che la lunghezza, se possibile. In caso contrario, se il ricevitore non ha modo di accettare un argomento di lunghezza separato, verificare che i dati originali vengano convertiti in un oggetto string prima di fissarli e trasferirli attraverso il confine 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. Mutazioni di stringhe

Le stringhe in C# non sono modificabili per progettazione e qualsiasi tentativo di modificarle usando codice non sicuro può causare un comportamento non definito. Esempio:

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

La modifica di una stringa internata (la maggior parte dei valori letterali stringa sono) modificherà il valore per tutti gli altri usi. Anche senza internità di stringhe, la scrittura in una stringa appena creata deve essere sostituita con l'API più sicura String.Create :

// 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. ❌ NON modificare le stringhe. Usare l'API String.Create per creare una nuova stringa se è necessaria una logica di copia complessa. In caso contrario, usare .ToString(), StringBuilder, new string(...), o la sintassi di interpolazione di stringhe.

18. Codice IL non elaborato (ad esempio, System.Reflection.Emit e Mono.Cecil)

L'emissione di codice IL non elaborato (tramite System.Reflection.Emit, librerie di terze parti come Mono.Cecil o scrivendo il codice IL direttamente) ignora per definizione tutte le garanzie di sicurezza della memoria offerte da C#. Evitare di usare tali tecniche, a meno che non sia assolutamente necessario.

Recommendations

  1. ❌ Non emettere codice IL grezzo poiché viene fornito senza guiderails e rende facile introdurre problemi di sicurezza dei tipi e altri problemi. Analogamente ad altre tecniche di generazione di codice dinamico, l'emissione del codice non elaborato non è compatibile con AOT anche se non viene eseguita in fase di compilazione.
  2. ✔️ Usare invece generatori di origine, se possibile.
  3. ✔️ Preferisci [UnsafeAccessor] invece di emettere IL non elaborato per scrivere codice di serializzazione efficiente per i membri privati, se necessario.
  4. ✔️ Inviare una proposta API a dotnet/runtime se manca un'API e sei costretto a usare il codice IL grezzo.
  5. ✔️ Usare ilverify o strumenti simili per convalidare il codice IL emesso se è necessario usare il codice IL non elaborato.

19. Locali non inizializzati [SkipLocalsInit] e Unsafe.SkipInit

[SkipLocalsInit] è stato introdotto in .NET 5.0 per consentire al JIT di evitare l'azzeramento delle variabili locali nei metodi, sia su base metodo che a livello di modulo. Questa funzionalità è stata spesso usata per aiutare il JIT a eliminare le inizializzazioni a zero ridondanti, come quelle per stackalloc. Tuttavia, può causare un comportamento non definito se le variabili locali non vengono inizializzate in modo esplicito prima dell'uso. Con i recenti miglioramenti nella capacità di JIT di eliminare le inizializzazioni zero ed eseguire la vettorizzazione, la necessità di [SkipLocalsInit] e Unsafe.SkipInit ha notevolmente diminuito.

Recommendations

  1. ❌ NON usare [SkipLocalsInit] e Unsafe.SkipInit se non vengono osservati vantaggi in termini di prestazioni nel codice ad accesso frequente o non si è certi dei rischi introdotti.
  2. ✔️ Fai codice difensivo quando usi API come GC.AllocateUninitializedArray e ArrayPool<T>.Shared.Rent, che possono restituire in modo simile buffer non inizializzati.

20. ArrayPool<T>.Shared e API di pooling simili

ArrayPool<T>.Shared è un pool condiviso di matrici usato per ridurre la pressione del GC nel codice ad accesso frequente. Viene spesso usato per allocare buffer temporanei per operazioni di I/O o o altri scenari di breve durata. Anche se l'API è semplice e non contiene intrinsecamente funzionalità non sicure, può causare bug senza uso in C#. Esempio:

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

Qualsiasi uso di _buffer dopo la Return chiamata è un bug use-after-free. Questo esempio minimo è facile da individuare, ma il bug diventa più difficile da rilevare quando Rent e Return si trovano in ambiti o metodi diversi.

Recommendations

  1. ✔️ MANTENERE le chiamate corrispondenti a Rent e Return all'interno dello stesso metodo, se possibile, per restringere l'ambito dei potenziali bug.
  2. ❌ NON usare uno try-finally schema per chiamare Return nel finally blocco, a meno che non siate certi che la logica che ha fallito abbia terminato di usare il buffer. È preferibile abbandonare il buffer anziché rischiare un bug di uso-dopo-free a causa di un Return anticipato e imprevisto.
  3. ✔️ Tenere presente che possono verificarsi problemi simili con altre API o modelli di pooling, ad esempio ObjectPool<T>.

21. bool<->int conversioni

Anche se lo standard ECMA-335 definisce un valore booleano come 0-255 dove true è qualsiasi valore diverso da zero, è preferibile evitare conversioni esplicite tra interi e booleani per evitare di introdurre valori "denormalizzati" come qualsiasi valore diverso da 0 o 1 comporta probabilmente un comportamento inaffidabile.

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

Il jit presente nei runtime .NET precedenti non ha ottimizzato completamente la versione sicura di questa logica, con conseguente uso di costrutti non sicuri per la conversione tra bool e int nei percorsi di codice sensibili alle prestazioni. Questo non è più il caso e le moderne JIT .NET sono in grado di ottimizzare efficacemente la versione sicura.

Recommendations

  1. ❌ NON scrivere conversioni "senza ramo" tra interi e Booleani utilizzando codice non sicuro.
  2. ✔️ Usare invece operatori ternari (o altra logica di diramazione). I moderni JIT .NET li ottimizzeranno efficacemente.
  3. ❌ NON leggere bool usando API non sicure, ad Unsafe.ReadUnaligned esempio o MemoryMarshal.Cast se non si considera attendibile l'input. Considerate di usare operatori ternari o confronti di uguaglianza:
// 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; }

Per altre informazioni, vedere Serializzazione e deserializzazione binaria di struct con riempimenti o membri non-blittable.

22. Interoperabilità

Anche se la maggior parte dei suggerimenti in questo documento si applica anche agli scenari di interoperabilità, è consigliabile seguire la guida alle procedure consigliate per l'interoperabilità nativa . È anche consigliabile usare wrapper di interoperabilità generati automaticamente, ad esempio CsWin32 e CsWinRT. Ciò riduce al minimo la necessità di scrivere codice di interoperabilità manuale e riduce il rischio di introdurre problemi di sicurezza della memoria.

23. Thread safety

La sicurezza della memoria e la sicurezza dei thread sono concetti ortogonali. Il codice può essere sicuro per la memoria ma contenere ancora race di dati, letture strappate o bug di visibilità; viceversa, il codice può essere thread-safe mentre richiama ancora un comportamento non definito tramite la manipolazione della memoria non sicura. Per indicazioni più generali, vedere Procedure consigliate per il threading gestito e modello di memoria .NET.

24. Codice non sicuro per SIMD/Vectorization

Per altri dettagli, vedere Linee guida per la vettorializzazione . Nel contesto del codice non sicuro è importante tenere presente quanto segue:

  • Le operazioni SIMD hanno requisiti complessi per fornire garanzie di atomicità (a volte non forniscono affatto tali garanzie).
  • La maggior parte delle API di caricamento/archiviazione SIMD non fornisce controlli dei limiti.

Test di fuzzing

Il test fuzz (o "fuzzing") è una tecnica di test software automatizzata che implica la fornitura di dati non validi, imprevisti o casuali come input a un programma computer. Fornisce un modo per rilevare i problemi di sicurezza della memoria nel codice che potrebbero avere lacune nella copertura dei test. È possibile usare strumenti come SharpFuzz per configurare test fuzz per il codice .NET.

26. Avvisi del compilatore

In genere, il compilatore C# non fornisce un supporto completo, ad esempio avvisi e analizzatori relativi all'utilizzo errato del codice non sicuro. Esistono tuttavia alcuni avvisi esistenti che possono aiutare a rilevare potenziali problemi e non devono essere ignorati o eliminati senza un'attenta considerazione. Alcuni esempi includono:

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

// ptr is used here

Questo codice genera l'avviso CS9123 ("L'operatore '&' non deve essere usato nei parametri o nelle variabili locali nei metodi asincroni"), che implica che il codice è probabilmente errato.

Recommendations

  1. ✔️ Prestare attenzione agli avvisi del compilatore e correggere i problemi sottostanti anziché eliminarli.
  2. ❌ NON presupporre che l'assenza di avvisi del compilatore implica che il codice sia corretto. Il compilatore C# ha un supporto limitato o nullo per rilevare un utilizzo scorretto del codice non sicuro.

References