Wstęp

W pewnym momencie powstał (a właściwie „został odkryty”) typ Span<T>. Nagle Microsoft zaczął go wszędzie używać, twierdząc że jest zajebisty. No dobra, ale czym tak naprawdę jest ten Span? I czy faktycznie jest taki zajebisty? Zobaczmy.

Jedno jest pewne. Używasz go, być może nawet o tym nie wiedząc (jest rozlany wszędzie we Frameworku).

Czym jest Span<T>?

Żeby była jasność. Span to NIE JEST kontener.

W zasadzie Span<T> to takie oczko, które gapi się na przekazany fragment pamięci:

I właściwie mógłbym na tym artykuł skończyć, bo obrazek powyżej idealnie oddaje sens i działanie Spanu. Ale ok, jeśli chcesz więcej, to czytaj dalej 🙂

Przede wszystkim musisz zrozumieć różnice między stosem i stertą. Jeśli nie jesteś pewien, koniecznie przeczytaj ten artykuł, w którym nieco nakreślam sytuację.

Span jako wskaźnik

Span<T> jest w pewnym sensie wskaźnikiem w świecie zarządzanym. Wskazuje na pewien obszar pamięci na stercie (zarządzanej i niezarządzanej). Ale może też wskazywać na pamięć na stosie. Pamiętaj, że jako wskaźnik, sam Span jest alokowany na stosie i zasadniczo składa się z dwóch elementów:

  • adresu miejsca, na który wskazuje
  • wielkości tego miejsca

Można by powiedzieć, że Span<T> wygląda mniej więcej tak:

public readonly ref struct Span<T>
{
    private readonly ref T _pointer;
    private readonly int _length;
}

Tak, mniej więcej taka jest zawartość spanu (+ do tego kilka prostych metod i rozszerzeń). Z tego wynika pierwsza rzecz:

  • Span jest niemutowalny (immutable) – raz utworzonego Spanu nie można zmienić. Tzn., że raz utworzony Span zawsze będzie wskazywał na to samo miejsce w pamięci.

W pamięci może to wyglądać mniej więcej tak:

Na stosie mamy dwie wartości, które składają się na Span. Pointer, jak to pointer, wskazuje na jakiś obszar w pamięci na stercie, natomiast length posiada wielkość tego obszaru. A co to za obszar? Ten, który wskażesz przy tworzeniu Spana.

Uniwersalność tworzenia

Utworzyć Spana możemy właściwie ze wszystkiego. Z tablicy, z listy, z Enumerable, a nawet ze zwykłego niskopoziomowego wskaźnika. To jest jego główna moc i jeden z powodów jego powstania.

I, powtarzam – Span to NIE JEST żadna kolekcja. To NIE JEST tablica. Span nie służy do przechowywania danych, tylko pokazuje Ci fragment pamięci z tymi danymi, które zostały zaalokowane gdzieś wcześniej.

A po co to?

Wyobraź sobie teraz, że masz listę intów, której elementy chcesz zsumować. Możesz przecież zrobić coś takiego:

static void Main(string[] args)
{
    List<int> list = [1, 2, 3];
    var value = Sum(list);
}

private static int Sum(List<int> source)
{
    var sum = 0;
    foreach (var item in source)
    {
        sum += item;
    }

    return sum;
}

I to zadziała super. Masz metodę, która sumuje jakieś dane.

Ok, a teraz załóżmy, że w pewnym momencie ktoś zamiast listy chce dać Ci tablicę. I czym to się kończy? Czymś takim:

static void Main(string[] args)
{
    int[] arr = [1, 2, 3];
    var value = Sum(arr.ToList());
}

private static int Sum(List<int> source)
{
    var sum = 0;
    foreach (var item in source)
    {
        sum += item;
    }

    return sum;
}

Czy to jest w porządku?

Spójrz co się dzieje w linii 4. TWORZYSZ listę. W sensie dosłownym – tworzysz zupełnie nowy obiekt (wywołując ToList()). Wszystkie wartości tablicy są kopiowane i jest tworzona nowa lista, która będzie obserwowana przez Garbage Collector na stercie zarządzanej.

Czyli masz już dwa obiekty obserwowane na tej stercie – tablicę i listę. Pamiętaj, że tworzenie obiektów na stercie jest stosunkowo kosztowne. Poza tym, gdy pracuje Garbage Collector, Twoja aplikacja NIE pracuje, tylko czeka.

I teraz z pomocą przychodzi Span:

static void Main(string[] args)
{
    int[] arr = [1, 2, 3];
    var span = new Span<int>(arr);
    var value = Sum(span);
}

private static int Sum(Span<int> source)
{
    var sum = 0;
    foreach (var item in source)
    {
        sum += item;
    }

    return sum;
}

Co się dzieje w tym kodzie? Przede wszystkim zwróć uwagę, że metoda Sum w ogóle się nie zmieniła (poza typem argumentu, który przyjmuje). Span daje Ci możliwość iterowania bez żadnych przeszkód.

To, co zostało zmienione, to zamiast Listy na stercie, utworzony został Span. A gdzie? Na STOSIE! Ponieważ, jak pisałem na początku – Span to STRUKTURA i jako taka jest tworzona na stosie. Nie ma też narzutu związanego z kopiowaniem danych i tworzeniem obiektu na stercie.

Dlaczego nie ma narzutu związanego z kopiowaniem danych? Bo nic nie jest kopiowane – powtarzam – Span to wskaźnik – on wskazuje na obszar zajmowany przez dane w tablicy arr. I to jest też mega ważne – wskazuje na konkretne dane, a nie na cały obiekt. Innymi słowy, wskazuje na miejsce arr[0]. I to jest właśnie druga główna supermoc Spana (tak samo wskaże na początek danych listy itd).

