C# 高级教程 - 09 Span 与高效内存编程

Span 是 .NET Core 2.1 引入的里程碑式类型,它提供了一种类型安全、无需额外分配的内存视图抽象,能够在不复制数据的前提下操作连续内存块。配合 Memory、ReadOnlySpan 和 stackalloc,开发者可以编写出接近 C/C++ 级别性能的托管代码。本文将深入探讨这些值类型的内存语义和典型应用。

Span 本质

1
2
3
4
5
6
// Span<T> 是一个 ref struct —— 只能存储在栈上
public readonly ref struct Span<T>
{
private readonly ref T _reference;
private readonly int _length;
}

关键特点

  • 栈上分配(ref struct),不产生 GC 压力
  • 可引用数组、原生内存(IntPtr)、栈内存(stackalloc)
  • 提供类似数组的切片(slice)语义而不复制数据

创建 Span

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 1. 从托管数组
int[] array = [1, 2, 3, 4, 5];
Span<int> span1 = array;

// 2. 从数组切片(零分配!)
Span<int> slice = array.AsSpan(1, 3); // [2, 3, 4]

// 3. 从原生内存
IntPtr ptr = Marshal.AllocHGlobal(100);
Span<byte> nativeSpan;
unsafe
{
nativeSpan = new Span<byte>(ptr.ToPointer(), 100);
}
Marshal.FreeHGlobal(ptr);

// 4. 从栈内存(stackalloc)
Span<byte> stackSpan = stackalloc byte[256];
Span<int> ints = stackalloc int[10] { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 };

Span 在字符串处理中的应用

避免 Substring 引起的新字符串分配:

1
2
3
4
5
6
7
8
9
10
// 传统方式:每次 Substring 都创建新字符串
string fullName = "张三,工程师,北京";
string name = fullName.Substring(0, 2); // 分配

// 现代方式:零分配切片
ReadOnlySpan<char> span = fullName.AsSpan();
ReadOnlySpan<char> nameSpan = span[..2]; // 无分配
ReadOnlySpan<char> titleSpan = span[3..6]; // 无分配

Console.WriteLine(nameSpan.ToString()); // 仅在需要字符串时分配

高性能解析 CSV

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
static void ParseCsvLine(ReadOnlySpan<char> line, Span<ReadOnlySpan<char>> fields)
{
int fieldIndex = 0;
int start = 0;

for (int i = 0; i <= line.Length; i++)
{
if (i == line.Length || line[i] == ',')
{
if (fieldIndex < fields.Length)
{
fields[fieldIndex++] = line[start..i];
}
start = i + 1;
}
}
}

// 使用
ReadOnlySpan<char> csvLine = "张三,28,工程师";
Span<ReadOnlySpan<char>> fields = stackalloc ReadOnlySpan<char>[3];
ParseCsvLine(csvLine, fields);
Console.WriteLine(fields[0].ToString()); // 张三(无字符串分配)

Memory —— 堆上版本的 Span

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// Memory<T> 是结构体,可以存储在堆上(如异步方法中)
async Task ProcessDataAsync(Memory<byte> buffer)
{
// 在异步方法中无法使用 Span<T>,但可以用 Memory<T>
// 然后通过 .Span 属性获取 Span
Span<byte> span = buffer.Span;
span[0] = 0xFF;

// 或者使用 async 友好的 MemoryManager
await WriteToNetworkAsync(buffer);
}

static async ValueTask WriteToNetworkAsync(Memory<byte> data)
{
// 模拟异步写入
await Task.Delay(10);
}

ReadOnlySequence —— 非连续内存

当数据分布在多个连续内存段(如网络缓冲区的链式结构)时使用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// ReadOnlySequence<T> 核心在 System.Memory
using System.Buffers;

ReadOnlySequence<byte> sequence;

// 应用:高性能 pipeline 解析器(如 ASP.NET Core Kestrel 内部)
struct HttpParser
{
public bool TryParseRequest(ref ReadOnlySequence<byte> buffer, out HttpRequest request)
{
var reader = new SequenceReader<byte>(buffer);
// 使用 SequenceReader<T> 高效解析
return reader.TryReadTo(out ReadOnlySpan<byte> line, (byte)'\n');
}
}

ArrayPool —— 数组池化

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
using System.Buffers;

