📁 Sangam Dhakal - Portfolio v2.0 - Netscape Navigator 4.0
_
□
×

📚 Interview question

When is unsafe code justified in modern C#, and what tools exist beyond raw pointers? Explain unsafe blocks, pointers and fixed (pinning), function pointers, NativeMemory, the System.Runtime.CompilerServices.Unsafe class, MemoryMarshal, what can go wrong (GC holes, buffer overruns, pinning cost), and how to keep unsafe code safe.

Unsafe C#: pointers, fixed, NativeMemory, the Unsafe class and MemoryMarshal, risks and guidelines.

Senior Theory Expert interop memorymarshal performance pointers unsafe

🎯 Key points

  • Unsafe code needs AllowUnsafeBlocks and an unsafe context, and removes memory safety checks
  • fixed pins managed objects only for its block; long pins fragment the heap
  • NativeMemory allocates outside the GC heap and must be freed exactly once
  • The Unsafe class and MemoryMarshal work with managed refs but skip bounds and type checks
  • Managed refs are GC-tracked; raw pointers to unpinned objects create GC holes
  • Justify unsafe code with interop or measured hot paths; the JIT often removes bounds checks already
  • Isolate unsafe code behind safe APIs, validate inputs, fuzz and benchmark

⏱ 30-second answer

Unsafe code turns off the compiler's memory safety checks so you can use pointers, fixed (pinning managed objects), sizeof on any unmanaged type, function pointers (delegate* unmanaged, C# 9) and NativeMemory (.NET 6). It needs <AllowUnsafeBlocks>true</AllowUnsafeBlocks> and an unsafe context. The Unsafe class (Unsafe.As, Unsafe.Add, Unsafe.ReadUnaligned, Unsafe.BitCast) and MemoryMarshal (GetReference, Cast, AsBytes, CreateSpan) give similar power with managed refs, often without the unsafe keyword, but they are just as dangerous. It is justified for interop, proven hot paths where spans and the JIT are not enough, and low-level libraries. Risks are buffer overruns, use-after-free, GC holes from storing raw pointers to movable objects, and heap fragmentation from long pinning. Keep it small, isolated, reviewed, fuzzed and benchmarked.

🗣 Full answer (senior)

How I would answer this in an interview

Unsafe code is justified only when measured performance or interop needs can't be met with safe APIs — and in modern .NET that's rare, because Span<T>, MemoryMarshal, Unsafe, NativeMemory and source-generated interop cover most cases. I'd explain the tools from raw pointers to safer alternatives, what can go wrong (GC holes, overruns, pinning cost), and how to keep unsafe code contained and verified.

1. Unsafe blocks, pointers and fixed

// <AllowUnsafeBlocks>true</AllowUnsafeBlocks>
public static unsafe int Checksum(byte[] data)
{
    int sum = 0;
    fixed (byte* p = data)                    // pins the array so the GC can't move it
    {
        byte* end = p + data.Length;
        for (byte* q = p; q < end; q++) sum += *q;   // no bounds checks
    }
    return sum;
}
  • unsafe enables pointer types (T*), address-of (&), dereference (*), pointer arithmetic, sizeof for any unmanaged type and stackalloc to pointers.
  • fixed pins a managed object (array, string, field) for the duration of the block and gives a pointer. While pinned, the GC can't compact around it.
  • Pointers only work with unmanaged types.

2. Function pointers (C# 9)

delegate* unmanaged[Cdecl]<int, int, int> nativeAdd = (delegate* unmanaged[Cdecl]<int, int, int>)NativeLibrary.GetExport(handle, "add");
int r = nativeAdd(1, 2);

[UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])]
static int Callback(int x) => x * 2;
delegate* unmanaged[Cdecl]<int, int> cb = &Callback;   // pass to native code without a delegate/GCHandle

No delegate allocation, no marshalling stubs — used for high-performance interop and callbacks.

3. NativeMemory

void* p = NativeMemory.Alloc((nuint)size);             // malloc-like
void* aligned = NativeMemory.AlignedAlloc((nuint)size, 64);
try { var span = new Span<byte>(p, size); /* use */ }
finally { NativeMemory.Free(p); NativeMemory.AlignedFree(aligned); }

.NET 6+ API for unmanaged allocations (replacing Marshal.AllocHGlobal in most cases). Memory isn't tracked by the GC — you must free it (wrap in a SafeHandle or MemoryManager<T>), and use GC.AddMemoryPressure for large allocations so the GC knows.

4. System.Runtime.CompilerServices.Unsafe

Generic, mostly pointer-free helpers operating on refs:

  • Unsafe.Add(ref T, int), Unsafe.Subtract, Unsafe.AreSame, Unsafe.IsAddressLessThan — ref arithmetic.
  • Unsafe.As<TFrom, TTo>(ref TFrom) / Unsafe.As<T>(object) — reinterpret without checks.
  • Unsafe.ReadUnaligned<T>, WriteUnaligned<T>, CopyBlock, InitBlock.
  • Unsafe.SkipInit, Unsafe.NullRef<T>, Unsafe.IsNullRef.

They compile to single IL instructions and are what the BCL uses inside spans. They don't require the unsafe keyword but are equally dangerous.

5. MemoryMarshal

  • MemoryMarshal.Cast<TFrom, TTo>(span) — reinterpret a span of bytes as a span of structs (unmanaged types).
  • MemoryMarshal.Read<T>/Write<T>/TryRead — read structs from bytes (with endianness caveats; prefer BinaryPrimitives for wire formats).
  • MemoryMarshal.GetReference(span), GetArrayDataReference(array) — refs to first elements (no bounds check on empty).
  • MemoryMarshal.CreateSpan, CreateReadOnlySpan — build spans from refs (can bypass read-only-ness).
  • MemoryMarshal.AsBytes, TryGetArray, TryGetString.

These are "safe-looking" APIs that can break safety if misused (e.g. writing into strings, reading past ends).

