(C#) Span<T>

 

C# 7.2 스펙에서 새롭게 추가된 Span<T> 고성능 메모리 뷰의 구조체 입니다.

이 글에서는 C# Span<T> 이 무엇인지, 어떤 상황에서 사용하면 효율적인지 알아보겠습니다.

모든 프로세스는 프로세스가 실행될때 OS로 부터 가상 메모리(Virtual Memory System)를 할당 받게 되는데 닷넷의 가상 메모리는 크게 힙(Heap) 영역과 스택(Stack) 영역으로 나뉘어 집니다.
그림1

힙(Heap) 영역은 닷넷에서 또 다른 명칭으로 관리 힙 으로 불리우며 객체가 인스턴스화 되는 프로세스의 전역 공간이라고 생각하면 됩니다.
이러한 관리 힙 영역은 가비지콜렉션(Garbage Collection)의 관리 대상이 되어 느리고 GC의 영향을 받게 됩니다.
반면 스택(Stack) 영역은 각 스레드 마다 공간이 할당 되고 힙 영역보다 훨씬 빠릅니다.

ref struct

우선 Span<T> 를 알려면 값 타입의 Struct를 알아볼 필요가 있습니다.
Struct는 Stack에 할당될 수도 있고 Heap에 할당 될 수도 있는데 스택에 할당되어 있는 값 형식을 힙 영역에 할당 될때는
박싱이 되어 메모리에 할당이 되고 그 반대는 언박싱이 되어 지는데 이 과정은 GC 개입으로 성능이 저하 되어
높은 성능을 위해 때로는 힙 할당 없이 스택에 할당해 처리를 해야 하는 경우가 있습니다.

이때 지원 되는 값 타입의 구조체가 ‘ref struct’ 입니다. ‘ref struct’ 는 반드시 스택에만 할당 되도록 할 수 있는 타입 입니다.
구조체를 정의할때 ‘ref struct’로 정의하기만 하면 됩니다.

ref struct Point
{
    public int X;
    public int Y;

    public void Offset(int dx, int dy)
    {
      ...
    }
}

이렇게 정의한 구조체는 오직 스택에만 존재할 수 있기 때문에 클래스의 맴버변수로 사용이 불가능하고 로컬 변수와 메서드의 매게 변수로만 사용이 가능합니다.
또한 읽기 전용의 ‘readonly ref struct’ 를 지원합니다.

Span<T>

Span<T> 구조체는 위에서 설명한 ref strcut 타입의 구조체 입니다.
그렇기 때문에 ref struct의 특성과 동일하게 힙 영역 할당에 영향받는 박싱 작업은 불가능합니다.
마찬가지로 스택에만 존재할 수 있는데 Span<T> 가 지원되기 이전에 스택에 메모리 블록을 할당할 수 있는 stackalloc 키워드를 이용해서 안전하게 Span<T> 로 사용할 수 있습니다.

[unsafe 구문에서만 사용 가능했던 stackalloc 사용 코드]

unsafe
{
    int length = 3;
    int* numbers = stackalloc int[length];
    for (var i = 0; i < length; i++)
    {
        numbers[i] = i;
    }
}

이제 위 코드는 Span<T> 를 활용해 다음과 같이 손실 없이 안전하게 사용할 수 있습니다.

int length = 3;
Span<int> numbers = stackalloc int[length];
for (var i = 0; i < length; i++)
{
    numbers[i] = i;
}

하지만 stackalloc으로 할당된 배열을 Span<T> 로 받아 메서드의 반환값으로 처리하려 하는 경우 컴파일 오류가 발생하게 됩니다.

var fooFun = () =>
{
    Span<int> span = stackalloc[] { 1, 2, 3, 4, 5 };
    return span;  // 컴파일 오류 [CS8352]
};

이유는 스택에만 존재 가능한 배열은 해당 메서드의 스코프를 벗어나게 되면 즉시 해제가 되기 때문에 해당 값은 반환값으로 사용하지 못하기 때문입니다.
마찬가지 이유로 클래스의 속성 또는 맴버 변수로 정의도 불가능 합니다.

public class FooC<T>
{
    public Span<T> arrayView { get; set; } = new Span<T>();  // 컴파일 오류 [CS8345]
}


그리고 메모리 뷰(참조)를 제공하기 때문에 어떤 배열 타입이든지 Span<T> 으로 처리할 수 있습니다.

var intNums = new[] {1, 2, 3, 4, 5};
Span<int> arrView = intNums;

이 처럼 Span<T> 은 힙 영역이나 스택 영역, stackalloc으로 할당한 비 관리 메모리 영역 까지 공통으로 접근할 수 있는 만능(?) 타입 입니다.
Span<T> 은 메모리의 참조 뷰를 제공하는데 아래 그림 처럼 메모리 일부의 영역을 참조받아 사용할 수 있습니다.
image

메모리 참조 뷰는 Slice() 메서드를 사용해서 효율적으로 사용할 수 있습니다.

var buffer = new byte[10];
Span<byte> bytes = buffer;

Span<byte> slicedBytes = bytes.Slice(start: 5, length: 2);
slicedBytes[0] = 42;
slicedBytes[1] = 43;
bytes[2] = 44;
bytes[5] = 45;

메모리의 참조형태의 View 형식이기에 Span<T> 에서 Slice()로 일부 값을 변경하면 원본의 객체도 변경 되고, 마찬가지로 원본 객체의 값이 변경 되도 Span<T> View 에도 반영 됩니다.
또 하나의 예제를 살펴 보겠습니다.

string str = "hello, world";
// world 문자열에 대해 Allocates 됨
string newStr = str.Substring(startIndex: 7, length: 5);

// Allocates 없음
ReadOnlySpan<char> newStrSpan =
  str.AsSpan().Slice(start: 7, length: 5);

위 코드는 “hello world” 문자열에 대해 일부 문자를 처리하는 코드인데 일반적인 System.String<T> 의 Substring() 메서드 사용은 새롭게 힙 할당이 일어 나는 반면, System.ReadOnlySpan<T> 사용의 Slice() 메서드는 할당 없이 일부의 영역 뷰만 얻어 올 수 있습니다.
이렇게 가르키는 일부 영역은 읽기 전용이기 때문에 값 변경은 불가능 합니다.

 newStrSpan[0] = 'a'  // 컴파일 오류 [CS8331]

ReadOnlySpan<T>

Span<T> 타입 외 읽기 전용의 ‘readonly ref struct’ 구현체와 동일한 System.ReadOnlySpan<T> 타입이 있습니다.
System.ReadOnlySpan<T> 는 인덱서를 통해 읽기 전용으로 객체에 접근 할 수 있도록 제공 됩니다.
이러한 읽기 전용은 불변 타입의 객체를 다룰때 적당합니다.

// String은 불변(immutable) 타입
// 읽기 전용의 char 배열로 받아 들임
ReadOnlySpan<char> test = "   Hello, World! ";
// Hello, World!
Console.WriteLine(test.Trim().ToArray());

Memory<T>

Span<T> 은 스택에만 할당 될 수 있는 제약 조건으로 인해 클래스의 속성이나 맴버 변수로 사용이 불가능합니다.
만약 클래스의 속성으로 사용이 필요한 경우 Memory<T> 타입에서 Span<T> 타입으로 변환 하거나 또는 System.MemoryExtensions 클래스에서 확장 메서드로 지원 되는 타입중 AsSpan<T>() 메서드를 사용해서 Span<T> 로 변환하여 사용이 가능합니다.

public class FooC<T>
{
    public Memory<T> memory { get; set; }
    public ArraySegment<T> arraySegment { get; set;}
}

var nums = new[] { 1, 2, 3, 4, 5 };
FooC<int> fooc = new();
fooc.memory = nums.AsMemory();
fooc.arraySegment = nums;

// Memory<T> -> Span<T>
var spanView = fooc.memory.Span;
// AsSpan() 사용 -> Span<T>
var spanView2 = fooc.arraySegment.AsSpan();

그리고 Memory<T> 도 읽기 전용의 System.ReadOnlyMemory<T> 구조체 타입을 지원 하고 있어,
Span속성으로 System.ReadOnlySpan<T> 타입으로 사용할 수 있습니다.


이 처럼 System.Memory<T>System.Span<T> 를 사용하면 관리 / 비 관리 메모리 할당 방법에 관계 없이 연속적인 메모리에 엑세스 할 수 있고 고성능이 필요한 환경에서 유용하게 사용할 수 있습니다.