Delen via


Best practices voor het onveilig schrijven van code

Dit artikel bevat gedetailleerde aanbevelingen voor specifieke onveilige patronen, de risico's die ze met zich meebrengen en hoe u deze risico's kunt beperken. Deze richtlijnen zijn gericht op alle ontwikkelaars die onveilige code schrijven of controleren in C#. Andere .NET-talen, zoals F# en Visual Basic, vallen buiten het bereik van dit artikel, hoewel sommige aanbevelingen ook van toepassing kunnen zijn op die talen.

Glossary

  • AVE - Uitzondering van toegangsschending.
  • Byref : een beheerde aanwijzer (ref T t) die vergelijkbaar is met een niet-beheerde aanwijzer, maar die wordt bijgehouden door de GC. Verwijst doorgaans naar willekeurige onderdelen van objecten of stapels. Verwijzing is feitelijk een beheerste pointer met een +0-offset.
  • CVE : openbaar bekendgemaakte kwetsbaarheden in de cyberbeveiliging.
  • JIT - Just-In-Time-compiler (RyuJIT in CoreCLR en NativeAOT).
  • PGO - Profielgestuurde optimalisatie.
  • Onbeheerde aanwijzer (of onbewerkte aanwijzer): een aanwijzer (T* p) die verwijst naar een willekeurige geheugenlocatie en niet wordt beheerd of bijgehouden door de GC.

Zie .NET Runtime-woordenlijst voor andere termen.

Veelvoorkomende onbetrouwbare patronen

C# biedt een veilige omgeving waar ontwikkelaars zich geen zorgen hoeven te maken over de interne werking van de runtime en de GC. Met onveilige code kunt u deze veiligheidscontroles omzeilen, waardoor mogelijk onbetrouwbare patronen ontstaan die kunnen leiden tot beschadiging van het geheugen. Hoewel dergelijke patronen nuttig kunnen zijn in bepaalde scenario's, moet u ze voorzichtig gebruiken en alleen wanneer dit absoluut noodzakelijk is. C# en .NET bieden niet alleen geen hulpmiddelen om de correctheid van onveilige code te controleren (zoals verschillende C/C++-codecontroleurs kunnen leveren), maar GC-specifieke gedragingen kunnen extra risico's introduceren in onveilige C#-code, risico's die traditionele C/C++-ontwikkelaars mogelijk goed kennen.

Onveilige code rond beheerde verwijzingen moet worden geschreven met de volgende conservatieve veronderstellingen in gedachten:

  • De GC kan de uitvoering van elke methode op elk moment onderbreken bij elke instructie.
  • De GC kan objecten in het geheugen verplaatsen en alle bijgehouden verwijzingen bijwerken.
  • De GC weet precies wanneer verwijzingen niet meer nodig zijn.

Een klassiek voorbeeld van heapbeschadiging treedt op wanneer de GC een objectverwijzing verliest of ongeldige aanwijzers behandelt als heap-verwijzingen. Dit resulteert vaak in niet-deterministische crashes of geheugenbeschadiging. Heap-corruptiebugs zijn bijzonder lastig om te reproduceren en diagnosticeren omdat:

  • Deze problemen kunnen lang verborgen blijven en alleen manifesten na een niet-gerelateerde codewijziging of runtime-update.
  • Ze vereisen vaak nauwkeurige timing om te reproduceren, zoals de GC die uitvoering onderbreekt op een specifieke locatie en heap compactie start, wat een zeldzame en niet-deterministische gebeurtenis is.

In de volgende secties worden veelvoorkomende onveilige patronen beschreven met ✔️ DO en ❌ NIET-aanbevelingen.

1. Niet-bijgehouden beheerde aanwijzers (Unsafe.AsPointer en vrienden)

Het is niet mogelijk om een beheerde (bijgehouden) aanwijzer te converteren naar een niet-beheerde (niet-bijgehouden) aanwijzer in veilige C#. Wanneer een dergelijke behoefte zich voordoet, kan het verleidelijk zijn om Unsafe.AsPointer<T>(T) te gebruiken om de overhead van een fixed statement te vermijden. Hoewel er geldige gebruiksscenario's zijn, bestaat er een risico dat er niet-gevolgde aanwijzers worden gemaakt die betrekking hebben op verplaatsbare objecten. Voorbeeld:

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

Als de GC de uitvoering van de UnreliableCode methode onderbreekt nadat de aanwijzer is gelezen (het adres waarnaar wordt verwezen door x) en het object waarnaar wordt verwezen, verplaatst, wordt de locatie die is opgeslagen in x de GC correct bijgewerkt, maar weet er niets over nativePointer en wordt de waarde die het bevat niet bijgewerkt. Op dat moment betekent schrijven naar nativePointer dat er naar willekeurig geheugen wordt geschreven.

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

Zodra GC de uitvoering van de methode hervat, schrijft deze 42 naar de oude locatie van x, wat kan leiden tot een onverwachte uitzondering, algemene corruptie van de globale staat of procesbeëindiging via een toegangsschending.

De aanbevolen oplossing is in plaats daarvan het trefwoord fixed en de & adresoperator te gebruiken om ervoor te zorgen dat de GC tijdens de duur van de bewerking de doelverwijzing niet kan verplaatsen.

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

