﻿# 認識 C# 7.2 與 .NET Core 2.1 推出的 Span<T> 結構

其實 C# 的 `Span<T>` 結構已經問世很多年了，直到昨天有同事問我這個東西要怎麼用，我就把多年前做好的簡報給他研究，然後順便複習一下這個厲害的玩意。

![Performance](https://stwillblogassets.blob.core.windows.net/files/images/external/stwillblogassets.blob.core.windows.net/5a295bc3a0e07b4f8cbe-d72b8fe0-93f6-4925-9c0c-35324ea9bbd0.webp)

### 什麼是 `Span<T>` 結構

`Span<T>` 結構是一個泛型結構，它可以用來表示一個**連續的記憶體區塊**，而且它是一個**可變動**的結構，也就是說你可以透過它來修改記憶體中的資料。`Span<T>` 結構是在 .NET Core 2.1 ( C# 7.2 ) 中推出的，它的目的是為了提供一個**高效率**的記憶體存取方式，避免經常在 Heap 配置新的物件，我們可以在不需要額外配置記憶體的情況下，直接存取記憶體中的資料。由於 `Span<T>` 可以直接存取記憶體，減少無意義的記憶體取用，相對的也會大幅減少 GC (Garbage Collector) 的發生，這對於提高效能和降低記憶體用量非常有幫助。

> 若要在 .NET Framework 4.6.1+ 使用 `Span<T>` 型別，必須安裝 [System.Memory](https://www.nuget.org/packages/System.Memory) 套件。

簡單來說，`Span<T>` 提供一個**安全**且**有效率**的方式存取記憶體！

-   **安　全**：宣告記憶體空間後就無法存取超出範圍的記憶體
-   **有效率**：減少配置記憶體的動作發生

### 幾個 `Span<T>` 使用範例

我用一段簡短的程式碼來說明 `Span<T>` 結構的用法，他可以將一個 `int[]` 轉成 `Span<int>` 結構：

```csharp
// 建立一個整數陣列
int[] numbers = { 1, 2, 3, 4, 5 };

// 使用 Span<T> 建立一個切片(Slice)
Span<int> slice = numbers.AsSpan();

// 修改切片的內容，這也會修改原始陣列元素的內容 (因為直接存取記憶體的關係)
slice[2] = 99;

// 原始陣列的值也已經被修改
Console.WriteLine(numbers[2]); // 輸出 99
```

在 MemoryExtensions 靜態類別下有個 `AsSpan()` 擴充方法，支援將許多型別轉換成 `Span<T>` 結構。我再以一個 `string` 轉成 `ReadOnlySpan<char>` 的情境來說明：

```csharp
// 建立一個字串
string name = "Will";

// 使用 .AsSpan() 建立一個 ReadOnlySpan<T> 唯讀切片(Slice)
ReadOnlySpan<char> slice = name.AsSpan();

// 直接存取記憶體，不用另外建立一個字串物件
Console.WriteLine(slice[0]); // 輸出 "W"
```

### 用 `Span<T>` 真的很快嗎？

我用 [BenchmarkDotNet](https://github.com/dotnet/BenchmarkDotNet) 對比了一下使用 `Span<T>` 切片與 `String` 的 `Split()` 方法的效能。

先看看我們用 `String` 的 `Split()` 方法的程式碼：

```csharp
static string date = "2022-04-15";

[Benchmark]
public (string, string, string) ParseDateUsingSplit()
{
    var y = date.Split("-")[0];
    var m = date.Split("-")[1];
    var d = date.Split("-")[2];

    return (y, m, d);
}
```

再看看使用 `Span<T>` 切片的程式碼：

```csharp
static string date = "2022-04-15";

[Benchmark]
public (string, string, string) ParseDateUsingReadOnlySpan()
{
    ReadOnlySpan<char> nameAsSpan = date.AsSpan();

    var y = nameAsSpan.Slice(0, 4);
    var m = nameAsSpan.Slice(5, 2);
    var d = nameAsSpan.Slice(8);

    return (y.ToString(), m.ToString(), d.ToString());
}
```

以下是跑完效能比較的結果，使用 `ReadOnlySpan<char>` 的執行效率整整快了 4.7 倍：

![BenchmarkDotNet](https://stwillblogassets.blob.core.windows.net/files/images/external/stwillblogassets.blob.core.windows.net/92db9f147b207130a427-f0951ad7-ab62-4ebd-a6db-042081ee6c90.webp)

不過，如果我拿掉回傳字串的寫法，單純的比較字串處理效率的話，`ReadOnlySpan<char>` 的效率將會比 `Split` 操作快了 `557` 倍！

```csharp
static string date = "2024-09-19";

[Benchmark]
public void ParseDateUsingSplit()
{
    var y = date.Split("-")[0];
    var m = date.Split("-")[1];
    var d = date.Split("-")[2];
}

[Benchmark]
public void ParseDateUsingReadOnlySpan()
{
    ReadOnlySpan<char> nameAsSpan = date.AsSpan();

    var y = nameAsSpan.Slice(0, 4);
    var m = nameAsSpan.Slice(5, 2);
    var d = nameAsSpan.Slice(8);
}
```

![BenchmarkDotNet](https://stwillblogassets.blob.core.windows.net/files/images/external/stwillblogassets.blob.core.windows.net/0a65b0e2109106a6edf8-31561444-0fd9-4054-9939-e68f00595c31.webp)

經過網友 **土阿** 的提醒，我加入了 `Substring()` 的效能評比，結果意外的發現，使用 `Substring()` 的處理方法，竟然比 `ReadOnlySpan<char>` 的 `ToString()` 還快，還快了 `1.26` 倍！

```csharp
[Benchmark]
public (string, string, string) ParseDateReturnString_UsingSubstring()
{
    var y = date.Substring(0, 4);
    var m = date.Substring(5, 2);
    var d = date.Substring(8, 2);

    return (y, m, d);
}

[Benchmark]
public (string, string, string) ParseDateReturnString_UsingReadOnlySpan()
{
    ReadOnlySpan<char> nameAsSpan = date.AsSpan();

    var y = nameAsSpan.Slice(0, 4);
    var m = nameAsSpan.Slice(5, 2);
    var d = nameAsSpan.Slice(8);

    return (y.ToString(), m.ToString(), d.ToString());
}
```

我想應該是 `ParseDateReturnString_UsingReadOnlySpan()` 最後的輸出的 3 個字串，因為需要將 `ReadOnlySpan<char>` 轉成 3 個新的字串，需要配置全新的記憶體空間，最終導致了效能變差的結果！

我們之所以要用 Span 或 ReadOnlySpan 結構，其目的不外乎就是有效率的去處理一段**連續的記憶體區塊**，處理的過程是非常快的，我們從上述的效能檢測看的出來，但是最終如果又會產生新的記憶體空間，那麼效能就會變差，所以我們在使用 Span 結構的時候，一定要注意不要產生新的記憶體空間，否則就會失去效能的優勢。

接著，我又調整了一下寫法如下，利用 `Int32.Parse()` 方法，將字串轉成 Int32 型別。這次的評測結果就比較符合預期了，使用 `ReadOnlySpan<char>` 的版本快了 `1.67` 倍：

```csharp
[Benchmark]
public (int, int, int) ParseDateReturnInt32_UsingSubstring()
{
    var y = date.Substring(0, 4);
    var m = date.Substring(5, 2);
    var d = date.Substring(8, 2);

    return (Int32.Parse(y), Int32.Parse(m), Int32.Parse(d));
}

[Benchmark]
public (int, int, int) ParseDateReturnInt32_UsingReadOnlySpan()
{
    ReadOnlySpan<char> nameAsSpan = date.AsSpan();

    var y = nameAsSpan.Slice(0, 4);
    var m = nameAsSpan.Slice(5, 2);
    var d = nameAsSpan.Slice(8);

    // 注意: Int32.Parse() 方法也有支援 ReadOnlySpan<char> 的重載方法
    return (Int32.Parse(y), Int32.Parse(m), Int32.Parse(d));
}
```

由於 `ParseDateReturnInt32_UsingReadOnlySpan()` 方法，在處理字串時，一直都沒有產生新的字串物件，因此記憶體使用率較佳，效能也較好。

### 總結

自從 .NET Core 2.1 開始，之所以 .NET Core 在效能上能有大幅改進，其中 [ref struct types](https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/builtin-types/ref-struct?WT.mc_id=DT-MVP-4015686) 與 [Span](https://learn.microsoft.com/en-us/dotnet/api/system.span-1?view=net-6.0&WT.mc_id=DT-MVP-4015686) 的出現功不可沒，事實上 .NET 的 BCL (Base Class Library) 已經有許多方法都採用了 `Span<T>` 與 `ReadOnlySpan<T>` 結構，例如：

-   [Int32.Parse](https://learn.microsoft.com/en-us/dotnet/api/system.int32.parse?view=net-6.0#system-int32-parse\(system-readonlyspan\(\(system-char\)\)-system-globalization-numberstyles-system-iformatprovider\))
-   [StringBuilder.Append](https://learn.microsoft.com/en-us/dotnet/api/system.text.stringbuilder.append?view=net-6.0#system-text-stringbuilder-append\(system-readonlyspan\(\(system-char\)\)\))
-   [Path.GetFileName](https://learn.microsoft.com/en-us/dotnet/api/system.io.path.getfilename?view=net-6.0#system-io-path-getfilename\(system-readonlyspan\(\(system-char\)\)\))
-   [Stream.Read](https://learn.microsoft.com/en-us/dotnet/api/system.io.stream.read?view=net-6.0#system-io-stream-read\(system-span\(\(system-byte\)\)\))
-   ...

所以之後有機會遇到陣列或字串操作，都可以先想到用 `Span<T>` 來處理，相信你會有意想不到的效能提升！

Span 還有許多的跟記憶體相關的開發技巧，如果有興趣深入研究，可以參考本文的**相關連結**。

### 相關連結

-   Microsoft Learn
    -   [ref struct types - C# reference](https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/builtin-types/ref-struct?WT.mc_id=DT-MVP-4015686)
        -   [Span Struct](https://learn.microsoft.com/en-us/dotnet/api/system.span-1?view=net-6.0&WT.mc_id=DT-MVP-4015686)
        -   [ReadOnlySpan Struct (System)](https://learn.microsoft.com/en-us/dotnet/api/system.readonlyspan-1?view=net-6.0&WT.mc_id=DT-MVP-4015686)
        -   [MemoryExtensions.AsSpan 方法](https://learn.microsoft.com/zh-tw/dotnet/api/system.memoryextensions.asspan?view=net-6.0&WT.mc_id=DT-MVP-4015686)
    -   [Memory and spans - .NET | Microsoft Learn](https://learn.microsoft.com/en-us/dotnet/standard/memory-and-spans/?WT.mc_id=DT-MVP-4015686)
        -   [Memory and Span usage guidelines](https://learn.microsoft.com/en-us/dotnet/standard/memory-and-spans/memory-t-usage-guidelines?WT.mc_id=DT-MVP-4015686)
-   NDepend
    -   [Improve C# code performance with Span](https://blog.ndepend.com/improve-c-code-performance-with-spant/)
-   CODE Magazine
    -   [New NET Core 2.1 Flagship Types: Span and Memory](https://www.codemag.com/article/1807051/Introducing-.NET-Core-2.1-Flagship-Types-Span-T-and-Memory-T)
-   Medium
    -   [An Introduction to Writing High-Performance C# Using Span Struct | by Nishān Wickramarathna](https://nishanc.medium.com/an-introduction-to-writing-high-performance-c-using-span-t-struct-b859862a84e4)
-   博客园
    -   [.NET高性能编程 - C#如何安全、高效地玩转任何种类的内存之Span的本质(一)。 - justmine](https://www.cnblogs.com/justmine/p/10006621.html)