Porównanie

Zróbmy sobie teraz małe porównanie. Span idealnie działa ze stringami, gdzie widać jego moc już na pierwszy rzut oka. Więc napiszmy sobie prostą apkę, która zwróci ze stringa jakieś obiekty. String będzie w tej postaci:

string data = "firstname=John;lastname=Smith";

Stwórzmy też wynikową klasę:

class Person
{
    public string FirstName { get; set; }
    public string LastName { get; set; }
}

Aby wykonać to zadanie bez użycia Spana, napisalibyśmy kod mniej więcej taki:

private static Person ProcessDataUsingString(string data)
{
    var values = data.Split(";"); //alokacja dwóch nowych stringów i tablicy
    var firstName = values[0].Substring(values[0].IndexOf('=') + 1); //alokacja nowego stringu
    var lastName = values[1].Substring(values[1].IndexOf('=') + 1); //alokacja nowego stringu

    return new Person
    {
        FirstName = firstName,
        LastName = lastName
    };
}

Natomiast ze Spanem mogłoby to wyglądać tak:

private static Person ProcessDataUsingSpan(string data)
{
    var span = data.AsSpan();

    var fNameValues = span.Slice(0, span.IndexOf(';'));
    var lNameValues = span.Slice(span.IndexOf(';') + 1);

    var firstName = fNameValues.Slice(fNameValues.IndexOf('=') + 1);
    var lastName = lNameValues.Slice(lNameValues.IndexOf('=') + 1);

    return new Person
    {
        FirstName = firstName.ToString(), //alokacja nowego stringu
        LastName = lastName.ToString() //alokacja nowego stringu
    };
}

Zrobiłem Benchmark dla jednego takiego rekordu:

MethodMeanErrorStdDevRatioGen0AllocatedAlloc Ratio
ProcessWithString48.81 ns0.231 ns0.193 ns1.000.0421264 B1.00
ProcessWithSpan26.69 ns0.534 ns0.525 ns0.550.0179112 B0.42

Jak widać Span jest zdecydowanie bardziej wydajny. Nie tylko pod względem czasu wykonania, ale i alokacji pamięci. Właściwie jedyne alokacje, jakie się tu odbyły są już podczas tworzenia nowego obiektu Person – gdy przypisywane są konkretne nowe stringi do obiektu.

Span tylko do odczytu

Zwykły Span pozwala na zmianę konkretnych danych w pamięci. A jeśli jednak chciałbyś użyć jego bezpieczniejszej wersji, to możesz ReadOnlySpan. Działa dokładnie tak samo, tylko nie umożliwia zmiany danych.

I ta właśnie wersja jest zwracana przez stringa – czyli wciąż nie możesz zmienić raz utworzonego stringa (bez użycia kodu unsafe i niskopoziomowych wskaźników).

Możesz mieć tu teraz mały mindfuck – jak to span jest niemutowalny, ale można zmienić mu dane?

Span jest niemutowalny pod tym względem, że raz utworzony zawsze będzie wskazywał na ten sam fragment pamięci. Ale to, co jest w tej pamięci możesz zmienić (chyba że masz ReadOnlySpan). Zobaczysz to dalej w artykule.

Na co nie pozwala?

Nie można zmienić stringu

Jak już pisałem, Span nie pozwala na zmianę elementów stringu. String jest zawsze niemutowalny i nic na to nie poradzisz (poza niskopoziomowymi wskaźnikami).

Nie może być częścią klasy

Z tego powodu, że Span MUSI BYĆ alokowany na stosie, nie może być elementem klasy. W sensie właściwością, czy polem. Elementy klasy mogą być alokowane na stercie, a Span nie może, bo ma to zabronione. Za to Span może być częścią ref struct. Ale w takiej sytuacji musisz uważać.

Musisz używać go mądrze

Pamiętasz jak pisałem, że raz utworzony Span zawsze będzie wskazywał dokładnie to samo miejce w pamięci? Musisz wiedzieć, co masz w tym miejscu, żeby nie doszło do wykrzaczenia:

byte[] arr = [1, 2, 3];
var span = new Span<byte>(arr);
arr = null!;

GC.Collect();

span[0] = 10;

Spójrz, najpierw alokujemy pamięć na tablicę. Na stosie znajduje się adres do elementów tej tablicy (czyli zmienna arr). Tworzymy sobie Span dla tej tablicy – Span teraz wskazuje na elementy tablicy na stercie.

Następnie usuwamy naszą tablicę – w efekcie nic tego miejsca już nie używa. Garbage Collector w pewnym momencie może je wyczyścić albo mogą zostać tam zaalokowane inne rzeczy. Natomiast Span cały czas wskazuje na tę pamięć. I przypisując jakąś wartość możesz doprowadzić do Access Violation.

Więc pod tym względem musisz być uważny.

Nie możesz go używać w asynchronicznych operacjach

Jako, że każdy wątek ma swój oddzielny stos, a Stack jest alokowany na stosie, to nie może być używany w operacjach asynchronicznych. Jeśli potrzebujesz czegoś takiego, użyj Memory<T>. O tym może też coś napiszę.

Czy Span jest zajebisty?

To w sumie tylko narzędzie. Odpowiednio użyte może sprawdzić, że aplikacje w C# będą naprawdę wydajne. Niemniej jednak cieszę się, że coś takiego powstało, ponieważ to jest jedna z rzeczy, których brakowało mi trochę, a które używałem w innych językach. Czyli wskaźniki. I to bez użycia kodu unsafe 🙂


Dzięki za przeczytanie artykułu. Jeśli czegoś nie rozumiesz lub znalazłeś jakiś błąd, koniecznie daj znać w komentarzu 🙂

Podziel się artykułem na: