Advanced C# A 08 14 features

Performance and Memory

Fast C# is mostly about not allocating. Know how the GC works, give back what you borrow, and measure before you change anything.

This page starts with the garbage collector and the dispose pattern. Then it covers the tools that cut allocations: pools, string.Create, spans, stackalloc and the marshal helpers. It ends with layout, .NET 8 lookup types, C# 14 compound operators, and how to measure. Types and Memory covers value vs reference types and Span<T> basics.

Contents

  1. The garbage collector
  2. IDisposable and using
  3. Finalizers and SafeHandle
  4. ArrayPool and MemoryPool
  5. string.Create and StringBuilder
  6. Interpolated string handlers
  7. Span parsing and stackalloc
  8. unsafe and fixed
  9. Unsafe, MemoryMarshal, CollectionsMarshal
  10. Struct layout and false sharing
  11. Frozen collections
  12. SearchValues
  13. Compound assignment operators
  14. Measuring
  15. Recap

1. The garbage collector .NET 1.0 POH: .NET 5 NoGC region: .NET 4.6

What it is

The .NET GC is generational, compacting, and tracing. New objects go into gen 0. Objects that survive a collection move up to gen 1, then gen 2. Most objects die young, so gen 0 collections are cheap and frequent. Gen 2 collections scan everything and are rare.

new object() gen 0 collected often cheap, most die here gen 1 buffer between young and old gen 2 long-lived objects full GC, rare, costly survives survives LOH 85,000 bytes or more POH pinned: true arrays new byte[100_000] skips gen 0 and 1 LOH and POH are collected with gen 2
Figure A8.1 — Small objects climb the generations as they survive, large and pinned ones live beside gen 2.

Reading the figure. Amber is the young generation where allocation happens. Blue and violet are older generations. Red and green are the special heaps that only a gen 2 collection visits. An object that keeps surviving ends up in gen 2 and costs the most to clean.

Why and when

Sample

using System.Runtime;

public static class GcDemo
{
    /// <summary>Shows one object being promoted as it survives collections.</summary>
    /// <returns>Its generation before and after two forced collections.</returns>
    /// <example>GcDemo.Promotions() returns [0, 1, 2].</example>
    public static List<int> Promotions()
    {
        var obj = new object();
        // A fresh object always starts in gen 0.
        List<int> gens = [GC.GetGeneration(obj)];
        // Each forced collection that obj survives promotes it one level.
        GC.Collect();
        gens.Add(GC.GetGeneration(obj));
        GC.Collect();
        gens.Add(GC.GetGeneration(obj));
        // KeepAlive stops the JIT from treating obj as dead before this line.
        GC.KeepAlive(obj);
        return gens;
    }

    /// <summary>Reports the generation of a freshly allocated array.</summary>
    /// <param name="bytes">Array size in bytes.</param>
    /// <returns>0 for small arrays, 2 for arrays on the LOH.</returns>
    /// <example>GcDemo.GenerationOf(100_000) returns 2.</example>
    public static int GenerationOf(int bytes) => GC.GetGeneration(new byte[bytes]);

    /// <summary>Allocates a pinned array on the POH.</summary>
    /// <param name="length">Array length.</param>
    /// <returns>The array length, to show the call worked.</returns>
    /// <example>GcDemo.PinnedLength(16) returns 16.</example>
    public static int PinnedLength(int length)
        => GC.AllocateArray<byte>(length, pinned: true).Length;

    /// <summary>Names the GC flavor this process runs.</summary>
    /// <returns>"server" or "workstation".</returns>
    /// <example>GcDemo.Mode() returns "workstation" in a console app.</example>
    public static string Mode() => GCSettings.IsServerGC ? "server" : "workstation";

    /// <summary>Runs a short burst with collections suspended.</summary>
    /// <returns>True when the runtime reported the no-GC latency mode inside the region.</returns>
    /// <example>GcDemo.NoGcBurst() returns true.</example>
    public static bool NoGcBurst()
    {
        // 1 MB budget: enough for the small burst below, small enough to be granted.
        if (!GC.TryStartNoGCRegion(1_000_000))
        {
            return false;
        }
        try
        {
            var scratch = new byte[1_000];   // 1,000 bytes: well inside the budget
            return scratch.Length > 0 && GCSettings.LatencyMode == GCLatencyMode.NoGCRegion;
        }
        finally
        {
            GC.EndNoGCRegion();
        }
    }
}

Test

T.Check("promotions", GcDemo.Promotions(), [0, 1, 2]);
T.Check("small array gen", GcDemo.GenerationOf(1_000), 0);
T.Check("large array gen", GcDemo.GenerationOf(100_000), 2);
T.Check("mode", GcDemo.Mode(), "workstation");
T.Check("no gc burst", GcDemo.NoGcBurst(), true);

Pitfalls

Interview questions

2. IDisposable, the dispose pattern and using declarations IDisposable: .NET 1.0 using declarations: C# 8

What it is