Aanbevelingen

  1. ❌ Gebruik geen ref X argumenten met een impliciet contract dat X altijd is toegewezen, vastgemaakt of anderszins niet kan worden losgekoppeld door de GC. Hetzelfde geldt voor gewone objecten en spans: introduceer geen niet-voor de hand liggende contracten voor aanroepers over hun levensduur in methodesignaturen. Overweeg in plaats daarvan een ref struct argument te gebruiken of het argument te wijzigen naar een ruwe pointertype (X*).
  2. ❌ Gebruik geen aanwijzer van Unsafe.AsPointer<T>(T) als deze langer bestaat dan het oorspronkelijke object waarnaar hij verwijst. Volgens de documentatie van de API is het aan de aanroeper om te garanderen dat de GC de verwijzing niet kan verplaatsen. Zorg ervoor dat het voor coderevisoren duidelijk zichtbaar is dat de aanroeper aan deze vereiste heeft voldaan.
  3. ✔️ GEBRUIK GCHandle of fixed scopes in plaats van Unsafe.AsPointer<T>(T) om expliciete scopes te definiëren voor niet-beheerde pointers en om ervoor te zorgen dat het object altijd is gepind.
  4. ✔️ Gebruik niet-beheerde aanwijzers (met) fixedin plaats van byrefs wanneer u een matrix wilt uitlijnen op een specifieke grens. Dit zorgt ervoor dat de GC het object niet verplaatst en eventuele uitlijningsveronderstellingen ongeldig maakt waarop uw logica kan vertrouwen.

2. Aanwijzers buiten het fixed bereik weergeven

Hoewel het vaste trefwoord een bereik definieert voor de aanwijzer die is verkregen uit het vastgemaakte object, is het nog steeds mogelijk voor die aanwijzer om het fixed bereik te ontsnappen en bugs te introduceren, omdat C# geen eigendoms-/levenscyclusbeveiligingen biedt. Een typisch voorbeeld is het volgende fragment:

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 dit voorbeeld wordt de array correct vastgezet met behulp van het fixed trefwoord (waardoor de GC deze niet binnen het fixed blok kan verplaatsen), maar wordt de pointer buiten het fixed blok geëxposeerd. Hiermee wordt een zwevende aanwijzer gemaakt waarvan de deductie resulteert in niet-gedefinieerd gedrag.

Aanbevelingen

  1. ✔️ Zorg ervoor dat aanwijzers in fixed blokken het gedefinieerde bereik niet verlaten.
  2. ✔️ DO geef de voorkeur aan veilige low-level primitieve typen met ingebouwde escape-analyse, zoals C#'s ref struct. Zie Struct-verbeteringen op laag niveau voor meer informatie.

3. Interne implementatiedetails van de runtime en bibliotheken

Hoewel het benaderen of vertrouwen op interne implementatiedetails in het algemeen slecht is (en niet ondersteund door .NET), is het de moeite waard om specifieke veelvoorkomende gevallen te benadrukken. Dit is niet bedoeld om een volledige lijst te zijn van alle mogelijke dingen die fout kunnen gaan wanneer code ongepast afhankelijk is van een interne implementatiedetails.

Aanbevelingen

  1. ❌ Wijzig of lees geen delen van de koptekst van een object.

    • Objectheaders kunnen verschillen in runtimes.
    • In CoreCLR kan de object header niet veilig worden benaderd zonder eerst het object te pinnen.
    • Wijzig nooit het type van het object door de MethodTable-aanwijzer te wijzigen.
  2. ❌ Sla geen gegevens op in de opvulling van een object. Ga er niet van uit dat de inhoud van padding behouden blijft of dat padding altijd standaard op nul wordt ingesteld.

  3. ❌ Maak geen veronderstellingen over de grootten en verschuivingen van iets anders dan primitieven en structs met sequentiële of expliciete indeling. Zelfs dan bestaan er uitzonderingen, zoals wanneer GC-handles betrokken zijn.

  4. ❌ ROEP geen niet-openbare methoden aan, open niet-openbare velden of muteer leesvelden in BCL-typen met weerspiegeling of onveilige code.

  5. ❌ Stel niet dat een niet-openbaar lid in de BCL altijd aanwezig is of een specifieke vorm heeft. Het .NET-team wijzigt of verwijdert af en toe niet-openbare API's in onderhoudsreleases.

  6. ❌ Wijzig velden niet static readonly met behulp van weerspiegeling of onveilige code, omdat ze worden verondersteld constant te zijn. RyuJIT laat ze bijvoorbeeld meestal in lijnen als expliciete constanten.

  7. ❌ Neem niet zomaar aan dat een verwijzing niet verplaatsbaar is. Deze richtlijnen zijn van toepassing op letterlijke tekenreeks- en UTF-8-waarden"..."u8, statische velden, RVA-velden, LOH-objecten, enzovoort.

    • Dit zijn runtime-implementatiedetails die voor sommige runtimes kunnen worden opgeslagen, maar niet voor andere.
    • Onbeheerde aanwijzers naar dergelijke objecten kunnen niet verhinderen dat assembly's worden ontladen, waardoor de aanwijzers verstoord raken. Gebruik fixed scopen om de juistheid te garanderen.
    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. ❌ SCHRIJF GEEN code die afhankelijk is van de implementatiedetails van een specifieke runtime.

4. Ongeldige beheerde aanwijzers (zelfs als ze nooit worden gedereferentieerd)

Bepaalde codecategorieën leunen op aanwijzerbewerking en rekenkundige bewerking, en dergelijke code heeft vaak een keuze tussen het gebruik van onbeheerde aanwijzers (T* p) en beheerde aanwijzers (ref T p). Deze aanwijzers kunnen willekeurig worden gemanipuleerd, bijvoorbeeld via operators op niet-beheerde aanwijzers (p++) en via Unsafe methoden voor beheerde aanwijzers (p = ref Unsafe.Add(ref p, 1)). Beide worden beschouwd als 'onveilige code' en het is mogelijk om onbetrouwbare patronen met beide te maken. Voor bepaalde algoritmen kan het echter eenvoudiger zijn om per ongeluk GC-onveilige patronen te maken bij het bewerken van beheerde aanwijzers. Aangezien niet-beheerde aanwijzers niet worden bijgehouden door de GC, is de waarde die ze bevatten alleen relevant wanneer deze door de code van de ontwikkelaar wordt gedereferenceerd. De waarde van een beheerde aanwijzer is daarentegen niet alleen relevant wanneer deze wordt gededucteerd door de code van de ontwikkelaar, maar ook wanneer deze door de GC wordt onderzocht. Een ontwikkelaar kan dus ongeldige unmanaged pointers maken zonder gevolgen, mits ze niet worden gedereferentieerd, maar het is een bug om een ongeldige managed pointer te maken. Voorbeeld:

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

Vergelijkbare code die byrefs (beheerde aanwijzers) gebruikt, is echter ongeldig.

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
}

Hoewel de beheerde implementatie hier de kleine pinning overhead voorkomt, is dit ondeugdelijk omdat invalidPtr een externe aanwijzer kan worden terwijl het werkelijke adres van array[0] wordt bijgewerkt door de GC. Dergelijke bugs zijn subtiel, en zelfs .NET is er tijdens de ontwikkeling tegenaan gelopen.

Aanbevelingen

  1. ❌ Maak geen ongeldige beheerde pointers, zelfs niet als ze niet worden gedereferentieerd of zich binnen codepaden bevinden die nooit worden uitgevoerd.
  2. ✔️ GEBRUIK vastgemaakte onbeheerde aanwijzers als het algoritme dergelijke manipulaties vereist.

5. Reïnterpretatie-achtige typecasts

Hoewel allerlei soorten struct-to-class- of class-to-struct-casts per definitie een ongedefinieerd gedrag zijn, is het ook mogelijk om onbetrouwbare patronen tegen te komen bij struct-to-struct of class-to-class conversies. Een typisch voorbeeld van een onbetrouwbaar patroon is de volgende code:

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.

En zelfs als de indeling vergelijkbaar is, moet u nog steeds voorzichtig zijn wanneer GC-verwijzingen (velden) betrokken zijn.

Aanbevelingen

  1. ❌ Converteer structuren niet naar klassen of andersom.
  2. ❌ Gebruik niet Unsafe.As voor conversies van struct-to-struct of class-to-class, tenzij u er absoluut zeker van bent dat de cast legaal is. Zie de sectie Opmerkingen van de Unsafe.As API-documenten voor meer informatie.
  3. ✔️ DO prefereren veiliger veld-voor-veld kopiëren, externe bibliotheken zoals AutoMapper of source generators voor dergelijke conversies.
  4. ✔️ DO prefer Unsafe.BitCast boven Unsafe.As, omdat BitCast enkele elementaire gebruikscontroles biedt. Houd er rekening mee dat deze controles geen volledige juistheidsgaranties bieden, wat betekent BitCast dat het nog steeds wordt beschouwd als een onveilige API.

6. Het omzeilen van de schrijfbarrière en niet-atomische bewerkingen op GC-verwijzingen

Normaal gesproken zijn alle soorten schrijf- of leesbewerkingen van GC-verwijzingen altijd atomisch. Ook alle pogingen om een GC-verwijzing (of een byref naar een struct met GC-gerelateerde velden) toe te wijzen aan een mogelijke heap-locatie, worden verwerkt door de schrijfbarrière die ervoor zorgt dat de GC nieuwe verbindingen tussen objecten kan volgen. Met onveilige code kunnen we deze garanties echter omzeilen en onbetrouwbare patronen introduceren. Voorbeeld:

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

Op dezelfde manier is de volgende code met beheerde aanwijzers ook onbetrouwbaar:

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

Aanbevelingen

  1. ❌ GEBRUIK geen niet-atomische bewerkingen op GC-verwijzingen (bijvoorbeeld SIMD-bewerkingen bieden ze vaak niet).
  2. ❌ Gebruik geen onbeheerde aanwijzers om GC-verwijzingen op te slaan in heaplocaties (het weglaten van de schrijfbarrière).

7. Veronderstellingen over levensduur van objecten (finalizers, GC.KeepAlive)

Vermijd aannames over de levensduur van objecten vanuit het perspectief van de GC. Neem in het bijzonder niet aan dat een object nog actief is wanneer het mogelijk niet is. De levensduur van objecten kan variëren tussen verschillende runtimes of zelfs tussen verschillende lagen van dezelfde methode (Laag0 en Laag1 in RyuJIT). Finalizers zijn een veelvoorkomend scenario waarbij dergelijke veronderstellingen onjuist kunnen zijn.

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 dit voorbeeld kan DestroyHandle worden aangeroepen voordat DoWork is voltooid of zelfs voordat het begint. Daarom is het van cruciaal belang om niet uit te gaan dat objecten, zoals this, tot het einde van de methode actief blijven.

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

Daarom wordt het aanbevolen om de levensduur van objecten expliciet uit te breiden met behulp van GC.KeepAlive(Object) of SafeHandle.

Een ander klassiek exemplaar van dit probleem is 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);

Aanbevelingen

  1. ❌ Maak geen aannames over de levensduur van objecten. Neem bijvoorbeeld nooit aan dat this altijd actief blijft tot het einde van de methode.
  2. ✔️ DO gebruiken SafeHandle voor het beheren van systeemeigen resources.
  3. ✔️ GEBRUIK DEZE functie GC.KeepAlive(Object) om de levensduur van objecten zo nodig uit te breiden.

8. Draadoverschrijdende toegang tot lokale variabelen

Toegang tot lokale variabelen vanuit een andere thread wordt over het algemeen als slecht beschouwd. Het wordt echter expliciet niet-gedefinieerd gedrag wanneer beheerde verwijzingen betrokken zijn, zoals wordt beschreven in het .NET-geheugenmodel.

Voorbeeld: Een struct met GC-verwijzingen kan op een niet-threadveilige manier worden overschreven binnen een no-GC-regio terwijl een andere thread deze leest, wat leidt tot ongedefinieerd gedrag.

Aanbevelingen

  1. ❌ Gebruik geen lokale variabelen over threads (vooral als ze GC-referenties bevatten).
  2. ✔️ Gebruik in plaats daarvan heap- of onbeheerd geheugen (bijvoorbeeld NativeMemory.Alloc) .

9. Verwijdering van onveilige grenzencontroles

In C# is er standaard een grenzencontrole bij alle idiomatische geheugentoegang. De JIT-compiler kan deze controles verwijderen als het kan bewijzen dat ze overbodig zijn, zoals in het onderstaande voorbeeld.

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

Hoewel het JIT voortdurend verbetert in het herkennen van dergelijke patronen, zijn er nog steeds scenario's waarin de controles op hun plaats blijven, wat mogelijk invloed heeft op de prestaties van hot code. In dergelijke gevallen bent u misschien geneigd onveilige code te gebruiken om deze controles handmatig te verwijderen zonder de risico's volledig te begrijpen of de prestatievoordelen nauwkeurig te beoordelen.

Denk bijvoorbeeld aan de volgende methode.

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

Als de JIT niet kan bewijzen dat dat index altijd wettelijk binnen de grenzen van arrayvalt, wordt de methode herschreven om er ongeveer als volgt uit te zien.

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

Als u de overhead van die controle in veelvuldig aangeroepen code wilt verminderen, is het misschien verleidelijk om onveilige-equivalente API's (Unsafe en 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);
}

Of gebruik vastzetten en onbewerkte aanwijzers:

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

Dit kan leiden tot willekeurige crashes of beschadiging van de status als index zich buiten de grens van array bevindt. Dergelijke onveilige transformaties kunnen prestatievoordelen hebben voor zeer dynamische paden, maar deze voordelen zijn vaak tijdelijk, omdat elke .NET-release de mogelijkheid verbetert om onnodige grenzencontroles te elimineren wanneer dit veilig is.

Aanbevelingen

  1. ✔️ Controleer of de nieuwste versie van .NET de controle voor grenzen nog steeds niet kan elimineren. Als dat mogelijk is, herschrijft u deze met behulp van veilige code. Dien anders een probleem in tegen de RyuJIT. Gebruik dit traceringsprobleem als een goed uitgangspunt.
  2. ✔️ DO meet de werkelijke impact op de prestaties. Als de prestatiewinst te verwaarlozen is of als de code buiten een triviale microbenchmark niet als performant bewezen is, herschrijft u deze in veilige code.
  3. ✔️ DO provide aanvullende hints aan de JIT, zoals handmatige grenscontroles voordat lussen worden uitgevoerd en velden worden opgeslagen in lokale variabelen, omdat het .NET-geheugenmodel in sommige scenario's kan voorkomen dat de JIT grenscontroles verwijdert.
  4. ✔️ DO code beschermen met Debug.Assert grenscontroles als onveilige code nog steeds noodzakelijk is. Bekijk het onderstaande voorbeeld.
Debug.Assert(array is not null);
Debug.Assert((index >= 0) && (index < array.Length));
// Unsafe code here

U kunt deze controles zelfs herstructureren in herbruikbare helpermethoden.

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

Opname van Debug.Assert biedt geen validatiecontroles voor release-builds, maar kan helpen bij het opsporen van mogelijke fouten in debug-builds.

10. Toegang tot geheugen samenvoegen

Mogelijk bent u geneigd onveilige code te gebruiken om geheugentoegang samen te stellen om de prestaties te verbeteren. Een klassiek voorbeeld is de volgende code om in een tekenmatrix te schrijven "False" :

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

In eerdere versies van .NET was de onveilige versie die wordt gebruikt MemoryMarshal , aanzienlijk sneller dan de eenvoudige veilige versie. Moderne versies van .NET bevatten echter een veel verbeterde JIT die gelijkwaardige codegen produceert voor beide gevallen. Vanaf .NET 10 is het x64-codegen:

; 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

Er is een nog eenvoudigere en beter leesbare versie van de code:

"False".CopyTo(dst);

Vanaf .NET 10 produceert deze aanroep identieke codegen zoals hierboven. Het heeft zelfs een extra voordeel: het geeft de JIT aan dat strikte schrijfbewerkingen per element niet atomair hoeven te zijn. De JIT kan deze hint combineren met andere contextuele kennis om nog meer optimalisaties te bieden dan wat hier is besproken.

Aanbevelingen

  1. ✔️ Geef de voorkeur aan idiomatische veilige code boven onveilige code voor het optimaliseren van geheugentoegang:
    • Geef de voorkeur Span<T>.CopyTo aan en Span<T>.TryCopyTo voor het kopiëren van gegevens.
    • Geef de voorkeur aan String.Equals en Span<T>.SequenceEqual voor het vergelijken van gegevens, zelfs wanneer u StringComparer.OrdinalIgnoreCase gebruikt.
    • Geef de voorkeur Span<T>.Fill aan het invullen van gegevens en Span<T>.Clear voor het wissen van gegevens.
    • Houd er rekening mee dat schrijf-/leesbewerkingen per element of per veld automatisch door JIT kunnen worden gekoppeld.
  2. ✔️ Stel een probleem bij dotnet/runtime als u idiomatische code schrijft en constateert dat deze niet naar verwachting is geoptimaliseerd.
  3. ❌ Sluit geheugentoegang niet handmatig samen als u niet zeker weet of er sprake is van verkeerd uitgelijnde risico's voor geheugentoegang, atomiciteitsgaranties of de bijbehorende prestatievoordelen.

11. Niet-uitgelijnde geheugentoegang

Het samenvoegen van geheugentoegang die wordt beschreven in geheugentoegang, leidt vaak tot expliciete of impliciete onjuist uitgelijnde lees-/schrijfbewerkingen. Hoewel dit meestal geen ernstige problemen veroorzaakt (afgezien van mogelijke prestatiestraffen vanwege het overschrijden van cache- en paginagrenzen), vormt het nog steeds een aantal echte risico's.

Denk bijvoorbeeld aan het scenario waarin u twee elementen van een matrix tegelijk wist:

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

Stel dat de vorige waarden op deze locaties beide uint.MaxValue (0xFFFFFFFF) waren. Het .NET-geheugenmodel garandeert dat beide schrijfbewerkingen atomisch zijn, dus alle andere threads in het proces zullen alleen de nieuwe waarde 0 of de oude waarde 0xFFFFFFFFobserveren, nooit "gescheurde" waarden zoals 0xFFFF0000.

Stel echter dat de volgende onveilige code wordt gebruikt om de grenzencontrole te omzeilen en beide elementen nul te maken met één 64-bits opslag:

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

Deze code heeft het neveneffect van het verwijderen van de atomiciteitsgarantie. Gescheurde waarden kunnen worden waargenomen door andere threads, wat leidt tot niet-gedefinieerd gedrag. Voor een dergelijke samengevoegde schrijfbewerking moet het geheugen uitgelijnd zijn aan de grootte van de schrijfbewerking (8 bytes in dit geval). Als u het geheugen handmatig wilt uitlijnen vóór de bewerking, moet u er rekening mee houden dat de GC de array op elk moment kan verplaatsen en daardoor de uitlijning kan wijzigen als deze niet vastgezet is. Zie de documentatie voor het .NET-geheugenmodel voor meer informatie.

Een ander risico op niet-uitgelijnde geheugentoegang is het potentieel dat een toepassing vastloopt in bepaalde scenario's. Hoewel sommige .NET-runtimes afhankelijk zijn van het besturingssysteem om verkeerd uitgelijnde toegangen op te lossen, zijn er nog steeds enkele scenario's op sommige platforms waar verkeerd uitgelijnde toegang tot een DataMisalignedException (of SEHException) kan leiden. Enkele voorbeelden zijn:

  • Interlocked bewerkingen op onjuist uitgelijnd geheugen op sommige platforms (voorbeeld).
  • Verkeerd uitgelijnde drijvendekommabewerkingen in ARM.
  • Toegang tot speciaal apparaatgeheugen met bepaalde uitlijningsvereisten (niet echt ondersteund door .NET).

Aanbevelingen

  1. ❌ Gebruik geen niet-uitgelijnde geheugentoegang in vergrendelingsvrije algoritmen en andere scenario's waarbij atomiciteit belangrijk is.
  2. ✔️ LIJN indien nodig gegevens handmatig uit, maar houd er rekening mee dat de GC objecten op elk gewenst moment kan verplaatsen, waardoor de uitlijning dynamisch kan worden gewijzigd. Dit is vooral belangrijk voor verschillende StoreAligned/LoadAligned API's in SIMD.
  3. ✔️ GEBRUIK expliciete niet-uitgelijnde lees-/schrijf-API's, zoals Unsafe.ReadUnaligned/Unsafe.WriteUnaligned in plaats van uitgelijnde API's, zoalsUnsafe.Read<T>(Void*)/Unsafe.Write<T>(Void*, T) of Unsafe.As<TFrom,TTo>(TFrom) als gegevens mogelijk onjuist zijn uitgelijnd.
  4. ✔️ Houd er rekening mee dat verschillende API's voor geheugenmanipulatie, zoals Span<T>.CopyTo(Span<T>) ook geen atomiciteitsgaranties bieden.
  5. ✔️ Raadpleeg de documentatie van .NET Memory Model (zie verwijzingen) voor meer informatie over atomiciteitsgaranties.
  6. ✔️ DO meet de prestaties op al uw doelplatforms, omdat sommige platforms een aanzienlijke prestatiestraf opleggen voor niet-uitgelijnde geheugentoegang. Het kan zijn dat op deze platforms naïeve code beter presteert dan slimme code.
  7. ✔️ Houd er rekening mee dat er scenario's en platforms zijn waarbij niet-uitgelijnde geheugentoegang tot een uitzondering kan leiden.

12. Binaire (de)serialisatie van structuren met opvullingen of niet-blittable leden

Wees voorzichtig wanneer u verschillende serialisatie-achtige API's gebruikt voor het kopiëren of lezen van structs naar of van bytematrices. Als een struct opvulling of niet-belichte leden (bijvoorbeeld bool of GC-velden) bevat, kunnen klassieke onveilige geheugenbewerkingen zoals Fill, CopyToen SequenceEqual per ongeluk gevoelige gegevens van de stack naar de opvullingen kopiëren of garbagegegevens behandelen als belangrijk tijdens vergelijkingen waardoor zelden reproduceerbare bugs ontstaan. Een veelvoorkomend antipatroon kan er als volgt uitzien:

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

De enige juiste benadering is het gebruik van veldspecifieke laad/opslagmechanismen die op maat zijn gemaakt voor elke TObject invoer (of gegeneraliseerd met Reflection, Source Generators of de (de)serialisatiebibliotheken).

