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

📚 Interview question

How do you use stackalloc safely in modern C#? Explain stackalloc into Span<T>, the unmanaged type requirement, stack size limits and why a stack overflow kills the process, the hybrid stackalloc-or-ArrayPool pattern, why you must not stackalloc in loops, SkipLocalsInit, and how to choose a threshold.

Safe stackalloc: span usage, size limits, hybrid pooling pattern, loops and SkipLocalsInit.

Mid-Level Coding Advanced arraypool performance skiplocalsinit span stackalloc

🎯 Key points

  • stackalloc into Span<T> gives GC-free stack memory without unsafe code
  • Only unmanaged element types are allowed
  • Thread stacks are small and stack overflow terminates the process
  • Use a fixed small threshold and fall back to ArrayPool<T> for larger sizes
  • Return pooled fallbacks in finally and clear them if they held sensitive data
  • Never stackalloc inside loops or deep recursion; memory is freed only at method return
  • SkipLocalsInit skips zeroing and is safe only with write-before-read

⏱ 30-second answer

stackalloc reserves memory on the current thread's stack; assigned to a Span<T> (C# 7.2+) it needs no unsafe code, is freed automatically when the method returns and costs nothing for the GC. It only works for unmanaged element types (byte, char, int, unmanaged structs). The stack is small (commonly around 1 MB per thread on Windows, and other platforms differ), and a stack overflow cannot be caught: the process dies. So use it only for small, bounded sizes, with the hybrid pattern: stackalloc below a fixed threshold (for example 256 bytes to 1 KB), otherwise rent from ArrayPool<T> and return it in finally. Never stackalloc in a loop, because memory is only released when the method returns. [SkipLocalsInit] skips zeroing the buffer for speed, which is safe only if you write before you read.

🗣 Full answer (senior)

How I would answer this in an interview

stackalloc allocates a block of memory on the current thread's stack, which is freed automatically when the method returns — no GC involvement. In modern C# you assign it to a Span<T>, which keeps it memory-safe (bounds checked, no pointers). It's excellent for small, bounded, temporary buffers. The danger is that the stack is small and a stack overflow kills the process, so safe usage means capping the size and falling back to ArrayPool<T>, never allocating in loops, and understanding SkipLocalsInit.

1. stackalloc into Span<T>

Span<byte> hash = stackalloc byte[32];                  // SHA-256 output
SHA256.HashData(input, hash);
string hex = Convert.ToHexString(hash);

Span<char> buffer = stackalloc char[64];
if (value.TryFormat(buffer, out int written, "N2", CultureInfo.InvariantCulture))
    writer.Write(buffer[..written]);
  • No unsafe keyword needed when assigning to a Span<T>/ReadOnlySpan<T> (C# 7.2+).
  • The span is a ref struct, so it can't escape the method (can't be returned, stored or used across await).
  • Since C# 8, stackalloc can be used in nested expressions (Span<int> s = n < 128 ? stackalloc int[n] : new int[n];).

2. The unmanaged type requirement

The element type must be unmanaged: primitives, enums, pointers, or structs containing only unmanaged fields (recursively). You can't stackalloc string[] or arrays of structs containing references, because the GC doesn't scan stack-allocated buffers for references.

3. Stack size limits and why overflow is fatal

  • Default thread stack sizes are ~1 MB on Windows and typically 8 MB for the main thread on Linux (secondary threads often ~1.5 MB in .NET); thread-pool threads use the runtime default. Deep call chains in ASP.NET Core already use some of it.
  • A StackOverflowException can't be caught — the runtime terminates the process immediately (no finally, no logging). In a web app, every in-flight request on that instance fails and the container restarts.
  • So never stackalloc with a size controlled by user input or unbounded data.

4. The hybrid stackalloc-or-ArrayPool pattern

const int StackThreshold = 256;   // chars

char[]? rented = null;
Span<char> buffer = length <= StackThreshold
    ? stackalloc char[StackThreshold]
    : (rented = ArrayPool<char>.Shared.Rent(length));
try
{
    buffer = buffer[..length];
    Fill(buffer);
    return Process(buffer);
}
finally
{
    if (rented is not null) ArrayPool<char>.Shared.Return(rented);
}
  • Small inputs (the common case) use the stack — zero allocation.
  • Large inputs use a pooled array — no stack risk, still minimal allocation.
  • Use a constant size in the stackalloc (here StackThreshold) so the JIT can optimise, then slice.
  • Always return pooled arrays in finally. Clear them first if they held sensitive data.