The GC frees memory, but not files, sockets or locks. IDisposable.Dispose() is where a type releases those right away. A using statement calls Dispose in a hidden finally. A using declaration (using var x = ...;, C# 8) does the same at the end of the enclosing block, in reverse order of declaration.

The full dispose pattern adds a protected virtual Dispose(bool disposing). It exists for unsealed classes and for classes with a finalizer. A sealed class with only managed fields just needs a plain Dispose.

using / Dispose() your code, on time GC runs ~Finalizer() someone forgot Dispose Dispose(bool) if (_disposed) return true false free managed objects free unmanaged handle then SuppressFinalize free unmanaged handle only, others may be gone disposing = true means "called by user code"
Figure A8.2 — Both entry points meet in Dispose(bool), and the flag decides how much is safe to touch.

Reading the figure. Blue is the normal path from user code. Red is the safety-net path from the finalizer thread. On the red path other managed objects may already be finalized, so only the raw handle is released. Green boxes are the work each path does.

Why and when

Sample

public sealed class Tracker(string name, List<string> log) : IDisposable
{
    /// <summary>Records that this resource was released.</summary>
    /// <example>using var t = new Tracker("a", log); logs "dispose a" at scope end.</example>
    public void Dispose() => log.Add($"dispose {name}");
}

public class ResourceHolder : IDisposable
{
    private bool _disposed;
    protected List<string> Log { get; }

    public ResourceHolder(List<string> log) => Log = log;

    /// <summary>Uses the resource, failing after disposal.</summary>
    /// <returns>"used" when still open.</returns>
    /// <example>new ResourceHolder([]).Use() returns "used".</example>
    public string Use()
    {
        // .NET 7 guard: throws ObjectDisposedException when the flag is set.
        ObjectDisposedException.ThrowIf(_disposed, this);
        return "used";
    }

    /// <summary>Public entry point. Safe to call more than once.</summary>
    /// <example>holder.Dispose() logs "base managed" once.</example>
    public void Dispose()
    {
        Dispose(disposing: true);
        // No finalizer work is left, so skip the finalization queue.
        GC.SuppressFinalize(this);
    }

    /// <summary>Shared cleanup. disposing is true from Dispose, false from a finalizer.</summary>
    /// <param name="disposing">True when called from user code.</param>
    protected virtual void Dispose(bool disposing)
    {
        if (_disposed)
        {
            return;
        }
        if (disposing)
        {
            // Managed cleanup: only safe on the user-code path.
            Log.Add("base managed");
        }
        _disposed = true;
    }
}

public sealed class DerivedHolder(List<string> log) : ResourceHolder(log)
{
    // Its own flag: the base flag is private, and base.Dispose runs last.
    private bool _disposed;

    protected override void Dispose(bool disposing)
    {
        if (_disposed)
        {
            return;
        }
        if (disposing)
        {
            Log.Add("derived managed");
        }
        _disposed = true;
        // Always chain to the base so its cleanup runs too.
        base.Dispose(disposing);
    }
}

public static class DisposeDemo
{
    /// <summary>Shows that using declarations dispose in reverse order at scope end.</summary>
    /// <returns>The event log.</returns>
    /// <example>DisposeDemo.UsingOrder() returns ["body", "dispose b", "dispose a"].</example>
    public static List<string> UsingOrder()
    {
        List<string> log = [];
        {
            using var a = new Tracker("a", log);
            using var b = new Tracker("b", log);
            log.Add("body");
        }   // b is disposed first, then a
        return log;
    }

    /// <summary>Disposes a derived holder twice and then tries to use it.</summary>
    /// <returns>The event log, showing one cleanup and one blocked use.</returns>
    /// <example>DisposeDemo.DoubleDispose() returns ["derived managed", "base managed",
    /// "blocked"].</example>
    public static List<string> DoubleDispose()
    {
        List<string> log = [];
        var holder = new DerivedHolder(log);
        holder.Dispose();
        holder.Dispose();   // second call is a no-op thanks to the _disposed flag
        try
        {
            holder.Use();
        }
        catch (ObjectDisposedException)
        {
            log.Add("blocked");
        }
        return log;
    }
}

Test

T.Check("using order", DisposeDemo.UsingOrder(), ["body", "dispose b", "dispose a"]);
T.Check("double dispose", DisposeDemo.DoubleDispose(),
    ["derived managed", "base managed", "blocked"]);

Pitfalls

Interview questions

3. Finalizers and SafeHandle finalizers: C# 1 SafeHandle: .NET 2.0

What it is

A finalizer, written ~Name(), runs on the GC’s finalizer thread some time after the object becomes unreachable. It is a safety net for unmanaged resources when someone forgets Dispose. SafeHandle wraps an OS handle and already has a correct, hardened finalizer. You derive from it and override ReleaseHandle.

Why and when

Sample

using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using Microsoft.Win32.SafeHandles;

public sealed class FakeOsHandle : SafeHandleZeroOrMinusOneIsInvalid
{
    private readonly Action _onRelease;

    public FakeOsHandle(nint value, Action onRelease) : base(ownsHandle: true)
    {
        _onRelease = onRelease;
        SetHandle(value);
    }

    /// <summary>Called exactly once, by Dispose or by the finalizer.</summary>
    /// <returns>True to report a clean release.</returns>
    protected override bool ReleaseHandle()
    {
        _onRelease();
        return true;
    }
}

public sealed class NativeBuffer : IDisposable
{
    private nint _ptr;

    public NativeBuffer(int bytes) => _ptr = Marshal.AllocHGlobal(bytes);

    // Safety net: frees the memory if Dispose was never called.
    ~NativeBuffer() => Free();

    public bool IsFreed => _ptr == 0;   // 0 is the null pointer

    /// <summary>Frees the memory now and cancels the finalizer.</summary>
    public void Dispose()
    {
        Free();
        GC.SuppressFinalize(this);
    }

    private void Free()
    {
        if (_ptr != 0)   // 0 means already freed
        {
            Marshal.FreeHGlobal(_ptr);
            _ptr = 0;
        }
    }
}

public sealed class Finalizable(StrongBox<int> counter)
{
    // Runs on the finalizer thread after the object becomes unreachable.
    ~Finalizable() => Interlocked.Increment(ref counter.Value);
}

public static class FinalizerDemo
{
    /// <summary>Disposes a SafeHandle twice and counts the releases.</summary>
    /// <returns>How many times ReleaseHandle ran.</returns>
    /// <example>FinalizerDemo.SafeHandleReleases() returns 1.</example>
    public static int SafeHandleReleases()
    {
        int released = 0;   // 0: nothing released yet
        // 42 is a made-up handle value. Any value other than 0 or -1 counts as valid.
        var handle = new FakeOsHandle(42, () => released++);
        handle.Dispose();
        handle.Dispose();   // SafeHandle guarantees ReleaseHandle runs only once
        return released;
    }

    /// <summary>Frees a native buffer with Dispose.</summary>
    /// <returns>True once the pointer is cleared.</returns>
    /// <example>FinalizerDemo.BufferFreed() returns true.</example>
    public static bool BufferFreed()
    {
        var buffer = new NativeBuffer(64);   // 64 bytes: any small size works
        buffer.Dispose();
        return buffer.IsFreed;
    }

    /// <summary>Lets an object die, forces a GC, and waits for its finalizer.</summary>
    /// <returns>How many finalizers ran.</returns>
    /// <example>FinalizerDemo.FinalizerRuns() returns 1.</example>
    public static int FinalizerRuns()
    {
        var counter = new StrongBox<int>(0);   // 0: no finalizer has run yet
        MakeGarbage(counter);
        GC.Collect();
        // Finalizers run on their own thread. This blocks until the queue is empty.
        GC.WaitForPendingFinalizers();
        return counter.Value;
    }

    // NoInlining makes sure no local in the caller keeps the object alive.
    [MethodImpl(MethodImplOptions.NoInlining)]
    private static void MakeGarbage(StrongBox<int> counter) => _ = new Finalizable(counter);
}

Test

T.Check("safe handle once", FinalizerDemo.SafeHandleReleases(), 1);
T.Check("buffer freed", FinalizerDemo.BufferFreed(), true);
T.Check("finalizer ran", FinalizerDemo.FinalizerRuns(), 1);

Pitfalls

Interview questions

4. ArrayPool<T> and MemoryPool<T> .NET Core 1.0 / 2.1

What it is

ArrayPool<T>.Shared lends out arrays and takes them back. Rent(n) returns an array of at least n elements, often rounded up to a power of two. Return hands it back for reuse. MemoryPool<T> does the same but gives an IMemoryOwner<T> you dispose, which fits async code and pipelines.

Why and when

Sample

using System.Buffers;

public static class Pools
{
    /// <summary>Sums squares 0..n-1 using a rented scratch buffer.</summary>
    /// <param name="n">How many squares.</param>
    /// <returns>The sum.</returns>
    /// <example>Pools.SumSquares(4) returns 14.</example>
    public static int SumSquares(int n)
    {
        int[] rented = ArrayPool<int>.Shared.Rent(n);
        try
        {
            // Slice to n: the rented array may be longer and hold stale data.
            Span<int> buf = rented.AsSpan(0, n);
            // i is the index being filled. Invariant: buf[0..i) holds squares.
            for (int i = 0; i < buf.Length; i++)
            {
                buf[i] = i * i;
            }
            int sum = 0;   // 0: empty sum
            foreach (int v in buf)
            {
                sum += v;
            }
            return sum;
        }
        finally
        {
            // Always return, even on exceptions, or the pool slowly drains.
            ArrayPool<int>.Shared.Return(rented);
        }
    }

    /// <summary>Shows that Rent can hand back a bigger array than asked for.</summary>
    /// <param name="n">Requested length.</param>
    /// <returns>The actual rented length.</returns>
    /// <example>Pools.RentedLength(100) returns 128.</example>
    public static int RentedLength(int n)
    {
        byte[] rented = ArrayPool<byte>.Shared.Rent(n);
        int length = rented.Length;
        ArrayPool<byte>.Shared.Return(rented);
        return length;
    }

    /// <summary>Fills pooled memory and sums it, with disposal returning the block.</summary>
    /// <param name="n">Bytes to use.</param>
    /// <returns>The sum of n bytes that each hold 7.</returns>
    /// <example>Pools.MemoryPoolSum(3) returns 21.</example>
    public static int MemoryPoolSum(int n)
    {
        using IMemoryOwner<byte> owner = MemoryPool<byte>.Shared.Rent(n);
        // [..n] trims to the size we asked for.
        Memory<byte> mem = owner.Memory[..n];
        mem.Span.Fill(7);   // 7: any marker value
        int sum = 0;   // 0: empty sum
        foreach (byte b in mem.Span)
        {
            sum += b;
        }
        return sum;
    }
}

Test

T.Check("sum squares", Pools.SumSquares(4), 14);
T.Check("rent at least", Pools.RentedLength(100) >= 100, true);
T.Check("memory pool", Pools.MemoryPoolSum(3), 21);

Pitfalls

Interview questions

5. string.Create and StringBuilder StringBuilder: .NET 1.0 string.Create: .NET Core 2.1

What it is

Strings are immutable, so s += x in a loop copies the whole string each time. That is O(n²). StringBuilder keeps a growable buffer and builds the string once at ToString(). string.Create(length, state, action) goes further. It allocates the final string once and lets you write its characters through a Span<char>.

Why and when

Sample

using System.Text;

public static class Strings
{
    /// <summary>Masks all but the last four characters, with one allocation.</summary>
    /// <param name="card">The card number text.</param>
    /// <returns>The masked text.</returns>
    /// <example>Strings.Mask("1234567890") returns "******7890".</example>
    public static string Mask(string card)
        // static lambda: captures nothing, so no closure is allocated.
        => string.Create(card.Length, card, static (span, src) =>
        {
            // 4 visible digits at the end, the usual card masking rule.
            int keepFrom = src.Length - 4;
            // i is the position being written. Invariant: span[0..i) is final.
            for (int i = 0; i < span.Length; i++)
            {
                span[i] = i < keepFrom ? '*' : src[i];
            }
        });

    /// <summary>Joins numbers with a separator using StringBuilder.</summary>
    /// <param name="xs">The numbers.</param>
    /// <param name="sep">Separator character.</param>
    /// <returns>The joined text.</returns>
    /// <example>Strings.Join([1, 2, 3], '-') returns "1-2-3".</example>
    public static string Join(IEnumerable<int> xs, char sep)
    {
        var sb = new StringBuilder();
        // x is the current number. Invariant: sb holds all earlier numbers, joined.
        foreach (int x in xs)
        {
            // Length > 0 means something is already there, so add a separator first.
            if (sb.Length > 0)
            {
                sb.Append(sep);
            }
            sb.Append(x);   // Append(int) formats without a temporary string
        }
        return sb.ToString();
    }

    /// <summary>Encodes bytes as lower-case hex with string.Create.</summary>
    /// <param name="bytes">Input bytes.</param>
    /// <returns>Two hex characters per byte.</returns>
    /// <example>Strings.Hex([255, 1]) returns "ff01".</example>
    public static string Hex(byte[] bytes)
        // * 2 because each byte becomes two hex digits.
        => string.Create(bytes.Length * 2, bytes, static (span, src) =>
        {
            const string digits = "0123456789abcdef";
            // i indexes the source byte. Invariant: span[0..2i) is written.
            for (int i = 0; i < src.Length; i++)
            {
                // >> 4 takes the high nibble, & 0xF the low nibble.
                span[2 * i] = digits[src[i] >> 4];
                span[2 * i + 1] = digits[src[i] & 0xF];
            }
        });
}

Test

T.Check("mask", Strings.Mask("1234567890"), "******7890");
T.Check("join", Strings.Join([1, 2, 3], '-'), "1-2-3");
T.Check("hex", Strings.Hex([255, 1]), "ff01");

Pitfalls

Interview questions

6. Interpolated string handlers C# 10

What it is

Since C# 10, $"..." does not always build a string. If the target parameter type is marked [InterpolatedStringHandler], the compiler calls that type’s AppendLiteral and AppendFormatted methods piece by piece. The handler constructor can return shouldAppend = false. Then the compiler skips all the appends, and does not even evaluate the hole expressions.

The BCL uses this in StringBuilder.Append($"..."), Debug.Assert, and plain string interpolation through DefaultInterpolatedStringHandler.

Why and when

Sample

using System.Runtime.CompilerServices;

public sealed class Logger
{
    public bool Enabled { get; init; }
    public List<string> Lines { get; } = [];

    /// <summary>Logs a message. The handler decides whether to format at all.</summary>
    /// <param name="handler">Built by the compiler from an interpolated string.</param>
    /// <example>log.Log($"x={x}") adds "x=..." only when Enabled.</example>
    public void Log([InterpolatedStringHandlerArgument("")] LogHandler handler)
    {
        if (handler.IsEnabled)
        {
            Lines.Add(handler.ToStringAndClear());
        }
    }
}

[InterpolatedStringHandler]
public ref struct LogHandler
{
    private DefaultInterpolatedStringHandler _inner;

    public bool IsEnabled { get; }

    // The "" in InterpolatedStringHandlerArgument passes the Logger receiver in here.
    public LogHandler(int literalLength, int formattedCount, Logger logger,
        out bool shouldAppend)
    {
        IsEnabled = logger.Enabled;
        // false tells the compiler to skip every Append call and every hole expression.
        shouldAppend = IsEnabled;
        _inner = IsEnabled
            ? new DefaultInterpolatedStringHandler(literalLength, formattedCount)
            : default;
    }

    public void AppendLiteral(string s) => _inner.AppendLiteral(s);

    public void AppendFormatted<TValue>(TValue value) => _inner.AppendFormatted(value);

    public string ToStringAndClear() => _inner.ToStringAndClear();
}

public static class HandlerDemo
{
    /// <summary>Logs one message and counts how often the costly hole ran.</summary>
    /// <param name="enabled">Whether the logger is on.</param>
    /// <returns>Lines logged and hole evaluations.</returns>
    /// <example>HandlerDemo.Run(false) returns "lines=0 calls=0".</example>
    public static string Run(bool enabled)
    {
        var log = new Logger { Enabled = enabled };
        int calls = 0;   // 0: the costly method has not run yet
        // Stands in for an expensive value such as a serialized object.
        string Costly()
        {
            calls++;
            return "big";
        }
        log.Log($"state={Costly()}");
        return $"lines={log.Lines.Count} calls={calls}";
    }
}

Test

T.Check("handler on", HandlerDemo.Run(true), "lines=1 calls=1");
T.Check("handler off", HandlerDemo.Run(false), "lines=0 calls=0");

Pitfalls

Interview questions

7. Span-based parsing and stackalloc Span: C# 7.2, .NET Core 2.1 Span.Split: .NET 8

What it is

A ReadOnlySpan<char> is a window over existing characters. Slicing it is free: no new string, no copy. Most parse methods accept spans, such as int.Parse(ReadOnlySpan<char>). stackalloc gives a small buffer on the stack, which the GC never sees.

Why and when

Sample

public static class SpanParse
{
    /// <summary>Sums a comma-separated list of ints with no substring allocations.</summary>
    /// <param name="line">Text such as "1,2,3".</param>
    /// <returns>The sum.</returns>
    /// <example>SpanParse.SumCsv("1,2,3") returns 6.</example>
    public static int SumCsv(ReadOnlySpan<char> line)
    {
        int sum = 0;   // 0: empty sum
        // Each pass peels one field off the front. Invariant: sum covers the peeled fields.
        while (true)
        {
            int comma = line.IndexOf(',');
            // -1 (comma < 0) means this is the last field.
            ReadOnlySpan<char> field = comma < 0 ? line : line[..comma];
            sum += int.Parse(field);
            if (comma < 0)
            {
                return sum;
            }
            // + 1 skips past the comma itself.
            line = line[(comma + 1)..];
        }
    }

    /// <summary>Same sum using the .NET 8 span Split into a stack buffer of ranges.</summary>
    /// <param name="line">Text with at most 16 fields.</param>
    /// <returns>The sum.</returns>
    /// <example>SpanParse.SumCsvRanges("4,5") returns 9.</example>
    public static int SumCsvRanges(ReadOnlySpan<char> line)
    {
        // 16 ranges on the stack: a fixed cap that keeps stack use tiny.
        Span<Range> ranges = stackalloc Range[16];
        int count = line.Split(ranges, ',');
        int sum = 0;   // 0: empty sum
        // i is the field index. Invariant: sum covers fields 0..i-1.
        for (int i = 0; i < count; i++)
        {
            sum += int.Parse(line[ranges[i]]);
        }
        return sum;
    }

    /// <summary>Reverses a string, using the stack for short inputs.</summary>
    /// <param name="s">Input text.</param>
    /// <returns>The reversed text.</returns>
    /// <example>SpanParse.Reverse("abc") returns "cba".</example>
    public static string Reverse(string s)
    {
        // 256 chars = 512 bytes of stack: a safe cap. Longer input goes to the heap.
        const int StackLimit = 256;
        Span<char> buf = s.Length <= StackLimit ? stackalloc char[StackLimit] : new char[s.Length];
        buf = buf[..s.Length];
        s.AsSpan().CopyTo(buf);
        buf.Reverse();
        return new string(buf);
    }
}

Test

T.Check("sum csv", SpanParse.SumCsv("1,2,3"), 6);
T.Check("sum ranges", SpanParse.SumCsvRanges("4,5"), 9);
T.Check("reverse", SpanParse.Reverse("abc"), "cba");

Pitfalls

Interview questions

8. unsafe and fixed C# 1

What it is

An unsafe block allows raw pointers, sizeof on any unmanaged struct, and pointer math. The project needs <AllowUnsafeBlocks>true</AllowUnsafeBlocks>. A fixed statement pins a managed array or string so the GC cannot move it while you hold a pointer to it.

Why and when

Sample

public struct PointI
{
    public int X;
    public int Y;
}

public static class UnsafeCode
{
    /// <summary>Sums an array by walking a pinned pointer.</summary>
    /// <param name="xs">The values.</param>
    /// <returns>The sum.</returns>
    /// <example>UnsafeCode.SumFixed([1, 2, 3]) returns 6.</example>
    public static unsafe int SumFixed(int[] xs)
    {
        int sum = 0;   // 0: empty sum
        // fixed pins xs so the GC cannot move it while p is in use.
        fixed (int* p = xs)
        {
            // q walks from the first element to one past the last.
            // Invariant: sum covers every element before q.
            for (int* q = p, end = p + xs.Length; q < end; q++)
            {
                sum += *q;
            }
        }
        return sum;
    }

    /// <summary>Reports the size of a small struct in bytes.</summary>
    /// <returns>8: two 4-byte ints.</returns>
    /// <example>UnsafeCode.PointSize() returns 8.</example>
    public static unsafe int PointSize() => sizeof(PointI);

    /// <summary>Writes through a pointer to a local struct.</summary>
    /// <returns>The struct's X after the write.</returns>
    /// <example>UnsafeCode.WriteThroughPointer() returns 7.</example>
    public static unsafe int WriteThroughPointer()
    {
        var pt = new PointI();
        // Locals live on the stack and never move, so no fixed is needed.
        PointI* ptr = &pt;
        ptr->X = 7;   // 7: any test value
        return pt.X;
    }
}

Test

T.Check("sum fixed", UnsafeCode.SumFixed([1, 2, 3]), 6);
T.Check("sum fixed empty", UnsafeCode.SumFixed([]), 0);
T.Check("sizeof", UnsafeCode.PointSize(), 8);
T.Check("pointer write", UnsafeCode.WriteThroughPointer(), 7);

Pitfalls

Interview questions

9. Unsafe, MemoryMarshal and CollectionsMarshal .NET Core 2.1 / .NET 5 GetValueRefOrAddDefault: .NET 6

What it is

Why and when

The most useful one in interviews is counting with a dictionary. counts[w] = counts.GetValueOrDefault(w) + 1 hashes the key twice. GetValueRefOrAddDefault hashes once and updates in place.

Sample

using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;

public static class Marshals
{
    /// <summary>Counts words with one hash lookup per word.</summary>
    /// <param name="words">The words.</param>
    /// <returns>A word to count map.</returns>
    /// <example>Marshals.CountWords(["a", "b", "a"])["a"] returns 2.</example>
    public static Dictionary<string, int> CountWords(IEnumerable<string> words)
    {
        var counts = new Dictionary<string, int>();
        // w is the current word. Invariant: counts covers every earlier word.
        foreach (string w in words)
        {
            // ref to the slot. A missing key is added with value 0 first.
            ref int slot = ref CollectionsMarshal.GetValueRefOrAddDefault(counts, w, out _);
            slot++;
        }
        return counts;
    }

    /// <summary>Sums a List through its backing array, with no enumerator.</summary>
    /// <param name="xs">The list.</param>
    /// <returns>The sum.</returns>
    /// <example>Marshals.SumList([1, 2, 3]) returns 6.</example>
    public static int SumList(List<int> xs)
    {
        int sum = 0;   // 0: empty sum
        // Do not add or remove items while this span is alive.
        foreach (int x in CollectionsMarshal.AsSpan(xs))
        {
            sum += x;
        }
        return sum;
    }

    /// <summary>Views raw bytes as ints without copying, then copies out.</summary>
    /// <param name="bytes">Little-endian bytes, length a multiple of 4.</param>
    /// <returns>The ints.</returns>
    /// <example>Marshals.AsInts([1, 0, 0, 0, 2, 0, 0, 0]) returns [1, 2].</example>
    public static int[] AsInts(byte[] bytes) => MemoryMarshal.Cast<byte, int>(bytes).ToArray();

    /// <summary>Reinterprets a float's bits as an int.</summary>
    /// <param name="f">The float.</param>
    /// <returns>Its IEEE 754 bit pattern.</returns>
    /// <example>Marshals.FloatBits(1f) returns 1065353216.</example>
    public static int FloatBits(float f) => Unsafe.BitCast<float, int>(f);
}

Test

T.Check("count a", Marshals.CountWords(["a", "b", "a"])["a"], 2);
T.Check("sum list", Marshals.SumList([1, 2, 3]), 6);
T.Check("as ints", Marshals.AsInts([1, 0, 0, 0, 2, 0, 0, 0]), [1, 2]);
T.Check("float bits", Marshals.FloatBits(1f), 1065353216);

Pitfalls

Interview questions

10. Struct layout and false sharing StructLayout: .NET 1.0

What it is

Struct fields are laid out in declaration order by default, with padding so each field sits on its natural alignment. Field order changes the size. [StructLayout(LayoutKind.Sequential, Pack = 1)] removes padding. LayoutKind.Explicit with [FieldOffset] places each field by hand.

False sharing happens when two threads write different variables that share one 64-byte CPU cache line. Each write invalidates the other core’s copy. The code is correct but slow.

false sharing: one cache line a[0] a[1] 64 bytes core 1 writes core 2 writes every write evicts the line from the other core, so both stall padded: separate lines c[0].Value padding c[1].Value padding each core owns its own line and never waits for the other
Figure A8.3 — Padding each counter onto its own cache line stops two cores fighting over one line.

Reading the figure. Amber is core 1’s counter and blue is core 2’s. A red box is a cache line both cores write. A green box is a line only one core writes. Same data, same results, very different speed.

Why and when

Sample

using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;

// byte, long, byte: padding after each byte to align the long. 24 bytes.
public struct LayoutLoose { public byte A; public long B; public byte C; }

// long first, then the bytes pack together. 16 bytes.
public struct LayoutOrdered { public long B; public byte A; public byte C; }

// Pack = 1 removes all padding: 1 + 8 + 1 = 10 bytes.
[StructLayout(LayoutKind.Sequential, Pack = 1)]
public struct LayoutPacked { public byte A; public long B; public byte C; }

// 128 bytes with the value at offset 64: neighbors in an array never share its line.
[StructLayout(LayoutKind.Explicit, Size = 128)]
public struct PaddedCounter
{
    [FieldOffset(64)] public long Value;   // 64: one cache line of padding in front
}

public static class Layout
{
    /// <summary>Reports the size of each layout struct.</summary>
    /// <returns>Sizes in bytes.</returns>
    /// <example>Layout.Sizes() returns [24, 16, 10, 128].</example>
    public static int[] Sizes() =>
    [
        Unsafe.SizeOf<LayoutLoose>(),
        Unsafe.SizeOf<LayoutOrdered>(),
        Unsafe.SizeOf<LayoutPacked>(),
        Unsafe.SizeOf<PaddedCounter>(),
    ];

    /// <summary>Two threads bump adjacent longs. Correct, but they share a cache line.</summary>
    /// <param name="perThread">Increments per thread.</param>
    /// <returns>The total count.</returns>
    /// <example>Layout.CountShared(1000) returns 2000.</example>
    public static long CountShared(int perThread)
    {
        var slots = new long[2];   // 2: one slot per thread, side by side
        Parallel.Invoke(
            () => { for (int i = 0; i < perThread; i++) slots[0]++; },
            () => { for (int i = 0; i < perThread; i++) slots[1]++; });
        return slots[0] + slots[1];
    }

    /// <summary>Same work, but each counter has its own cache line.</summary>
    /// <param name="perThread">Increments per thread.</param>
    /// <returns>The total count.</returns>
    /// <example>Layout.CountPadded(1000) returns 2000.</example>
    public static long CountPadded(int perThread)
    {
        var slots = new PaddedCounter[2];   // 2: one padded slot per thread
        Parallel.Invoke(
            () => { for (int i = 0; i < perThread; i++) slots[0].Value++; },
            () => { for (int i = 0; i < perThread; i++) slots[1].Value++; });
        return slots[0].Value + slots[1].Value;
    }
}

Test

T.Check("sizes", Layout.Sizes(), [24, 16, 10, 128]);
T.Check("shared count", Layout.CountShared(100_000), 200_000L);
T.Check("padded count", Layout.CountPadded(100_000), 200_000L);

Pitfalls

Interview questions

11. Frozen collections .NET 8

What it is

FrozenDictionary<K, V> and FrozenSet<T> in System.Collections.Frozen are read-only collections built once and read many times. Building is slower than a normal Dictionary. In exchange, the builder studies the keys and picks a faster lookup strategy, such as hashing only part of each string.

Why and when

Sample

using System.Collections.Frozen;

public static class FrozenLookup
{
    // Built once at type load. Lookups ignore case.
    private static readonly FrozenDictionary<string, int> Ports =
        new Dictionary<string, int>
        {
            ["http"] = 80,     // 80: the standard HTTP port
            ["https"] = 443,   // 443: the standard HTTPS port
            ["ssh"] = 22,      // 22: the standard SSH port
        }.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase);

    private static readonly FrozenSet<string> Reserved =
        new[] { "admin", "root", "system" }.ToFrozenSet();

    /// <summary>Looks up the default port for a scheme.</summary>
    /// <param name="scheme">A URL scheme in any case.</param>
    /// <returns>The port, or -1 when unknown.</returns>
    /// <example>FrozenLookup.PortOf("HTTPS") returns 443.</example>
    public static int PortOf(string scheme)
        => Ports.TryGetValue(scheme, out int port) ? port : -1;   // -1 marks "unknown"

    /// <summary>Checks a user name against the reserved list.</summary>
    /// <param name="name">A user name.</param>
    /// <returns>True when reserved.</returns>
    /// <example>FrozenLookup.IsReserved("root") returns true.</example>
    public static bool IsReserved(string name) => Reserved.Contains(name);
}

Test

T.Check("port https", FrozenLookup.PortOf("HTTPS"), 443);
T.Check("port unknown", FrozenLookup.PortOf("gopher"), -1);
T.Check("reserved", FrozenLookup.IsReserved("root"), true);
T.Check("not reserved", FrozenLookup.IsReserved("ada"), false);

Pitfalls

Interview questions

What it is

SearchValues<T> is a precomputed set of values to search for. You create it once with SearchValues.Create. Span methods like IndexOfAny, ContainsAny and IndexOfAnyExcept then use vectorized code tuned to that exact set. .NET 9 added sets of strings, for finding any of several words.

Why and when

Sample

using System.Buffers;

public static class Search
{
    private static readonly SearchValues<char> Vowels = SearchValues.Create("aeiouAEIOU");
    private static readonly SearchValues<char> HexDigits =
        SearchValues.Create("0123456789abcdefABCDEF");
    // .NET 9: a set of whole words, matched ignoring case.
    private static readonly SearchValues<string> Banned =
        SearchValues.Create(["spam", "scam"], StringComparison.OrdinalIgnoreCase);

    /// <summary>Finds the first vowel.</summary>
    /// <param name="s">Text to scan.</param>
    /// <returns>The index, or -1 when there is none.</returns>
    /// <example>Search.FirstVowel("rhythm and") returns 7.</example>
    public static int FirstVowel(string s) => s.AsSpan().IndexOfAny(Vowels);

