Skip to main content
    Courses/C#/Unsafe Code & Span<T>

    Lesson 24 • Advanced Track

    Unsafe Code, Span<T> & High-Performance C#

    By the end of this lesson you'll slice arrays and strings with zero allocations using Span<T>, hand big buffers back to the runtime with ArrayPool, and understand exactly when raw pointers and unsafe are worth the risk — and when they absolutely aren't.

    What You'll Learn

    • Use Span<T> and ReadOnlySpan<T> to slice memory without copying it
    • Allocate temporary buffers on the stack with stackalloc (no GC)
    • Store and pass buffers with Memory<T> and reuse them with ArrayPool<T>
    • Know the ref struct rules: why a Span can't be a field, boxed, or awaited
    • Read genuine unsafe pointer code: int*, & , fixed, and sizeof
    • Decide when unsafe/pointers/fixed are actually warranted (and when Span wins)

    💡 Real-World Analogy

    Think of an array as a long bookshelf. A Span<T> is a window frame you place over part of that shelf: you can read and rearrange the books you can see through the frame, but you never lift the shelf or copy a single book. Moving the frame to a different section (Slice) costs nothing — you're just looking somewhere else on the same shelf. Everything in this lesson is about working through that window instead of photocopying the books, because copying is what makes programs slow and creates garbage. unsafe pointers, by contrast, are like taking the shelf off the wall and carrying it by hand — far more freedom, far more chances to drop it.

    📊 The High-Performance Toolbox

    TypeLives onWhat it gives youUse when
    Span<T>Stack onlyNo-copy, writable window over memoryHot loops, slicing, parsing — inside one method
    ReadOnlySpan<T>Stack onlyRead-only window (e.g. over a string)Viewing strings/data you must not change
    Memory<T>Heap-safeSpan you can store in a field / use across awaitAsync pipelines, buffers held over time
    stackallocStackA small buffer with zero GC costTiny, short-lived scratch space
    ArrayPool<T>Heap (pooled)Rent & return big arrays instead of allocatingLarge reusable buffers in hot paths
    int* / unsafeRaw pointerDirect memory access, no bounds checksP/Invoke & rare interop only

    Rule of thumb: reach for the row that's highest in this table that solves your problem. Span<T> covers the vast majority of "make it faster" needs safely; pointers should be your last resort, not your first.

    1. Span<T> — a No-Copy Window Over Memory

    A Span<T> is a small struct that holds just two things: where some contiguous memory starts and how many items it covers. It never owns or copies the data — it points at memory you already have (an array, a string, or a stack buffer). That's why slicing is free: Slice just produces a new "where/how many" pair over the same bytes. Because it's bounds-checked, you still get the safety of an array index without the cost of a copy. Read this worked example, run it, then you'll write your own.

    Worked example: slice an array & a string

    Read every comment, run it, and check the output matches.

    Try it Yourself »
    C#
    using System;
    
    class Program
    {
        static void Main()
        {
            // A Span<T> is a "window" over memory you already have.
            // It copies NOTHING — it just remembers "start here, this many items".
            int[] numbers = { 10, 20, 30, 40, 50, 60, 70, 80 };
    
            // .AsSpan() makes a Span over the WHOLE array (zero allocation).
            Span<int> all = numbers.AsSpan();
            Console.WriteLine($"Length: {all.Length}");        // Length: 8
    
            // Slice(start, length) makes a small
    ...

    Your turn. The program below is almost complete — fill in the three blanks marked ___ using the hints, then run it. You'll slice the middle of an array and sum it without copying a single element.

    🎯 Your turn: slice an int[] and sum it

    Fill in AsSpan, the slice length, then check the sum against the expected output.

    Try it Yourself »
    C#
    using System;
    
    class Program
    {
        static void Main()
        {
            // 🎯 YOUR TURN — replace each ___ then press "Try it Yourself".
            int[] scores = { 5, 10, 15, 20, 25, 30 };
    
            // 1) Make a Span over the WHOLE array (no copy)
            Span<int> span = scores.___();      // 👉 AsSpan
    
            // 2) Slice the MIDDLE FOUR: start at index 1, take 4 items -> {10,15,20,25}
            Span<int> middle = span.Slice(1, ___); // 👉 the count: 4
    
            // 3) Add up the slice WITHOUT copying — 
    ...

    Now a second exercise that brings in two more no-copy tools: stackalloc (a tiny buffer placed on the stack, with no garbage-collector cost) and a ReadOnlySpan<char> view onto part of a string. Both run perfectly safely — no unsafe flag needed.

    🎯 Your turn: stackalloc + a string view

    Fill in the stackalloc keyword and the slice length, then run it.

    Try it Yourself »
    C#
    using System;
    
    class Program
    {
        static void Main()
        {
            // 🎯 YOUR TURN — two no-allocation windows, no /unsafe needed.
    
            // PART A — a tiny scratch buffer on the STACK (no 'new', no GC).
            // stackalloc gives a Span<int> backed by stack memory.
            Span<int> buffer = ___ int[3];      // 👉 stackalloc
            buffer[0] = 1;
            buffer[1] = 2;
            buffer[2] = 3;
            Console.WriteLine($"buffer total = {buffer[0] + buffer[1] + buffer[2]}");
    
            // PART 
    ...

    2. Memory<T> & ArrayPool — Buffers That Outlive a Method

    Span<T> has one big restriction: it can only live on the stack, so you can't store it in a class field or carry it across an await (more on why below). When you need a buffer that outlives a single method — an async download, say — use Memory<T>, the heap-safe sibling. You slice it the same no-copy way, then call .Span for fast access when you're ready to touch the bytes. And when you need big buffers repeatedly, ArrayPool<T> lets you rent an array and return it, so the garbage collector never sees the churn.

    Worked example: Memory<T> & ArrayPool<T>

    See heap-safe slicing and rent/return buffer reuse in action.

    Try it Yourself »
    C#
    using System;
    using System.Buffers;
    
    class Program
    {
        static void Main()
        {
            // Memory<T> is Span<T>'s heap-friendly cousin: it CAN be stored in
            // a field and used across 'await' (Span<T> cannot — see below).
            int[] data = { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 };
            Memory<int> memory = data.AsMemory();
    
            // Slice the same no-copy way:
            Memory<int> slice = memory.Slice(3, 4);            // {4,5,6,7}
    
            // To actually touch the elements, get a Span 
    ...

    🔎 Deep Dive: why Span<T> is a ref struct

    Span<T> is declared as a ref struct, which is the compiler's way of saying "this value must stay on the stack." That single rule is the reason for every restriction you'll hit:

    • It can't be a field of a normal class. Class instances live on the heap, and a stack-only value can't be stored there.
    • It can't be boxed (assigned to object or an interface) — boxing puts it on the heap, which is forbidden.
    • It can't survive an await or yield. Those suspend the method and stash locals on the heap; a stack-only Span can't be stashed there. Use Memory<T> across await instead.

    Why bother with such a strict type? Because the stack-only guarantee is exactly what makes a Span safe and fast: the runtime knows the memory it points at can't outlive the current stack frame, so it needs no extra bookkeeping. The restrictions are the feature.

    class Cache { Span<int> data; }   // ❌ CS8345: ref struct can't be a field
    object o = numbers.AsSpan();      // ❌ CS0029-style: a Span can't be boxed
    Memory<int> m = numbers.AsMemory(); // ✅ this one CAN live in a field / await

    3. Unsafe Code & Pointers — Read It, Rarely Write It

    C# lets you drop to raw pointers inside an unsafe block: & takes a variable's address, int* is "pointer to an int", and fixed pins a managed array so the garbage collector won't move it while you hold a pointer into it. This is real, low-level power — and almost always the wrong tool. It needs the /unsafe compiler flag (<AllowUnsafeBlocks>true</AllowUnsafeBlocks> in your .csproj), it removes bounds checking, and a single slip is undefined behaviour. Study the example below to recognise pointer code in the wild; the expected output is in the comments because the in-browser runner may not enable unsafe.

    ⚠️ Illustrative only — requires the /unsafe compiler flag. The next sample uses genuine pointers and fixed. It may not run in the in-browser editor; the // ✅ Expected output comments show what it produces on a machine with AllowUnsafeBlocks enabled.

    Worked example (illustrative): pointers, fixed & sizeof

    Read the pointer mechanics — see the inline expected output. Needs /unsafe to run.

    Try it Yourself »
    C#
    using System;
    
    class Program
    {
        // ⚠️ This whole method needs the /unsafe compiler flag
        // (<AllowUnsafeBlocks>true</AllowUnsafeBlocks> in the .csproj).
        // It's here to SEE pointers — the in-browser runner may refuse it.
        static unsafe void Main()
        {
            // & takes the address of a variable; int* is "pointer to int".
            int value = 42;
            int* ptr = &value;
            Console.WriteLine($"*ptr = {*ptr}");          // ✅ Expected output: *ptr = 42
    
            // 'fixed' PIN
    ...

    Putting It Together: an Allocation-Free CSV Parser

    Here's a small but genuinely useful program that uses this lesson's core skill. "100,200,300".Split(',') would allocate an array and three new strings. The version below walks a ReadOnlySpan<char> over the original text and parses each field in place — zero extra allocations. It runs safely, no unsafe needed.

    Worked example: parse CSV with no allocations

    Slice the string in place and parse each field. Change the numbers and re-run.

    Try it Yourself »
    C#
    using System;
    
    class Program
    {
        static void Main()
        {
            // === Parse "100,200,300" into 3 ints with ZERO extra allocations ===
            // The classic Split(',') would allocate an array AND 3 substrings.
            // ReadOnlySpan<char> slices the same memory instead.
            ReadOnlySpan<char> csv = "100,200,300".AsSpan();
            int sum = 0;
    
            while (true)
            {
                int comma = csv.IndexOf(',');
                // The last field has no comma -> take the whole remaining 
    ...

    Notice int.Parse accepts a ReadOnlySpan<char> directly — most modern .NET parsing APIs do, which is what makes allocation-free pipelines possible.

    Pro Tips

    • 💡 Reach for Span<T> before unsafe: it matches pointer speed but keeps bounds checking. unsafe should be reserved for P/Invoke and true interop.
    • 💡 Keep stackalloc small. The stack is tiny (~1 MB). A good ceiling is a few hundred elements; check a size limit before allocating per request.
    • 💡 Always pair Rent with Return, ideally in a finally block. A rented array that's never returned is effectively a leak from the pool's view.
    • 💡 Take ReadOnlySpan<char> parameters for string-processing helpers — callers can pass a substring with no copy, and you can't accidentally mutate it.
    • 💡 Use Memory<T> at API boundaries, Span<T> inside the hot loop. Convert with .Span only when you're ready to crunch the data.

    Common Errors (and the fix)

    • "CS8345: Field or auto-implemented property cannot be of type 'Span<int>'" — a ref struct can't be a class field. Store a Memory<T> (or the array) in the field and convert to a Span locally.
    • "CS4007: 'await' cannot be used on an expression of type ... Span" / a Span you can't use after await — Spans can't cross an await. Hold a Memory<T> across the await and call .Span after it resumes.
    • A Span can't be boxed — assigning one to object or an interface fails to compile because boxing would put a stack-only type on the heap. Keep it as its concrete type.
    • Stack overflow from a big stackallocstackalloc int[10_000_000] blows the ~1 MB stack and crashes the process. Cap the size, or rent a heap buffer from ArrayPool<T> instead.
    • "CS0227: Unsafe code may only appear if compiled with /unsafe" — add <AllowUnsafeBlocks>true</AllowUnsafeBlocks> to a <PropertyGroup> in your .csproj.
    • Garbage values / random crashes from pointer arithmetic — reading *(p + i) past the array length is undefined behaviour; there are no bounds checks. This is exactly why Span<T> is the safer default.

    📋 Quick Reference

    TaskCodeResult
    Span over an arrayarr.AsSpan()no-copy view
    Slice (start, length)span.Slice(2, 4)4 items from index 2
    View a string"hi".AsSpan()ReadOnlySpan<char>
    Stack bufferstackalloc int[3]Span<int>, no GC
    Heap-safe bufferarr.AsMemory()Memory<int>
    Rent / returnpool.Rent(n) / Return(a)reused array
    Address of a variableint* p = &x;unsafe only
    Pin an arrayfixed (int* p = arr)GC won't move it

    Frequently Asked Questions

    Q: When should I actually use unsafe and pointers?

    Rarely. Realistic cases are calling native libraries via P/Invoke, marshalling fixed-layout structs, or a profiled hot path where Span<T> genuinely isn't enough. For everyday slicing, parsing, and buffers, Span<T> gives you the same speed with bounds checking — prefer it every time.

    Q: What's the difference between Span<T> and Memory<T>?

    Span<T> is stack-only and the fastest to use, but can't be a field or cross an await. Memory<T> can live on the heap (fields, async), and you get a Span from it with .Span when you need to work with the data.

    Q: Is stackalloc dangerous?

    Only if you ask for too much. It places memory on the stack (no GC cost), but the stack is small — about 1 MB. Keep allocations to a few hundred elements; a large or unbounded stackalloc overflows the stack and crashes the process. For anything big, use ArrayPool<T>.

    Q: Does slicing with Slice copy the data?

    No. Slice just creates a new Span pointing at part of the same memory. Writing through the slice changes the original array. To get an independent copy you must explicitly call .ToArray().

    Mini-Challenge: Sum a Sub-Range, No Copy

    No blanks this time — just a brief and an outline. Use a Span<int> to add up a slice of the array without copying it. Build it, run it, and check your output against the expected line in the comments.

    🎯 Mini-Challenge: Span<int> sub-range sum

    Slice the array with AsSpan + Slice, loop the slice, and print the total.

    Try it Yourself »
    C#
    using System;
    
    class Program
    {
        static void Main()
        {
            // 🎯 MINI-CHALLENGE: sum a sub-range with Span<int> — no copying
            // 1. Start from this array:
            int[] data = { 2, 4, 6, 8, 10, 12, 14, 16 };
            // 2. Make a Span over the array with .AsSpan().
            // 3. Slice the items from index 2 up to (and including) index 5
            //    -> that's start = 2, length = 4  -> {6, 8, 10, 12}.
            // 4. Loop over the slice and add the values into an int 'total'.
          
    ...

    🎉 Lesson Complete

    • Span<T> / ReadOnlySpan<T> are no-copy windows over memory; Slice is free
    • stackalloc gives a tiny GC-free buffer — but keep it small or you overflow the stack
    • Memory<T> is the heap-safe sibling for fields and await; ArrayPool<T> reuses big buffers
    • ✅ Span is a ref struct: it can't be a field, be boxed, or cross an await — by design
    • unsafe/pointers/fixed are powerful but a last resort — reserved for interop and rare hot paths
    • Next lesson: IDisposable & Resource Management — clean up files, sockets, and unmanaged handles correctly

    Sign up for free to track which lessons you've completed and get learning reminders.

    Previous

    Cookie & Privacy Settings

    We use cookies to improve your experience, analyze traffic, and show personalized ads. You can manage your preferences below.

    By clicking "Accept All", you consent to our use of cookies for analytics and personalized advertising. You can customize your preferences or reject non-essential cookies.

    Privacy PolicyTerms of Service