Aanbevelingen

  1. ❌ Gebruik geen onveilige code om structs met opvullingen of niet-blittable leden te kopiëren, laden of vergelijken. Het laden van niet-vertrouwde invoer is problematisch, zelfs voor basistypen zoals bool of decimal. Tegelijkertijd kunnen opslagplaatsen gevoelige informatie van de stack per ongeluk reeksgewijs opslaan in de tussenruimten/vulruimten van een struct.
  2. ❌ Vertrouw niet op T : unmanaged beperking, RuntimeHelpers.IsReferenceOrContainsReferencesof vergelijkbare API's om te garanderen dat een algemeen type veilig is om bitsgewijze bewerkingen uit te voeren. Op het moment van schrijven van deze richtlijnen is er geen betrouwbare programmatische manier om te bepalen of het legaal is om willekeurige bitwise bewerkingen uit te voeren op een bepaald type.
    • Als u een dergelijke bitwise manipulatie moet uitvoeren, doet u dit alleen op basis van deze vastgelegde lijst met typen en moet u op de hoogte zijn van de endianiteit van de huidige computer:
      • De primitieve integrale typen Byte, , SByte, Int16, UInt16, Int32, UInt32, , en Int64;UInt64
      • Een Enum ondersteund door een van de bovenstaande primitieve integrale typen;
      • Char, Int128, UInt128, Half, Single, Double, IntPtr, UIntPtr.
  3. ✔️ Gebruik in plaats daarvan veld-op-veld laden/store (de)serialisatie. Overweeg populaire en veilige bibliotheken te gebruiken voor (de)serialisatie.

13. Door null beheerde aanwijzers

Over het algemeen zijn byrefs (beheerde aanwijzers) zelden null en de enige veilige manier om momenteel een null byref te maken, is door een met ref struct te default initialiseren. Vervolgens zijn alle ref velden null-beheerde aanwijzers:

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

Er zijn echter verschillende onveilige manieren om null byrefs te maken. Enkele voorbeelden zijn:

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

Het risico op het introduceren van problemen met geheugenveiligheid is laag, en elke poging om een null byref te derefereren leidt tot een duidelijk gedefinieerde NullReferenceException. De C#-compiler gaat er echter van uit dat het uitstellen van een byref altijd slaagt en geen waarneembaar neveneffect produceert. Daarom is het een legale optimalisatie om elke dereferentie weg te laten waarvan de resulterende waarde meteen wordt weggegooid. Zie dotnet/runtime#98681 (en deze gerelateerde opmerking) voor een voorbeeld van een nu opgeloste fout in .NET, waarbij bibliotheekcode onjuist afhankelijk was van de deductie die een neveneffect activeert, niet weet dat de C#-compiler de beoogde logica effectief heeft onderbroken.

Aanbevelingen

  1. ❌ Maak geen null byrefs in C# als dit niet nodig is. Overweeg in plaats daarvan normale beheerde verwijzingen, het Null-objectpatroon of lege spanten te gebruiken.
  2. ❌ Negeer het resultaat van een byref-doorverwijzing niet, omdat deze mogelijk kan worden geoptimaliseerd en kan leiden tot potentiële bugs.

14. stackalloc

stackalloc is historisch gebruikt om kleine, niet-ontsnappende matrices op de stapel te maken, waardoor de GC-druk wordt verminderd. In de toekomst kan de Escape Analysis van JIT beginnen met het optimaliseren van niet-escaping GC-toewijzingen van arrays naar stackobjecten, waardoor stackalloc mogelijk overbodig wordt. Tot die tijd blijft het stackalloc nuttig voor het toewijzen van kleine buffers aan de stack. Voor grotere buffers of escape-buffers wordt deze vaak gecombineerd met ArrayPool<T>.

Aanbevelingen

  1. ✔️ DO consumeer stackalloc altijd in ReadOnlySpan<T>/Span<T> aan de linkerkant van de expressie om grenzencontroles te bieden.

    // 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. ❌ Gebruik geen stackalloc binnenlussen. De stackruimte wordt pas vrijgemaakt totdat de methode terugkeert, dus als een stackalloc in een lus is opgenomen, kan dit leiden tot procesbeëindiging vanwege een stack overflow.

  3. ❌ GEBRUIK geen grote lengtes voor stackalloc. 1024 bytes kunnen bijvoorbeeld worden beschouwd als een redelijke bovengrens.

  4. ✔️ Controleer het bereik van variabelen die worden gebruikt als stackalloc lengten.

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

    Vaste versie:

    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. ✔️ GEBRUIK moderne C#-functies zoals letterlijke verzamelingen (Span<int> s = [1, 2, 3];), params Span<T>en inlinematrices om handmatig geheugenbeheer te voorkomen, indien mogelijk.

15. Buffers met vaste grootte

Buffers met een vaste grootte waren nuttig voor interopscenario's met gegevensbronnen uit andere talen of platforms. Ze werden vervolgens vervangen door veiliger en handiger inlinematrices. Een voorbeeld van een buffer met vaste grootte (context vereist unsafe ) is het volgende fragment:

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.

Een modern en veiliger alternatief is inline arrays:

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

Een andere reden om buffers met een vaste grootte te vermijden ten gunste van inline arrays, die standaard altijd op nul zijn geïnitialiseerd, is dat buffers met een vaste grootte mogelijk niet-nul geïnitialiseerde inhoud hebben in bepaalde scenario's.

Aanbevelingen

  1. ✔️ Vervang waar mogelijk buffers met een vaste grootte door inline-arrays of IL-marshalling-kenmerken.

16. Aaneengesloten gegevens doorgeven met opwijzers en lengtes (of afhankelijk zijn van nul-terminatie)

Vermijd het definiëren van API's die niet-beheerde of beheerde aanwijzers accepteren voor aaneengesloten gegevens. Gebruik in plaats daarvan Span<T> of 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);

Nulbeëindiging is bijzonder riskant omdat niet alle buffers nul worden beëindigd en het lezen voorbij een nul-afsluiter kan leiden tot openbaarmaking van informatie, beschadiging van gegevens of procesbeëindiging via een toegangsschending.

Aanbevelingen

  1. ❌ Maak geen methoden beschikbaar waarvan de argumenten pointertypen zijn (niet-beheerde aanwijzers T* of beheerde aanwijzers ref T) wanneer deze argumenten zijn bedoeld om buffers weer te geven. Gebruik veilige buffertypen zoals Span<T> of ReadOnlySpan<T> in plaats daarvan.

  2. ❌ Gebruik geen impliciete contracten voor byref-argumenten, zoals het vereisen van alle bellers om de invoer toe te wijzen aan de stack. Als een dergelijk contract nodig is, kunt u overwegen in plaats daarvan refstruct te gebruiken.

  3. ❌ Stel niet dat buffers nul-terminatie hebben, tenzij in het scenario uitdrukkelijk wordt aangegeven dat dit een geldige aanname is. Hoewel .NET bijvoorbeeld garandeert dat string exemplaren en "..."u8 letterlijke waarden nul-terminated zijn, geldt dit niet voor andere buffertypen zoals ReadOnlySpan<char> of 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. ❌ Geef geen vastgepinde Span<char> of ReadOnlySpan<char> over een p/invoke-grens door, tenzij u ook een expliciet lengteargument hebt meegegeven. Anders kan de code aan de andere kant van de grens p/invoke onjuist geloven dat de buffer null-beëindigd is.

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

U kunt dit oplossen door een alternatieve p/invoke-handtekening te gebruiken die, indien mogelijk, zowel de gegevensaanwijzer als de lengte accepteert. Als de ontvanger geen enkele manier heeft om een afzonderlijk lengteargument te accepteren, moet u ervoor zorgen dat de oorspronkelijke gegevens worden geconverteerd naar een string voordat deze worden vastgemaakt en doorgegeven aan de grens 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. Tekenreeksmutaties

Tekenreeksen in C# zijn standaard onveranderbaar en elke poging om ze te muteren met behulp van onveilige code kan leiden tot niet-gedefinieerd gedrag. Voorbeeld:

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

Het wijzigen van een geïnterneerde tekenreeks (de meeste letterlijke tekenreeksen zijn) zal de waarde voor alle andere toepassingen veranderen. Zelfs zonder het interneren van tekenreeksen, moet het schrijven naar een nieuw gemaakte tekenreeks worden vervangen door de veiligere 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
});

Aanbevelingen

  1. ❌ Wijzig tekenreeksen niet. Gebruik de String.Create API om een nieuwe tekenreeks te maken als complexe kopieerlogica nodig is. Gebruik anders .ToString(), StringBuilder, new string(...) syntaxis, of syntax voor interpolatie van tekenreeksen.

18. Onbewerkte IL-code (bijvoorbeeld System.Reflection.Emit en Mono.Cecil)

Het verzenden van onbewerkte IL (ofwel via System.Reflection.Emitbibliotheken van derden zoals Mono.Cecil, of het rechtstreeks schrijven van IL-code) overzeilt alle garanties voor geheugenveiligheid die C# biedt. Vermijd het gebruik van dergelijke technieken, tenzij dit absoluut noodzakelijk is.

Aanbevelingen

  1. ❌ Zend geen onbewerkte IL-code uit omdat deze zonder kaders wordt geleverd en het maakt het gemakkelijk om typeveiligheid en andere problemen te introduceren. Net als bij andere technieken voor het genereren van dynamische code is het verzenden van onbewerkte IL ook niet AOT-vriendelijk als dit niet op het moment van bouwen wordt gedaan.
  2. ✔️ Gebruik in plaats daarvan brongeneratoren, indien mogelijk.
  3. ✔️ DO prefer [UnsafeAccessor] in plaats van het genereren van onbewerkte IL voor het schrijven van serialisatiecode met een lage overhead voor privéleden als de behoefte zich voordoet.
  4. ✔️ Dien een API-voorstel in bij dotnet/runtime als er een API ontbreekt en u gedwongen bent onbewerkte IL-code te gebruiken.
  5. ✔️ GEBRUIK ilverify of vergelijkbare hulpprogramma's om de verzonden IL-code te valideren als u onbewerkte IL moet gebruiken.

19. Niet-geïnitialiseerde lokale variabelen [SkipLocalsInit] en Unsafe.SkipInit

[SkipLocalsInit] is geïntroduceerd in .NET 5.0 om het JIT toe te staan om lokale variabelen in methoden niet te initialiseren met nullen, of dit nu per methode of modulebreed gebeurt. Deze functie is vaak gebruikt om de JIT te helpen redundante nul-initialisaties te elimineren, zoals die voor stackalloc. Het kan echter leiden tot niet gedefinieerd gedrag als lokale variabelen niet expliciet worden geïnitialiseerd voordat deze worden gebruikt. Met recente verbeteringen in het vermogen van JIT om nul-initialisaties te elimineren en vectorisatie uit te voeren, is de behoefte aan [SkipLocalsInit] en Unsafe.SkipInit is aanzienlijk afgenomen.

Aanbevelingen

  1. ❌ Gebruik niet [SkipLocalsInit] en Unsafe.SkipInit als er geen prestatievoordelen in hot code worden waargenomen of als u niet zeker weet welke risico's ze veroorzaken.
  2. ✔️ DO programmeer defensief bij het gebruik van API's zoals GC.AllocateUninitializedArray en ArrayPool<T>.Shared.Rent, die eveneens niet-geïnitialiseerde buffers kunnen retourneren.

20. ArrayPool<T>.Shared en vergelijkbare pool-API's