byte[] buffer = ArrayPool<byte>.Shared.Rent(1024); // 至少 1024 字节
try
{
int bytesRead = await stream.ReadAsync(buffer);
Span<byte> validData = buffer.AsSpan(0, bytesRead);
Process(validData);
}
finally
{
ArrayPool<byte>.Shared.Return(buffer);
// 可选:清除数据避免泄漏(敏感信息场景)
// ArrayPool<byte>.Shared.Return(buffer, clearArray: true);
}

ArrayPool 内部:维护了多个桶(bucket),每个桶按 2 的幂次大小(16, 32, 64, …)组织数组。请求 1024 时返回 1024 大小的数组(也可能稍大)。重用数组减少 GC Gen 1/2 回收频率。

实战:高性能 Base64 编码器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
using System.Buffers;
using System.Buffers.Text;

public static int Base64Encode(ReadOnlySpan<byte> source, Span<char> destination)
{
return Base64.EncodeToUtf8(source, destination, out _, out _);
}

// 或手动实现高效转换
public static int FastToHex(ReadOnlySpan<byte> bytes, Span<char> hex)
{
const string hexChars = "0123456789ABCDEF";

for (int i = 0; i < bytes.Length; i++)
{
byte b = bytes[i];
hex[i * 2] = hexChars[b >> 4];
hex[i * 2 + 1] = hexChars[b & 0xF];
}
return bytes.Length * 2;
}

unsafe 与 Span

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
unsafe
{
// 通过指针创建 Span
int* ptr = stackalloc int[100];
var span = new Span<int>(ptr, 100);

// 修改
span[0] = 42;

// 固定托管对象获取 Span
fixed (int* arrayPtr = array)
{
var fixedSpan = new Span<int>(arrayPtr, array.Length);
}
}

实战:高性能日志格式化

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
using System.Buffers;
using System.Text;

public static class FastLogger
{
private static readonly SearchValues<char> NewLines = SearchValues.Create(['\n', '\r']);

public static string Format(string level, ReadOnlySpan<char> message)
{
var now = DateTime.UtcNow;
int capacity = 30 + level.Length + message.Length; // 粗略估计

// 使用 ValueStringBuilder 避免 StringBuilder 分配
using var builder = new ValueStringBuilder(stackalloc char[256]);
builder.Append(now.ToString("yyyy-MM-dd HH:mm:ss"));
builder.Append(" [");
builder.Append(level);
builder.Append("] ");
builder.Append(message);

return builder.ToString();
}
}

// ValueStringBuilder 是 .NET 内部的 ref struct
// 在需要高性能字符串拼接时,既可以手动实现也可以使用 StringBuilder(分配更多)

常见性能误区

1
2
3
4
5
6
7
8
9
10
11
12
13
// 错误:每次调用都分配新的 Span(其实是栈分配,本身无 GC,但可能引起大栈)
Span<byte> buffer = stackalloc byte[1024 * 10]; // 10KB 栈空间 —— 可能栈溢出!

// 更好:使用 ArrayPool
byte[] poolBuffer = ArrayPool<byte>.Shared.Rent(10240);
Span<byte> safeBuffer = poolBuffer;

// 错误:将 Span 作为装箱类型使用
object boxed = (object)new Span<int>(); // 编译错误!ref struct 不可装箱

// 错误:在 lambda/闭包中捕获 Span
Span<int> span = array;
Action action = () => Console.WriteLine(span[0]); // 编译错误!ref struct 不能在闭包中

性能数据对比

操作 传统方式 Span 方式 加速比
字符串 Substring 51 ns (分配) 0 ns (零分配)
数组切片 分配新数组 零分配切片
解析 CSV 1000 行 ~500 μs ~50 μs ~10x
Base64 编码 分配 byte[] ArrayPool + Span ~3x
字节反转 分配新数组 原地修改 Span ~2x + 零分配

总结Span<T> 使 C# 无需 unsafe 代码就能写出接近原生的高性能内存操作代码。零分配切片、stackallocArrayPool<T> 的组合大幅降低了 GC 压力。配合 Memory<T>ReadOnlySequence<T>,即使在异步和高性能网络场景下也能游刃有余。掌握 Span-based 编程是进阶高性能 .NET 开发者的必备技能。

ByteFisher
分享编程技术 · 记录钓鱼乐趣
扫码关注
▸ 扫码关注 ◂
分享: