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.
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.
GC.AllocateArray<T>(n, pinned: true). They never move, so they do not fragment the normal heap.GC.TryStartNoGCRegion(bytes): reserves memory up front and promises no GC until EndNoGCRegion. Useful for a short latency-critical burst.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.
<ServerGarbageCollection>true</ServerGarbageCollection>.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();
}
}
}
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);
GC.Collect() in production code. It promotes live objects and usually makes things slower.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.
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.
IDisposable when your type owns something disposable or unmanaged.using var for a resource that lives to the end of the method. Use a block using (...) { } when it must close earlier.IAsyncDisposable and await using (C# 8) when cleanup does I/O, such as flushing a stream.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;
}
}
T.Check("using order", DisposeDemo.UsingOrder(), ["body", "dispose b", "dispose a"]);
T.Check("double dispose", DisposeDemo.DoubleDispose(),
["derived managed", "base managed", "blocked"]);
_disposed check. Without one, a second Dispose reruns the derived cleanup before the base check stops it.IDisposable. Only add one when you own a raw handle, and prefer SafeHandle even then.using var lives to the end of the block. A lock or file may stay open longer than you think.using hands the caller a dead object.disposing flag mean? True means user code called Dispose, so managed fields are safe to touch. False means the finalizer called it, so only unmanaged state may be released.GC.SuppressFinalize(this)? It removes the object from the finalization queue. The object can then be collected in one GC instead of surviving an extra one.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.
SafeHandle subclass. Then your own class needs no finalizer.NativeMemory or Marshal.AllocHGlobal when a handle type does not fit.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);
}
T.Check("safe handle once", FinalizerDemo.SafeHandleReleases(), 1);
T.Check("buffer freed", FinalizerDemo.BufferFreed(), true);
T.Check("finalizer ran", FinalizerDemo.FinalizerRuns(), 1);
GC.KeepAlive fixes that.SafeHandle over a finalizer? It is hardened against thread aborts and handle recycling, counts references during P/Invoke calls, and releases once. Your class then needs no finalizer at all.Dispose for timely cleanup.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.
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;
}
}
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);
array.Length. The extra slots hold old data.clearArray: true to Return if they held secrets.Rent(100) return 128 elements? The shared pool keeps buckets of power-of-two sizes. It hands out the smallest bucket that fits.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>.
string.Concat for a fixed number of parts. Both are already fast.StringBuilder when you append in a loop or the size is unknown.string.Create when you know the exact length and want zero extra copies, such as masking or hex encoding.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];
}
});
}
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");
string.Create lambda. Pass them through state and mark the lambda static.StringBuilder for two or three pieces is slower than plain interpolation.StringBuilder a capacity when you can guess the size. It avoids regrowth copies.s += x in a loop slow? Each += allocates a new string and copies all old characters. Over n steps that is O(n²) work and n garbage strings.string.Create save over StringBuilder? The builder’s buffer and the final copy. The string is allocated once at its exact size and filled in place.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.
TryWrite into a Span<char>.$"..." got faster in .NET 6 is the interview value.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}";
}
}
T.Check("handler on", HandlerDemo.Run(true), "lines=1 calls=1");
T.Check("handler off", HandlerDemo.Run(false), "lines=0 calls=0");
ref struct. It cannot be stored, boxed, or used across await.ToStringAndClear, the pooled buffer inside DefaultInterpolatedStringHandler is never returned.$"{a}{b}" faster in .NET 6 than in .NET 5? The compiler now lowers it to DefaultInterpolatedStringHandler. It formats values into a pooled span with no boxing and no params object[].shouldAppend to false, so the compiler skips the appends and the hole expressions.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.
stackalloc for small, bounded scratch space. Use a size cap and fall back to the heap or a pool above it.Split.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);
}
}
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");
stackalloc can overflow the stack and kill the process. There is no catch for that.stackalloc inside a loop keeps growing the frame until the method returns. Hoist it out of the loop.await. Use Memory<T> there.SkipLocalsInit the stack buffer is not zeroed. Write before you read.Span<T> be a field of a class? It is a ref struct that may point at stack memory. A heap object could outlive that stack frame, so the compiler forbids it.stackalloc and ArrayPool? stackalloc for small, known-bounded sizes, a few hundred bytes. The pool for larger or variable sizes.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.
MemoryMarshal cover most old uses. Reach for pointers last.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;
}
}
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);
fixed blocks short. Pinned objects block GC compaction.fixed. It is invalid after the block.fixed exist? The GC moves objects when it compacts. fixed pins the object so a raw pointer to it stays valid.unsafe code faster? Not by default. The JIT already removes many bounds checks. Spans give most of the speed with safety.System.Runtime.CompilerServices.Unsafe: generic low-level helpers like As, Add, SizeOf and BitCast (.NET 8). No unsafe keyword needed.MemoryMarshal: reinterpret spans. Cast<byte, int> views bytes as ints, Read<T> reads a struct from bytes.CollectionsMarshal: reach inside BCL collections. AsSpan(list) exposes a List<T>’s backing array. GetValueRefOrAddDefault returns a ref to a dictionary slot.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.
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);
}
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);
ref can resize it. The ref then points at stale memory.CollectionsMarshal.AsSpan breaks if the list grows. The span still shows the old array.MemoryMarshal.Cast uses the machine’s byte order. Use BinaryPrimitives for file and network formats.CollectionsMarshal.GetValueRefOrAddDefault gives a ref to the slot, adding it if missing. Increment through the ref.Unsafe.As? It skips type checks. A wrong cast is not caught and can corrupt memory or crash the runtime.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.
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.
Pack = 1 only to match a binary format. Unaligned access can be slower.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;
}
}
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);
LayoutLoose 24 bytes and not 10? The long must start on an 8-byte boundary. Padding is added after each byte so the struct size is a multiple of 8.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.
ReadOnlyDictionary or ImmutableDictionary may fit better.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);
}
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);
FrozenDictionary faster to read? At build time it studies the keys. Then it picks a special strategy, such as hashing only a slice of each key.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.
ContainsAnyExcept.static readonly field. Creating it costs more than one search.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);
}
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);
SearchValues per call throws away the benefit. Keep it static.IndexOf or IndexOfAny(a, b) is already vectorized.StringComparison. Only Ordinal and OrdinalIgnoreCase are supported.IndexOfAny(char[])? That re-analyzes the set on every call. SearchValues does the analysis once and picks a SIMD strategy for that set.Regex, URI parsing, JSON and HTML encoding, and many string helpers since .NET 8.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.
+= is waste.sb += "text".operator + too, so a + b still works and returns a new value.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]}";
}
}
T.Check("compound", CompoundDemo.Run(), "same=True new=False h=3 copy=4 bin2=2");
readonly field works on a copy.a += b and a = a + b observably equal in value.a += b mean for user types before C# 14? Always a = a + b, which calls a static operator that returns a new object.void? It changes this in place. There is no new value to return, so nothing is allocated.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.
Stopwatch for rough timing and production telemetry.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;
}
}
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>();
}
DateTime.Now for timing. It is low resolution and can jump.Stopwatch? The first runs include JIT time, tiering may swap code mid-test, the GC adds noise, and dead code may be removed. BenchmarkDotNet controls all of these.Dispose releases resources on time. Use using var, make Dispose idempotent, and let SafeHandle own raw handles.ArrayPool, string.Create, spans and small stackalloc buffers.CollectionsMarshal.GetValueRefOrAddDefault counts with one lookup. FrozenDictionary and SearchValues speed up fixed lookups.Some speed and power come from code that writes code. Metaprogramming covers reflection, expression trees, source generators and the C# 14 partial members.