5. Why not stackalloc in loops

for (int i = 0; i < items.Count; i++)
{
    Span<byte> tmp = stackalloc byte[256];   // NOT freed per iteration!
}

Stack memory from stackalloc is released only when the method returns, not at the end of the loop body. Each iteration allocates more stack — 1,000 iterations × 256 bytes = 256 KB; a larger loop overflows the stack. Hoist the stackalloc outside the loop and reuse the buffer (analyser CA2014 warns about this).

6. SkipLocalsInit

By default, the C# compiler emits the localsinit flag, so the runtime zeroes stack-allocated memory and locals. For large stackalloc buffers that you'll fully overwrite, zeroing is wasted work. [SkipLocalsInit] (on a method, type, or module; requires <AllowUnsafeBlocks>true</AllowUnsafeBlocks>) removes the flag:

[SkipLocalsInit]
static int Encode(ReadOnlySpan<char> input, Span<byte> output) { Span<byte> scratch = stackalloc byte[512]; ... }

Risk: the buffer contains garbage from previous stack frames — reading before writing can leak data or produce nondeterministic bugs. Use only when every byte is written before being read.

7. Choosing a threshold

  • Common BCL thresholds are 256–1024 bytes (e.g. 256 chars, 512 bytes, 1 KB).
  • Consider: element size × count, the method's position in the call stack (deep recursion? called from deep middleware?), and the platform's stack size.
  • Prefer small constant thresholds; above ~1–2 KB, the benefit vs ArrayPool diminishes while risk grows.
  • Benchmark: stack vs pool for typical sizes; make sure the common case hits the stack path.

8. A production example

A service computed HMAC signatures for webhooks using stackalloc byte[payload.Length] for the encoded payload. A partner occasionally sent multi-megabyte payloads, and the process crashed with a stack overflow — no exception logs, just container restarts and failed in-flight requests. We switched to the hybrid pattern (stack for ≤ 1 KB, ArrayPool above) and computed the HMAC incrementally over streams for large payloads. Crashes stopped; small payloads stayed allocation-free.

🔍 What the interviewer is checking

The interviewer checks you can use stack memory for performance without risking crashes.

Green flags

  • Uses stackalloc with Span<T> and knows the unmanaged requirement.
  • Explains stack limits and that stack overflow kills the process.
  • Writes the hybrid stackalloc-or-pool pattern with finally.
  • Knows the loop problem and what SkipLocalsInit does.

Red flags

  • Sizes stackalloc from request data.
  • Allocates in loops.
  • Treats stack overflow as catchable.

📚 Learn in depth

1. What stackalloc compiles to

Span<byte> s = stackalloc byte[32]; emits the IL localloc instruction, which bumps the stack pointer by the requested size (rounded/aligned) and returns a pointer; the compiler wraps it in a span. With localsinit, the runtime zeroes the block. The JIT may also emit stack probes for large allocations to touch guard pages correctly.

2. Constant vs variable sizes

  • Constant-size stackalloc lets the JIT allocate in the prologue and sometimes avoid dynamic stack adjustment.
  • Variable sizes need runtime adjustment and bounds checks; they're also riskier.
  • Pattern: stackalloc T[Const] then slice to the needed length.

3. Stack overflow behaviour

On overflow, the OS raises a guard-page fault; the CLR can't reliably run managed code (no stack left), so it fails fast. In containers, the process exits with a signal/exit code; Kubernetes/ACA restarts it. Logs show only the crash, sometimes with a "Stack overflow" message in stderr.

4. Alternatives