ArrayPool<T>.Shared is een gedeelde groep matrices die worden gebruikt om de GC-druk in hot code te verminderen. Het wordt vaak gebruikt voor het toewijzen van tijdelijke buffers voor I/O-bewerkingen of andere scenario's met korte levensduur. Hoewel de API eenvoudig is en geen inherent onveilige functies bevat, kan deze situatie leiden tot gebruik-na-vrijgeven bugs in C#. Voorbeeld:

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

Elk gebruik van _buffer na het Return oproep is een use-after-free-bug. Dit minimale voorbeeld is gemakkelijk te herkennen, maar de fout wordt moeilijker te detecteren wanneer Rent en Return zich in verschillende bereiken of methoden bevinden.

Aanbevelingen

  1. ✔️ Houd indien mogelijk overeenkomende aanroepen naar Rent en Return binnen dezelfde methode om het bereik van potentiële bugs te beperken.
  2. ❌Gebruik geen try-finally patroon om Return in het finally blok aan te roepen, tenzij u zeker weet dat de mislukte logica de buffer niet meer gebruikt. Het is beter om de buffer los te laten in plaats van een use-after-free-bug te riskeren door een onverwachte vroege Return.
  3. ✔️ Houd er rekening mee dat soortgelijke problemen kunnen optreden met andere pool-API's of patronen, zoals ObjectPool<T>.

21. bool<->int conversies

Hoewel ECMA-335-standaard een Booleaanse waarde definieert als 0-255, waarbij true geen nulwaarde is, is het beter om expliciete conversies tussen gehele getallen en Booleaanse waarden te vermijden om te voorkomen dat 'gedenormaliseerde' waarden als iets anders dan 0 of 1 leiden tot onbetrouwbaar gedrag.

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

De JIT die aanwezig was in eerdere .NET-runtimes, heeft de veilige versie van deze logica niet volledig geoptimaliseerd, wat ertoe leidde dat ontwikkelaars onveilige constructies gebruikten om tussen bool en int te converteren in prestatiegevoelige codepaden. Dit is niet langer het geval en moderne .NET JIT's kunnen de veilige versie effectief optimaliseren.

Aanbevelingen

  1. ❌ SCHRIJF GEEN vertakkingloze conversies tussen gehele getallen en Booleaanse waarden met behulp van onveilige code.
  2. ✔️ GEBRUIK in plaats daarvan ternaire operators (of andere vertakkingslogica). Moderne .NET JIT's optimaliseren ze effectief.
  3. ❌ Lees niet bool met onveilige API's, zoals Unsafe.ReadUnaligned of MemoryMarshal.Cast als u de invoer niet vertrouwt. Overweeg in plaats daarvan ternaire operatoren of gelijkheidsvergelijkingen te gebruiken:
// 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; }

Zie Binary (de)serialisatie van structs met opvullingen of niet-belichte leden voor meer informatie.

22. Interop

Hoewel de meeste suggesties in dit document ook van toepassing zijn op interoperabiliteitsscenario's, is het raadzaam om de aanbevolen procedures voor systeemeigen interoperabiliteit te volgen. U kunt ook automatisch gegenereerde interop-wrappers gebruiken, zoals CsWin32 en CsWinRT. Dit minimaliseert de noodzaak voor het schrijven van handmatige interoperabiliteitscode en vermindert het risico op het introduceren van problemen met geheugenveiligheid.

23. Draadveiligheid

Geheugenveiligheid en draadveiligheid zijn orthogonale concepten. Code kan geheugenveilig zijn, maar toch gegevensraces, gescheurde leesbewerkingen of zichtbaarheidsfouten bevatten; code kan daarentegen wel draadveilig zijn en toch niet-gedefinieerd gedrag oproepen via onveilige geheugenmanipulatie. Zie Best practices voor beheerde threading en .NET-geheugenmodel voor bredere richtlijnen.

24. Onveilige code rond SIMD/Vectorization

Zie Vectorization-richtlijnen voor meer informatie. In de context van de onveilige code is het belangrijk om rekening te houden met:

  • SIMD-bewerkingen hebben complexe vereisten om atomiciteitsgaranties te bieden (soms bieden ze ze helemaal niet).
  • De meeste SIMD Load/Store-API's bieden geen controles voor grenzen.

25. Fuzz testen

Fuzz testen (of 'fuzzing') is een geautomatiseerde softwaretesttechniek waarbij ongeldige, onverwachte of willekeurige gegevens worden verstrekt als invoer voor een computerprogramma. Het biedt een manier om problemen met geheugenveiligheid in code te detecteren die mogelijk hiaten in de testdekking hebben. U kunt hulpprogramma's zoals SharpFuzz gebruiken om fuzz-tests in te stellen voor .NET-code.

26. Compilerwaarschuwingen

Over het algemeen biedt de C#-compiler geen uitgebreide ondersteuning, zoals waarschuwingen en analyses rond onjuist onveilig codegebruik. Er zijn echter enkele bestaande waarschuwingen die kunnen helpen bij het detecteren van mogelijke problemen en mogen niet worden genegeerd of onderdrukt zonder zorgvuldige overwegingen. Enkele voorbeelden zijn:

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

// ptr is used here

Deze code produceert waarschuwing CS9123 ('De operator '&' mag niet worden gebruikt voor parameters of lokale variabelen in asynchrone methoden), wat impliceert dat de code waarschijnlijk onjuist is.

Aanbevelingen

  1. ✔️ Let wel op compilerwaarschuwingen en los de onderliggende problemen op in plaats van ze te onderdrukken.
  2. ❌ Stel niet dat het ontbreken van compilerwaarschuwingen impliceert dat de code juist is. De C#-compiler heeft beperkt tot geen ondersteuning voor het detecteren van onjuist onveilig codegebruik.

References