6. What can go wrong

  • GC holes: holding a raw pointer to a managed object without pinning — the GC moves the object, the pointer now points to garbage or another object. Converting a ref to a pointer and storing it beyond fixed is the classic GC hole; bugs appear randomly under GC pressure.
  • Buffer overruns/underruns: no bounds checks — reading/writing outside buffers corrupts memory, causes crashes far from the bug, or creates security vulnerabilities (information disclosure, code execution).
  • Use-after-free with native memory or pooled buffers.
  • Pinning cost: long-lived pins fragment the heap (the GC can't compact around them); many short pins in hot paths add overhead. Use the Pinned Object Heap (GC.AllocateArray<T>(n, pinned: true)) for buffers that must stay pinned (e.g. long-running native I/O).
  • Type confusion: Unsafe.As reinterpreting objects of the wrong type.
  • Alignment and endianness issues when reinterpreting bytes.
  • Non-portable assumptions about layout (auto-layout structs, 32/64-bit sizes).

7. How to keep unsafe code safe

  1. Justify with measurements (BenchmarkDotNet, profiler) or interop requirements — try spans and safe APIs first.
  2. Isolate unsafe code in a small, well-named internal class/assembly; expose a safe API (spans in, spans out, validated lengths).
  3. Validate all sizes and indices at the boundary before entering unsafe code.
  4. Prefer refs/Unsafe over raw pointers, and spans over both, so the GC tracks references.
  5. Pin minimally (fixed blocks) and never let pointers escape their scope.
  6. Own native memory with SafeHandle/MemoryManager<T> and deterministic disposal.
  7. Test aggressively: unit tests at boundaries, fuzzing (SharpFuzz), stress tests with GC pressure (DOTNET_GCgen0size small, GCStress in CI on special builds), and differential tests against a safe implementation.
  8. Code review by someone experienced; document invariants in comments.
  9. Prefer source-generated interop ([LibraryImport]) over manual marshalling.

8. A production example

An image-processing step used fixed pointers and manual loops to convert pixel formats. A refactor moved part of the loop into a helper that stored a pointer in a field for "reuse" — outside the fixed scope. Under load, images occasionally came out corrupted and once the process crashed with an access violation. We rewrote the core with Span<T>, MemoryMarshal.Cast and Vector128<T> intrinsics; the safe version matched the unsafe one's performance within a few percent, and the corruption disappeared. The remaining unsafe interop with a native codec was wrapped in a SafeHandle and fuzz-tested.

🔍 What the interviewer is checking

The interviewer checks you know the low-level tools and, more importantly, when not to use them.

Green flags

  • Knows pointers, fixed, NativeMemory, function pointers, Unsafe and MemoryMarshal.
  • Explains GC holes, overruns and pinning costs.
  • Justifies unsafe code only with interop or measured hot paths.
  • Describes isolation, validation, fuzzing and benchmarking.

Red flags

  • Uses unsafe code as a default optimisation.
  • Thinks Unsafe class calls are safe.
  • Keeps raw pointers to managed objects outside fixed.

📚 Learn in depth

1. Pinning mechanics

  • fixed emits a pinned local — the JIT reports it to the GC as pinned while in scope. Very cheap when no GC occurs during the block.
  • GCHandle.Alloc(obj, GCHandleType.Pinned) pins until freed — needed for async native operations; easy to leak.
  • Memory<T>.Pin() returns a MemoryHandle (disposable) wrapping the pin.
  • POH (.NET 5+): GC.AllocateUninitializedArray<T>(n, pinned: true) allocates on the Pinned Object Heap, which never moves objects — no fragmentation of SOH.

2. GC holes in detail

unsafe byte* Leak(byte[] data) { fixed (byte* p = data) return p; }   // pointer escapes the fixed scope

After Leak returns, data can move; the pointer is stale. The compiler doesn't stop you. With refs (ref byte), the GC would update the reference; raw pointers aren't tracked.

3. Unsafe.As object reinterpretation

Unsafe.As<Derived>(baseObj) skips the type check of a cast. If the object isn't actually Derived, field accesses read wrong memory — corruption without exceptions. The BCL uses it only where the type is guaranteed.

4. Hardware intrinsics as a safer performance path

Vector128<T>, Vector256<T>, Vector512<T> and Vector<T> (with IsHardwareAccelerated checks) let you write SIMD code over spans, often removing the need for pointer code. Many BCL span methods are already vectorised.

5. Security considerations

Unsafe code bypasses the CLR's memory safety guarantees — bugs can become exploitable vulnerabilities, especially when processing untrusted input (file parsers, network protocols). Treat unsafe parsers as security-sensitive: fuzz them and review them like native code.

Try it yourself

  1. Implement a checksum with pointers, with spans, and with Vector<T>; benchmark all three.
  2. Write a MemoryManager<byte> over NativeMemory and use it with Memory<byte>.
  3. Use MemoryMarshal.Cast<byte, int> on a span and check alignment/endianness assumptions.
  4. Fuzz a small unsafe parser with SharpFuzz.

💻 Code

// Project file needs <AllowUnsafeBlocks>true</AllowUnsafeBlocks>
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;

public static class LowLevel
{
    // 1. fixed + pointers: pin the array only for this block
    public static unsafe int SumPinned(int[] values)
    {
        int sum = 0;
        fixed (int* p = values)
        {
            for (int i = 0; i < values.Length; i++) sum += p[i];   // no bounds checks: i must be valid
        }
        return sum;
    }

    // 2. Managed refs with Unsafe/MemoryMarshal: GC-safe, still unchecked
    public static int SumUnchecked(ReadOnlySpan<int> values)
    {
        ref int start = ref MemoryMarshal.GetReference(values);
        int sum = 0;
        for (int i = 0; i < values.Length; i++)
            sum += Unsafe.Add(ref start, i);                        // validated by the loop bound only
        return sum;
    }

    // 3. Reinterpreting binary data without copying
    public static int ReadLittleEndianInt(ReadOnlySpan<byte> data)
    {
        if (data.Length < sizeof(int)) throw new ArgumentException("Too short", nameof(data)); // validate first
        int raw = Unsafe.ReadUnaligned<int>(ref MemoryMarshal.GetReference(data));
        return BitConverter.IsLittleEndian ? raw : System.Buffers.Binary.BinaryPrimitives.ReverseEndianness(raw);
    }

    // 4. Native memory outside the GC heap
    public static unsafe long NativeChecksum(int size)
    {
        byte* buffer = (byte*)NativeMemory.Alloc((nuint)size);
        try
        {
            var span = new Span<byte>(buffer, size);               // safe view over native memory
            for (int i = 0; i < span.Length; i++) span[i] = (byte)i;
            long total = 0;
            foreach (byte b in span) total += b;
            return total;
        }
        finally
        {
            NativeMemory.Free(buffer);                              // must free exactly once
        }
    }
}

public static class Program
{
    public static void Main()
    {
        int[] numbers = [1, 2, 3, 4];
        Console.WriteLine(LowLevel.SumPinned(numbers));            // 10
        Console.WriteLine(LowLevel.SumUnchecked(numbers));         // 10
        Console.WriteLine(LowLevel.ReadLittleEndianInt([0x2A, 0, 0, 0])); // 42
        Console.WriteLine(LowLevel.NativeChecksum(256));           // 32640
        Console.WriteLine(Unsafe.BitCast<float, int>(1.0f).ToString("X")); // 3F800000 (.NET 8+)
    }
}

⚠ Common mistakes

1. Using unsafe code without measurement

What happens: pointer code written "for speed". Why it is wrong: risk without proven benefit; spans are often as fast. Do instead: benchmark safe alternatives first.

2. Letting pointers escape fixed

What happens: storing or returning pointers obtained in a fixed block. Why it is wrong: GC holes — random corruption. Do instead: keep pointer use within the pinned scope.

3. Missing bounds validation

What happens: unsafe loops trusting input lengths. Why it is wrong: buffer overruns and security bugs. Do instead: validate at the safe boundary.

4. Leaking native memory

What happens: NativeMemory.Alloc without guaranteed Free. Why it is wrong: unbounded memory growth outside the GC's view. Do instead: SafeHandle/MemoryManager<T> and using.

5. Long-lived pins on the SOH

What happens: GCHandle pins kept for long periods. Why it is wrong: heap fragmentation and GC inefficiency. Do instead: POH allocations or short pins.

6. Reinterpreting types carelessly

What happens: Unsafe.As or MemoryMarshal.Cast between incompatible layouts. Why it is wrong: silent corruption, endianness bugs. Do instead: explicit layouts and BinaryPrimitives for wire formats.

7. Spreading unsafe code across the codebase

What happens: pointer code in many classes. Why it is wrong: hard to audit and test. Do instead: isolate in small, reviewed components.

8. Writing manual marshalling

What happens: hand-written interop marshalling with pointers. Why it is wrong: error-prone and not AOT-friendly. Do instead: [LibraryImport] source-generated interop.

🏛 Senior insight: trade-offs

Modern .NET has made unsafe code rarely necessary: spans, MemoryMarshal, hardware intrinsics with Vector128<T>, source-generated interop and a strong JIT cover most needs safely. When it is needed, the senior skill is containment.

Practice Why
Small unsafe core, safe public API limits blast radius
Validate lengths before entering unsafe code prevents overruns
Prefer managed refs over pointers no GC holes
Fuzzing and GC stress tests heap corruption shows up late otherwise
Document invariants in comments future maintainers need them

Also remember the security angle: memory-safety bugs are the classic source of exploitable vulnerabilities, so unsafe code in services that parse untrusted input deserves security review.

🔗 Follow-ups the interviewer may ask

  1. When: Candidate explains pointers. Why: Checks pointer and reference fundamentals.
    How to answer

    A pointer is a raw memory address. In .NET, an object reference is a pointer the GC tracks: when the GC compacts the heap and moves an object, it updates every reference, so you never see addresses. ref, in and out pass a managed pointer to a variable instead of copying its value, and ref locals, ref returns and ref fields let you hold one; managed pointers can point inside objects, arrays or stack frames, and the GC tracks those too. Span<T> is a ref struct holding a managed pointer plus a length, giving safe views over arrays, stackalloc memory or native memory. Unsafe pointers (int*) are untracked addresses: they need an unsafe context and fixed or pinning, because the GC might otherwise move the object underneath them.

  2. When: Candidate mentions fixed or pinning. Why: Pinning interacts with the managed heap and POH.
    How to answer

    Each thread allocates from its own allocation context, a small chunk of gen 0 where new is just a pointer bump plus zeroing; when it runs out, the thread asks the GC for another chunk, and when the gen 0 budget is used up, a gen 0 GC runs. The GC is generational because most objects die young: gen 0 collections are cheap, survivors are promoted to gen 1 and then gen 2, which is collected rarely (and mostly in the background). Objects of 85,000 bytes or more go to the large object heap (LOH), which is collected only with gen 2 and isn't compacted by default, so it can fragment. Pinned objects can't be moved, which blocks compaction; the pinned object heap (.NET 5+) keeps long-lived pinned buffers separate. In hot paths I reduce the allocation rate rather than tuning the GC.

  3. When: Candidate mentions native interop. Why: LibraryImport generates interop code so you write less unsafe code.
    How to answer

    A source generator is a compiler plug-in that reads your code (usually a marker attribute on a partial declaration) and adds new C# files to the compilation; it can never change or delete your code. .NET ships several: System.Text.Json ([JsonSerializable] on a JsonSerializerContext) for reflection-free serialisation, [GeneratedRegex] for regex compiled to C# at build time, [LoggerMessage] for allocation-free structured logging, [LibraryImport] for P/Invoke marshalling, and in ASP.NET Core the configuration binding and Request Delegate generators that replace reflection in Bind/Get<T> and minimal APIs. They improve startup, cut allocations and make code trim and Native AOT safe. Inspect output under Dependencies, Analyzers in the IDE, or set EmitCompilerGeneratedFiles to write the files to obj.

🛠 Real-world problems: how you handle them

Heap corruption from an unchecked image filter loop
Situation
An image processing service applied filters to [5 million] user photos a day; a developer replaced span indexing with Unsafe.Add to remove bounds checks.
Signals
Weeks later, random crashes inside the GC (ExecutionEngineException / fatal error) with no managed stack pointing at the cause, about [3] per day.
Investigation
Enabled heap verification with DOTNET_HeapVerify in a staging load test, reproduced corruption with odd-width images, and traced it to a stride calculation that wrote one row past the buffer.
Resolution
Restored span indexing in the general loop (the JIT removed most bounds checks anyway) and kept unsafe code only in a small, fuzzed SIMD kernel with explicit length validation.
Prevention
Fuzz tests with random image dimensions in CI, security review for any new unsafe code, and benchmarks required to justify it.

How to tell it (STAR)

**Situation:** our [image service] started crashing inside the garbage collector a few times a day with no useful stack trace. **Task:** I was asked to find the cause because the crashes dropped user uploads. **Action:** I suspected heap corruption, enabled heap verification in staging, and reproduced it with odd-width images. An earlier optimisation used Unsafe.Add with a wrong stride. I reverted to span indexing, which benchmarks showed was only [2%] slower, and isolated the remaining unsafe code behind validated, fuzzed APIs. **Result:** the crashes stopped completely and we adopted a rule that unsafe code needs a benchmark and a fuzz test.

More in Memory-Oriented C#

Last updated 30 Oct 2026.

🌐 Sangam's Webring

⏪ Previous 🏠 Main Next ⏩
💬 Chat with me