Buffer need Option
Small, bounded, temporary stackalloc
Medium/large temporary ArrayPool<T>.Shared.Rent
Async or stored MemoryPool<T>/arrays
Fixed small inline buffers in structs [InlineArray(N)] (C# 12)
Formatting strings string.Create, interpolated handlers, ValueStringBuilder

5. Analysers

  • CA2014: do not use stackalloc in loops.
  • CA2241/IDE style rules for span usage.
  • Custom banned patterns for variable-size stackalloc in code reviews.

6. SkipLocalsInit scope

Applied at module level ([module: SkipLocalsInit]), it affects all methods — including locals that the C# compiler normally relies on being zero in some unsafe scenarios. Safe C# still requires definite assignment of locals, so ordinary locals are fine; the main exposure is stackalloc buffers and Unsafe.SkipInit.

Try it yourself

  1. Write the hybrid pattern and benchmark small vs large inputs.
  2. Put stackalloc inside a loop and watch CA2014; run with many iterations to see the crash (in a throwaway process).
  3. Use [SkipLocalsInit] and read a buffer before writing to observe garbage.
  4. Measure the default stack size on your platform by recursion depth tests.

💻 Code

using System.Buffers;
using System.Runtime.CompilerServices;
using System.Security.Cryptography;
using System.Text;

public static class Hashing
{
    private const int StackLimit = 512;   // fixed, small, not controlled by input

    [SkipLocalsInit]                       // needs <AllowUnsafeBlocks>true</AllowUnsafeBlocks>; we always write before reading
    public static string Sha256Hex(string input)
    {
        int maxBytes = Encoding.UTF8.GetMaxByteCount(input.Length);
        byte[]? rented = null;
        Span<byte> utf8 = maxBytes <= StackLimit
            ? stackalloc byte[StackLimit]
            : (rented = ArrayPool<byte>.Shared.Rent(maxBytes));
        try
        {
            int written = Encoding.UTF8.GetBytes(input, utf8);
            Span<byte> hash = stackalloc byte[SHA256.HashSizeInBytes];   // 32 bytes: always safe
            SHA256.HashData(utf8[..written], hash);
            return Convert.ToHexString(hash);
        }
        finally
        {
            if (rented is not null) ArrayPool<byte>.Shared.Return(rented, clearArray: true); // may hold user data
        }
    }

    public static int CountVowels(IEnumerable<string> words)
    {
        Span<char> lower = stackalloc char[64];        // allocated ONCE, outside the loop
        int count = 0;
        foreach (string word in words)
        {
            ReadOnlySpan<char> source = word.AsSpan(0, Math.Min(word.Length, lower.Length));
            int n = source.ToLowerInvariant(lower);
            foreach (char c in lower[..n]) if ("aeiou".Contains(c)) count++;
        }
        return count;
    }
}

public static class Program
{
    public static void Main()
    {
        Console.WriteLine(Hashing.Sha256Hex("hello"));                  // stack path
        Console.WriteLine(Hashing.Sha256Hex(new string('x', 10_000)));  // pooled path, no stack risk
        Console.WriteLine(Hashing.CountVowels(["Sydney", "Melbourne", "Adelaide"]));
    }
}

⚠ Common mistakes

1. Variable-size stackalloc from input

What happens: stackalloc byte[request.Length]. Why it is wrong: large inputs overflow the stack and kill the process. Do instead: cap with a constant threshold and use ArrayPool otherwise.

2. stackalloc in loops

What happens: allocating a buffer inside each iteration. Why it is wrong: stack memory accumulates until the method returns. Do instead: hoist the buffer outside the loop (CA2014).

3. Forgetting to return pooled arrays

What happens: the fallback path rents and never returns. Why it is wrong: pool exhaustion and more allocations. Do instead: return in finally.

4. Returning or storing stackalloc spans

What happens: trying to return the span or keep it in a field. Why it is wrong: compile errors (and dangling memory if forced unsafely). Do instead: copy results out before returning.

5. Using SkipLocalsInit carelessly

What happens: reading parts of uninitialised buffers. Why it is wrong: garbage data, nondeterministic bugs, possible leaks. Do instead: only skip init when every element is written first.

6. Large constant buffers in deep call stacks

What happens: stackalloc byte[16_384] in methods called from deep middleware. Why it is wrong: combined stack use may overflow. Do instead: keep stack buffers small.

7. Not slicing to the actual length

What happens: processing the full threshold-sized buffer. Why it is wrong: includes garbage or zeroes beyond the data. Do instead: slice to the used length.

8. Using stackalloc where allocation doesn't matter

What happens: micro-optimising rarely called code. Why it is wrong: adds risk and complexity. Do instead: use it only on measured hot paths.

🏛 Senior insight: trade-offs

stackalloc is the cheapest buffer in .NET, but its failure mode is the worst: an uncatchable process crash. That asymmetry drives the senior rules: fixed small sizes, never input-controlled, never in loops, and always a pooled fallback.

Rule Reason
Constant size, 256 B to 1 KB predictable stack use, simpler JIT code
Pool fallback large inputs stay safe
No loops or recursion stack use must be bounded
SkipLocalsInit only with write-before-read correctness and security
Clear pooled buffers holding secrets data leaks between requests

I also mention that the BCL uses exactly this pattern internally, so following it is idiomatic rather than clever.

🔗 Follow-ups the interviewer may ask

  1. When: Candidate explains where stackalloc memory lives. Why: Checks stack versus heap fundamentals.
    How to answer

    The stack is a per-thread region that holds method frames: locals, arguments, return addresses and saved registers. Allocating is just moving the stack pointer, and everything is freed when the method returns. The heap is shared memory for objects whose lifetime is not tied to one method; the generational garbage collector frees them once they are unreachable.

    The common line "value types live on the stack, reference types on the heap" is only half true. Storage follows the declaration: a value-type local usually lives on the stack (or in a register), but a value-type field lives inside its object on the heap, and boxing, lambdas that capture locals, iterators and async methods move data to the heap. Reference-type objects are conceptually heap objects (only the reference sits on the stack), although the current JIT's escape analysis can place a small object that provably never leaves the method on the stack as an optimisation. The language guarantees lifetime and copy semantics, not a location.

  2. When: Candidate uses the hybrid pattern. Why: ArrayPool is the fallback for large sizes.
    How to answer

    Pooling bugs are lifetime bugs: the pool gives a returned array to the next renter, so any code still using it reads or writes someone else's data. The usual causes are use after return (returning in finally while an async continuation, a queued Memory<T> or a lazy enumerable still uses the array), double return (two renters then share one array), treating array.Length as the data length (Rent returns an array at least as large as requested, with old contents), not clearing buffers that held secrets (Return(array, clearArray: true)), and returning arrays not rented from that pool. To debug: reproduce under concurrency, add canaries or a poisoning wrapper pool in tests that fills returned arrays with a marker and detects double returns, trace System.Buffers.ArrayPoolEventSource events, and review every rent for a single owner and a single return in finally.

  3. When: Candidate assigns stackalloc to a span. Why: stackalloc results are used through Span<T>.
    How to answer

    Span<T> is a ref struct holding a reference and a length: a typed, bounds-checked view over contiguous memory, whether that memory is an array, a stackalloc buffer, native memory or part of a string (through ReadOnlySpan<char>). Slicing (span[2..6], Slice) creates a new view without copying, so parsers and formatters can work on pieces of a buffer with no allocations. Because it can point at the stack, it is stack-only: it cannot be a field of a class, be boxed, be captured by a lambda, or live across await/yield (C# 13 allows span locals in async and iterator methods as long as they do not cross those points). Helpers in MemoryExtensions (IndexOf, SequenceEqual, Sort, Trim) are vectorised. CollectionsMarshal.AsSpan(list) exposes a list's backing array, but adding to the list invalidates it.

🛠 Real-world problems: how you handle them

Input-sized stackalloc crashed web servers
Situation
An API gateway hashed request header values with `stackalloc byte[value.Length * 3]` for a cache key, across [16] instances.
Signals
Instances restarted abruptly with exit code indicating stack overflow (no exception logged); restarts correlated with requests carrying very large headers.
Investigation
Crash dumps captured with DOTNET_DbgEnableMiniDump showed the stack overflow in the hashing helper; the header size limit had been raised to [64 KB] for a partner.
Resolution
Changed the helper to a fixed 512-byte stackalloc with an ArrayPool fallback and added a header length guard.
Prevention
Enabled CA2014 and a custom review rule against variable-size stackalloc, plus a fuzz test with oversized headers.

How to tell it (STAR)

**Situation:** our [API gateway] instances were crashing without any exception in the logs. **Task:** I was on call and had to stop the restarts. **Action:** I enabled crash dumps, found a stack overflow in a hashing helper that sized stackalloc from the header length, and replaced it with a fixed small stack buffer plus an ArrayPool fallback. **Result:** crashes stopped immediately, and our fuzz tests now include oversized inputs for every buffer helper.

↩ Usually asked after

More in Memory-Oriented C#

Last updated 30 Oct 2026.

🌐 Sangam's Webring

⏪ Previous 🏠 Main Next ⏩
💬 Chat with me