Csharp Span

Span

Span是 C# 7.2 引入的值类型,核心作用是零分配、高性能地表示连续的内存区域,可以直接操作数组、字符串、非托管内存等连续内存块,避免不必要的内存拷贝,大幅减少GC压力。
总的来说:Span就像一个 “内存视图”,不拥有内存,只是对已有内存的安全封装和高效访问。

1.1 核心用法

1.1.1 高性能内存操作,避免数组拷贝

传统方式处理数组子集时会产生拷贝,Span直接操作原内存:

1
2
3
4
5
6
7
// 传统方式(有拷贝)
int[] source = { 1, 2, 3, 4, 5 };
int[] subArray = new int[3];
Array.Copy(source, 1, subArray, 0, 3); // 拷贝数组,产生新内存

// Span方式(无拷贝)
Span<int> subSpan = source.AsSpan(1, 3); // 直接指向原数组的子集,无拷贝

1.1.2 字符串高效处理

字符串的Substring会创建新字符串(内存拷贝),而ReadOnlySpan.Slice无拷贝:

1
2
3
4
5
6
7
8
string longText = "这是一段很长的文本,只需要截取前10个字符";

// 传统Substring(有拷贝)
string subText = longText.Substring(0, 10);

// Span方式(无拷贝)
ReadOnlySpan<char> subTextSpan = longText.AsSpan(0, 10);
string result = subTextSpan.ToString(); // 仅在需要时才创建字符串

1.1.3 替代不安全指针操作

Span在安全代码中实现了指针级别的性能,无需unsafe关键字:

1
2
3
4
5
6
7
8
9
10
11
12
13
// 不安全的指针方式(需unsafe上下文)
unsafe
{
int[] arr = { 1, 2, 3 };
fixed (int* p = arr)
{
p[1] = 10;
}
}

// Span方式(安全,无unsafe)
Span<int> arrSpan = new[] { 1, 2, 3 };
arrSpan[1] = 10; // 直接修改原数组,安全且高效

1.2 注意事项

  • Span是栈绑定的,不能用于异步方法的参数和返回值;不能用于类的字段;不能被闭包捕获。
  • 只读场景用ReadOnlySpan例如字符串、不可变数据等,防止意外修改
  • 仅用于连续内存,非连续不能用比如List

1.3补充

查看Span的定义可以发现它的结构体定义前面包含ref。ref struct是 C# 7.2 为配合Span这类高性能内存类型引入的特殊结构体类型,核心规则是:强制该结构体只能分配在栈上,绝对不能进入堆。普通结构体虽默认栈分配,但存在被 “装箱” 到堆的可能(比如赋值给object、接口,或作为类字段),而ref struct通过编译器强制禁止所有堆分配行为。

1.3.1 强制栈分配,杜绝内存安全问题

ref struct的设计初衷是为了安全地操作非托管内存 / 连续内存(比如Span需要指向数组、字符串或非托管内存的指针)。如果允许ref struct进入堆,会引发严重的内存安全问题:

  • 栈内存的生命周期由栈帧(方法调用)决定,方法执行完栈帧销毁,栈内存就释放;
  • 堆内存的生命周期由 GC 决定,若ref struct(含内存指针)被放到堆上,其指向的栈内存可能已释放,导致悬垂引用(类似 C++ 野指针)。

通过ref struct强制栈绑定,确保:

  • ref struct的生命周期 ≤ 其引用的内存生命周期
  • 编译器直接阻断所有可能将ref struct放到堆的操作(比如赋值给类字段、异步方法参数等)。

总之ref struct的核心作用即:强制栈分配 + 允许安全持有 ref / 指针字段,杜绝因堆分配导致的内存悬垂、野指针等安全问题

1.3.2 ref T和T*

这俩都是指向内存地址,可以直接操作内存,但绝对不是一个东西。

  • ref T是C#提供的安全托管的内存引用,而T*是非托管的指针,需要unsafe上下文,不受CLR安全管控
  • 在发生GC Compact时,ref T回跟踪对象挪动位置自动调整引用地址,而T*可能会导致悬垂引用
  • ref T提供强类型安全,不能随意转换,而T*弱类型安全,可以强制转换为任意指针。ref T编译器提供引用有效性,例如栈绑定,T*无约束

1.4 与stackalloc配合

stackalloc是 C# 关键字,用于在栈上分配连续的内存块(而非堆),分配的内存会随方法栈帧销毁自动释放(无需 GC 回收),极致高性能,但受栈大小限制(默认进程栈大小约 1MB),仅适合小内存块分配。

1
2
// 栈上分配连续内存:生命周期仅当前方法
Span<T> span = stackalloc T[size];

在C# 7.2 前,stackalloc需要配合非托管指针使用(unsafe块)

1
2
3
4
5
// 不需要fixed,因为是栈内存,不会被GC移动
unsafe
{
int* p = stackalloc int[16];
}

1.5 一个小应用

如何通过索引器像数组一样访问结构体内部的字段?下面提供了三种方式

1.5.1 指针写法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 没有LayoutKind.Explicit,防止字段重排,类型是 blittable
[StructLayout(LayoutKind.Sequential)]
public unsafe struct Data
{
public int A;
public int B;
public int C;

public int this[int i]
{
get
{
if ((uint)i >= 3)
throw new IndexOutOfRangeException();

// fixed:固定A的内存地址(防止GC移动,结构体栈上时无实际作用,但语法要求)
fixed (int* p = &A)
{
return p[i];
}
}
}
}

1.5.2 MemoryMarshal + span

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

[StructLayout(LayoutKind.Sequential)]
public struct Data
{
public int A;
public int B;
public int C;

public int this[int i]
{
get
{
if ((uint)i >= 3)
throw new IndexOutOfRangeException();

Span<int> span = MemoryMarshal.CreateSpan(ref A, 3);
return span[i];
}
}
}

1.5.3 Unsafe.Add

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
[StructLayout(LayoutKind.Sequential)]
public struct Data
{
public int A;
public int B;
public int C;

public int this[int i]
{
get
{
if ((uint)i >= 3)
throw new IndexOutOfRangeException();

return Unsafe.Add(ref A, i);
}
}
}