    /// <summary>Counts vowels by jumping from match to match.</summary>
    /// <param name="s">Text to scan.</param>
    /// <returns>The vowel count.</returns>
    /// <example>Search.CountVowels("education") returns 5.</example>
    public static int CountVowels(ReadOnlySpan<char> s)
    {
        int count = 0;   // 0: none found yet
        int i;
        // Each pass finds one vowel and drops everything up to it.
        // Invariant: count is the number of vowels already dropped.
        while ((i = s.IndexOfAny(Vowels)) >= 0)
        {
            count++;
            s = s[(i + 1)..];   // + 1 skips past the vowel just counted
        }
        return count;
    }

    /// <summary>Checks that text is made only of hex digits.</summary>
    /// <param name="s">Text to check.</param>
    /// <returns>True for non-empty all-hex text.</returns>
    /// <example>Search.IsHex("c0ffee") returns true.</example>
    public static bool IsHex(string s)
        => s.Length > 0 && !s.AsSpan().ContainsAnyExcept(HexDigits);

    /// <summary>Checks text for any banned word.</summary>
    /// <param name="text">Text to scan.</param>
    /// <returns>True when a banned word appears.</returns>
    /// <example>Search.HasBanned("Free SPAM here") returns true.</example>
    public static bool HasBanned(string text) => text.AsSpan().ContainsAny(Banned);
}

Test

T.Check("first vowel", Search.FirstVowel("rhythm and"), 7);
T.Check("no vowel", Search.FirstVowel("rhythm"), -1);
T.Check("count vowels", Search.CountVowels("education"), 5);
T.Check("hex", Search.IsHex("c0ffee"), true);
T.Check("banned", Search.HasBanned("Free SPAM here"), true);

Pitfalls

Interview questions

13. User-defined compound assignment operators C# 14

What it is

Before C# 14, a += b on a user type always meant a = a + b. The static operator + had to return a new object. C# 14 lets a type declare an instance operator public void operator +=(T x). It mutates the object in place. You can also write public void operator ++(). If no compound operator exists, the old a = a + b rewrite still applies.

Why and when

Sample

public sealed class Histogram
{
    private readonly int[] _bins;

    public Histogram(int bins) => _bins = new int[bins];

    public int Total { get; private set; }

    public int this[int bin] => _bins[bin];

    /// <summary>Classic operator: copies, then adds. Leaves the original alone.</summary>
    /// <example>var h2 = h + 3; leaves h unchanged.</example>
    public static Histogram operator +(Histogram h, int bin)
    {
        var copy = new Histogram(h._bins.Length);
        h._bins.CopyTo(copy._bins, 0);   // 0: copy into the start of the new array
        copy.Total = h.Total;
        copy.Add(bin);
        return copy;
    }

    /// <summary>C# 14 compound operator: adds in place, no copy.</summary>
    /// <param name="bin">The bin to bump.</param>
    /// <example>h += 2; bumps bin 2 of the same object.</example>
    public void operator +=(int bin) => Add(bin);

    /// <summary>C# 14 instance increment: counts one extra event with no bin.</summary>
    /// <example>h++; raises Total by one.</example>
    public void operator ++() => Total++;

    private void Add(int bin)
    {
        _bins[bin]++;
        Total++;
    }
}

public static class CompoundDemo
{
    /// <summary>Shows that += keeps the same object while + makes a new one.</summary>
    /// <returns>Identity and count facts as text.</returns>
    /// <example>CompoundDemo.Run() returns "same=True new=False h=3 copy=4 bin2=2".</example>
    public static string Run()
    {
        var h = new Histogram(4);   // 4 bins: 0..3
        Histogram original = h;
        h += 2;   // 2: bump bin 2 in place
        h += 2;
        h++;
        bool same = ReferenceEquals(h, original);
        // + still returns a fresh object and leaves h alone.
        Histogram copy = h + 1;   // 1: bump bin 1 in the copy only
        bool isNew = ReferenceEquals(copy, h);
        return $"same={same} new={isNew} h={h.Total} copy={copy.Total} bin2={h[2]}";
    }
}

Test

T.Check("compound", CompoundDemo.Run(), "same=True new=False h=3 copy=4 bin2=2");

Pitfalls

Interview questions

14. Measuring: Stopwatch and BenchmarkDotNet Stopwatch: .NET 2.0 GetElapsedTime: .NET 7

What it is

Stopwatch uses the high-resolution system timer. Stopwatch.GetTimestamp() plus Stopwatch.GetElapsedTime(start) (.NET 7) measures a span with no allocation. BenchmarkDotNet is the standard library for micro-benchmarks. It warms up the JIT, runs many iterations, removes outliers, and reports allocations.

Why and when

Sample

using System.Diagnostics;

public static class Timing
{
    /// <summary>Times one run of some work after a few warm-up runs.</summary>
    /// <param name="work">The work. Its result is returned so it is not optimized away.</param>
    /// <param name="warmups">Untimed runs that let the JIT settle.</param>
    /// <returns>The result and the elapsed time.</returns>
    /// <example>Timing.Measure(() => Timing.SumTo(1000)).Result returns 499500.</example>
    public static (long Result, TimeSpan Elapsed) Measure(Func<long> work, int warmups = 3)
    {
        // i counts warm-up runs. 3 by default: enough for tiered JIT to promote hot code.
        for (int i = 0; i < warmups; i++)
        {
            work();
        }
        long start = Stopwatch.GetTimestamp();
        long result = work();
        return (result, Stopwatch.GetElapsedTime(start));
    }

    /// <summary>Adds 0..n-1.</summary>
    /// <param name="n">Upper bound, not included.</param>
    /// <returns>The sum as a long.</returns>
    /// <example>Timing.SumTo(4) returns 6.</example>
    public static long SumTo(int n)
    {
        long sum = 0;   // 0: empty sum
        // i is the next value to add. Invariant: sum = 0 + 1 + ... + (i - 1).
        for (int i = 0; i < n; i++)
        {
            sum += i;
        }
        return sum;
    }
}

Test

T.Check("measure result", Timing.Measure(() => Timing.SumTo(1000)).Result, 499500L);
T.Check("elapsed non-negative", Timing.Measure(() => 1).Elapsed >= TimeSpan.Zero, true);

The BenchmarkDotNet version below does not compile in this site’s test harness. It needs the BenchmarkDotNet NuGet package and its own Release console app.

using System.Text;
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;

[MemoryDiagnoser]   // adds allocated bytes and GC counts to the report
public class ConcatBench
{
    [Params(10, 1000)]   // 10 and 1000: a small and a large input size
    public int N;

    [Benchmark(Baseline = true)]
    public string PlusEquals()
    {
        string s = "";
        for (int i = 0; i < N; i++) s += "x";
        return s;
    }

    [Benchmark]
    public string Builder()
    {
        var sb = new StringBuilder(N);
        for (int i = 0; i < N; i++) sb.Append('x');
        return sb.ToString();
    }
}

public static class Program
{
    // Run with: dotnet run -c Release
    public static void Main() => BenchmarkRunner.Run<ConcatBench>();
}

Pitfalls

Interview questions

Recap

The things to carry forward

Say this out loud: “First I would profile to find the hot path. Then I would cut allocations there: rent buffers, slice spans instead of substrings, and avoid boxing. I would prove the gain with BenchmarkDotNet and the memory diagnoser.”

Where this goes next

Some speed and power come from code that writes code. Metaprogramming covers reflection, expression trees, source generators and the C# 14 partial members.


← A 07 — Null Safety and Errors A 09 — Metaprogramming →