.NET 数组的上限
这些年经常看到有人抱怨 .NET 数组的最大长度。
在 .NET 里,数组、集合、Span 以及很多相关 API 都是围绕 32 位长度和索引设计的。GitHub 上曾经有一个很长的 issue 讨论 64 位数组支持,但最后以 "won't fix" 关闭,因为这件事会牵涉到运行时、GC、JIT、类型系统、反射以及大量现有代码。
常见的解决办法大概有两类:一类是分配非托管内存,再用一个类包起来;另一类是用交错数组模拟一个更大的数组。这两种方案在某些场景下都能用,但代价也很明显。
手动管理内存很容易出错,而且它更适合非托管数据。像 string、object 这样的引用类型就不适合这个方向。交错数组避开了非托管内存,但本质上仍然是一组数组。你需要管理每个内部数组的大小,访问时要处理跨段边界,和那些期待连续内存区域的 API 配合起来也很别扭。
于是我决定自己做一个方案:
- 能容纳超过 20 亿个元素,并且仍然用一个索引访问。
- 索引应该跟架构相关:32 位系统上保持普通数组的限制,64 位系统上可以支持更大的范围。
- 支持
string和object之类的引用类型。 - 连续托管内存分配。
- 由 GC 管理,用户不需要手动释放内存。
- 支持 NativeAOT,因此不能依赖运行时代码生成或反射。
基本思路
在 .NET 中,最常见的一维、从零开始的数组是 SZArray,也就是 T[]。它的长度受 int 大小限制。想要直接放宽这个限制,就会碰到 GC、JIT、类型系统、反射和基础类库等很多地方。
但这个限制针对的是数组的元素个数,而不是元素背后的字节数。byte[1024] 存 1024 字节,int[1024] 存 4096 字节。它们的数组长度相同,只是每个元素更大。
所以第一个想法很简单:让一个数组元素代表多个逻辑元素。
csharp
struct TwoBytes
{
public byte A;
public byte B;
}
一个包含 20 亿个 TwoBytes 的数组,可以存下 40 亿个字节。它仍然是一个托管数组对象,只是每个元素变成了一小块。
从 .NET 8 开始,我们有了 InlineArrayAttribute。它可以让一个 struct 表示固定数量的重复字段,而不用把每个字段都手写出来。
csharp
using System.Runtime.CompilerServices;
[InlineArray(4)]
struct FourBytes
{
private byte _first;
}
它有一个很方便的地方:InlineArray 也能用于引用类型。
csharp
[InlineArray(4)]
struct FourStrings
{
private string _first;
}
它也能用于泛型:
csharp
[InlineArray(4)]
struct FourElements<T>
{
private T _first;
}
这样一来,一个 FourElements<T> 数组的每个物理元素,就可以容纳四个逻辑上的 T。如果物理数组本身可以有接近 20 亿个块,那么四倍宽度的块就能表示接近 80 亿个逻辑元素。
这就是 BigArray<T> 的核心思路。底层仍然是一个托管数组,但数组元素类型不一定是 T 本身,也可能是一个块类型。BigArray<T> 另外记录真实的逻辑长度,再把这些块里的数据看成一段连续的 T。
构建块类型
最直观的实现,是为每一种块长度都定义一个类型:
csharp
[InlineArray(1)] struct ElementChunk1<T> { private T _first; }
[InlineArray(2)] struct ElementChunk2<T> { private T _first; }
[InlineArray(3)] struct ElementChunk3<T> { private T _first; }
// ...
[InlineArray(65535)] struct ElementChunk65535<T> { private T _first; }
这显然不现实,而且对任意 T 来说也不一定合法。
这里有一个重要的运行时类型加载限制:作为数组元素的值类型不能超过 65,535 字节。对某个 T 来说,块长度是:
csharp
65535 / Unsafe.SizeOf<T>()
所以 byte 可以使用 65,535 的块长度。大小为 8 字节的类型可以使用 8,191。大小为 32 字节的类型可以使用 2,047。确定这个值之后,分配时只需要计算请求的逻辑长度需要多少个物理块。
这也意味着实现不需要为每一个整数都准备一个块类型。只要覆盖 65535 / size 可能产生的那些值就够了。这样块类型数量从 65,535 降到了 510,但仍然不少。
更进一步,块结构体本身也可以组合。
csharp
[InlineArray(2)]
struct ElementChunk2<T>
{
private T _first;
}
[InlineArray(3)]
struct ElementChunk3<T>
{
private T _first;
}
ElementChunk2<ElementChunk3<T>> 表示 2 个包含 3 个值的块,也就是 6 个逻辑 T。ElementChunk23<ElementChunk89<T>> 表示 2047 个逻辑元素。byte 能使用的最大块长度,也就是 65,535,可以写成:
csharp
ElementChunk3<ElementChunk5<ElementChunk17<ElementChunk257<byte>>>>
因为:
text
3 * 5 * 17 * 257 = 65535
因此,我们可以只保留一组质数长度的基础块类型,再通过嵌套组合出其他长度。为了覆盖 1 到 65,535 之间需要的块长度,最后只需要 85 个基础块类型:从 ElementChunk2<T> 到 ElementChunk8191<T>。其他长度都可以由这些基础长度相乘得到。
这比手写几万个字段,或者为每一个长度准备一个 struct 要容易维护得多。
有了这些块类型之后,就可以组合出 1 到 65,535 之间任意需要的块类型:
csharp
var chunkSize = 65535 / Unsafe.SizeOf<T>();
var chunks = length / chunkSize + (length % chunkSize == 0 ? 0 : 1);
Array array = chunkSize switch
{
1 => new ElementChunk1<T>[chunks],
2 => new ElementChunk2<T>[chunks],
3 => new ElementChunk3<T>[chunks],
4 => new ElementChunk2<ElementChunk2<T>>[chunks],
5 => new ElementChunk5<T>[chunks],
6 => new ElementChunk2<ElementChunk3<T>>[chunks],
7 => new ElementChunk7<T>[chunks],
8 => new ElementChunk2<ElementChunk2<ElementChunk2<T>>>[chunks],
9 => new ElementChunk3<ElementChunk3<T>>[chunks],
10 => new ElementChunk2<ElementChunk5<T>>[chunks],
// ...
21845 => new ElementChunk5<ElementChunk17<ElementChunk257<T>>>[chunks],
32767 => new ElementChunk7<ElementChunk31<ElementChunk151<T>>>[chunks],
65535 => new ElementChunk3<ElementChunk5<ElementChunk17<ElementChunk257<T>>>>[chunks],
};
这里的 chunks 表示真实托管数组的长度,和 BigArray<T> 暴露出来的逻辑长度不同。比如逻辑长度是 10,000,而块大小是 4,095,那么实现会分配 3 个物理块。最后一个块只用到一部分,真正的逻辑终点由 _length 记录。
这种做法会不会多分配一些没有用到的空间?答案是会,但非常小。
sizeof(T) |
chunkSize |
最坏情况多出的元素数 | 最坏情况多出的字节数 |
|---|---|---|---|
| 1 | 65535 | 65534 | 65534 B |
| 2 | 32767 | 32766 | 65532 B |
| 3 | 21845 | 21844 | 65532 B |
| 4 | 16383 | 16382 | 65528 B |
| 8 | 8191 | 8190 | 65520 B |
| 16 | 4095 | 4094 | 65504 B |
| 257 | 255 | 254 | 65278 B |
| 32768+ | 1 | 0 | 0 B |
可以看到最坏情况是逻辑长度刚好比块大小的整数倍多 1,而且块大小是 65,535。这时最后一个块只使用 1 个字节,剩下的部分都空着。
类型加载
现在假设 T 是 64 位运行时上的 object。一个引用是 8 字节,所以合法的块长度是 8,191:
text
65535 / 8 = 8191
这意味着 ElementChunk8191<object> 是合法的。但 ElementChunk3<ElementChunk5<ElementChunk17<ElementChunk257<object>>>> 就太大了,因为它包含 65,535 个 object 引用,作为数组元素的值类型会占用 8 * 65535 = 524,280 字节。这样的类型不能被加载,否则运行时在创建数组时会抛出 TypeLoadException。
麻烦的地方在于,代码不会执行和类型不会被加载不能简单画等号。如果一个方法里引用了很多已经构造好的泛型数组类型,JIT 和类型加载器在导入或编译方法时,仍然可能碰到非法组合。结果就是抛出 TypeLoadException,即使真正想分配的是另一个块形状:
csharp
AllocateArray<object>(42); // TypeLoadException: Array of type 'ElementChunk3`1[ElementChunk5`1[ElementChunk17`1[ElementChunk257`1[System.__Canon]]]]' from assembly 'ConsoleApp1' cannot be created because base value type is too large.
Array AllocateArray<T>(int length)
{
if (length <= 8191)
return new ElementChunk8191<T>[length];
else
return new ElementChunk3<ElementChunk5<ElementChunk17<ElementChunk257<T>>>>[length];
}
解决办法是把真正的分配延迟到选中分支之后。分配路径会先计算 T 对应的合法块长度,然后从 switch 里拿到这个块长度对应的分配器,最后只调用这个分配器。
分配器来自一个针对块长度的 switch。每个分支都返回一个静态 lambda,lambda 里只分配一种块类型:
csharp
internal static Func<int, bool, bool, Array> CreateBigArrayAllocator(int chunkLength)
{
return chunkLength switch
{
1 => static (chunks, pinned, uninitialized) =>
AllocateArray<ElementChunk1<T>>(chunks, pinned, uninitialized),
...,
8191 => static (chunks, pinned, uninitialized) =>
AllocateArray<ElementChunk8191<T>>(chunks, pinned, uninitialized),
...,
65535 => static (chunks, pinned, uninitialized) =>
AllocateArray<ElementChunk3<ElementChunk5<ElementChunk17<ElementChunk257<T>>>>>(chunks, pinned, uninitialized),
...,
_ => throw new UnreachableException(),
};
}
实际的 switch 有 510 个 case,但最重要的是它的实现:真正的分配藏在 lambda 后面,而且分配用的辅助方法标记为 NoInlining。
csharp
[MethodImpl(MethodImplOptions.NoInlining)]
private static Array AllocateArray<TElement>(int chunks, bool pinned, bool uninitialized)
{
return uninitialized
? GC.AllocateUninitializedArray<TElement>(chunks, pinned)
: GC.AllocateArray<TElement>(chunks, pinned);
}
这里强行要求间接调用很关键。它可以防止未选中的块数组类型被提前加载。只有和当前 Unsafe.SizeOf<T>() 匹配的块形状会真正实例化,因为 JIT 只会编译实际创建出来的 lambda 背后的方法。对于 object,代码会选择 8191 分支并创建 ElementChunk8191<object>[];65535 分支仍然存在给用于 byte 这样的类型使用,但它不会在 object 路径上被加载。
BigArray
有了块机制之后,BigArray<T> 本身可以保持得很小。
它只保存两个东西:
csharp
internal readonly Array _storage;
internal readonly nint _length;
普通长度下,它会分配一个 ElementChunk1<T>[],布局基本上接近带了一层包装的普通 T[]。更大的长度下,它会计算块长度,分配选中的块数组,并把逻辑长度记录为 nint。
这也是为什么 _storage 的类型是 Array:实际运行时类型取决于 T。它可能是 ElementChunk1<T>[],也可能是 ElementChunk8191<T>[],或者是 ElementChunk3<ElementChunk5<ElementChunk17<ElementChunk257<T>>>>[] 这样的组合块类型。
csharp
public BigArray(nint length)
{
if ((nuint)length > (nuint)MaxLength)
{
ThrowHelpers.ThrowOutOfRange(nameof(length));
}
if (length <= Array.MaxLength)
{
_storage = new ElementChunk1<T>[length];
}
else
{
_storage = CreateBigArraySlow(length);
}
_length = length;
}
然后是索引器实现。这里我们不需要在每次访问时都除以块大小。底层是一个托管数组,数组数据区里连续排列着块结构体,而元素又内联保存在这些块里,因此代码只需要拿到第一个逻辑 T 的引用,然后用普通的引用偏移往后移动。
csharp
public ref T this[nint index]
{
get
{
if ((nuint)index >= (nuint)_length)
{
ThrowHelpers.ThrowOutOfRange(nameof(index));
}
return ref Unsafe.Add(ref GetDataReference(), index);
}
}
这里确实用到了 Unsafe,但它只藏在实现内部。公开 API 的输入会先被验证,然后实现使用引用偏移,避免每一次逻辑访问都再走一次普通数组边界检查。如果 index、length 或 slice 超出合法范围,会在到达这条路径之前失败。对用户来说,公共 API 仍然是安全的;对实现来说,则可以尽量接近直接数组访问的成本。
数据引用是通过把数组数据开头重新解释为 T 得到的:
csharp
private static ref T GetDataReference(Array storage)
{
return ref Unsafe.As<byte, T>(ref MemoryMarshal.GetArrayDataReference(storage));
}
这就是为什么连续存储这个特性很重要。拿到第一个数据引用之后,Unsafe.Add(ref first, index) 会移动 index 个逻辑 T 元素。跨过一个块到下一个块,只是在同一段数组数据区里继续往前走。这样一来,BigArray<T> 不需要像交错数组包装器那样在每次访问时都做除法和取余;它只是把一个托管数组对象视作一段更大的逻辑序列。
最大长度则跟架构有关:
csharp
public static nint MaxLength =>
nint.Size == 4 ? Array.MaxLength : GetChunkLength() * (nint)Array.MaxLength;
在 32 位运行时上,nint 本身无法表示更大的索引空间,所以 BigArray<T> 保持普通数组的限制。
在 64 位运行时上,最大长度会随块大小增长。对 byte 来说,大约是 Array.MaxLength * 65535;对 64 位运行时上的 long 或对象引用来说,大约是 Array.MaxLength * 8191。对于 byte,这意味着它理论上可以表示接近 128 TiB 的数组,准确地说是 127.998 TiB。这里当然说的是理论上限,机器仍然需要真的有足够的内存。
BigSpan 和 BigMemory
只有持有存储的类型还不够。普通 .NET 代码里,数组只是编程模型的一部分。我们还会用 Span<T>、ReadOnlySpan<T>、Memory<T> 和 ReadOnlyMemory<T> 来传递视图。
BigSpan<T> 是一个面向超大连续区域的栈上视图:
csharp
public readonly ref struct BigSpan<T>
{
internal readonly ref T _first;
internal readonly nint _length;
}
它的基本形状和 Span<T> 一样:一个起始引用加一个长度。不同的是,长度是 nint,索引也使用 nint。和 Span<T> 一样,它不拥有内存,只是查看由别的对象保持存活的内存,通常是 BigArray<T> 或 BigMemory<T>。
csharp
BigArray<byte> buffer = new((nint)Array.MaxLength + 1024);
BigSpan<byte> span = buffer.AsBigSpan();
span[Array.MaxLength] = 42;
BigMemory<T> 和 BigReadOnlyMemory<T> 则是可以保存起来的视图。它们记录底层托管数组、起始偏移和长度:
csharp
internal readonly Array? _storage;
internal readonly nint _start;
internal readonly nint _length;
当你需要高效的引用访问时,它们的 Span 属性会生成 BigSpan<T> 或 BigReadOnlySpan<T>。由于 BigMemory<T> 把底层托管数组保存在 _storage 里,它可以被放进字段或从方法返回,同时仍然让这段存储对 GC 可见。
csharp
BigMemory<byte> page = buffer.AsBigMemory(1024, 4096);
page.Span.Fill(0);
API 的设计则尽量沿用了普通 Span/Memory 的习惯:切片、复制、搜索、排序、trim、split、ToArray、ToBigArray 以及只读转换。实现内部如果需要调用只接受 Span<T> 或 ReadOnlySpan<T> 的 BCL API,就把数据拆成能放进 int 的片段来处理。
BigSpan<T> 并不指望让所有现有 API 都接受超过 int.MaxValue 个元素。它给你一个大索引视图,并且在需要和现有 API 互操作时,允许你取出普通的 Span<T> 片段。
csharp
nint offset = (nint)5_000_000_000L;
Span<byte> window = buffer.AsSpan(offset, length: 4096);
分配 API
最简单的分配方式自然是调用构造函数:
csharp
nint length = (nint)10_000_000_000L;
BigArray<byte> buffer = new(length);
不过 .NET 的数组也有显式的 GC 分配辅助方法,所以我也提供了对应的 API:
csharp
nint length = (nint)10_000_000_000L;
BigArray<byte> zeroed = GC.AllocateBigArray<byte>(length);
BigArray<byte> scratch = GC.AllocateUninitializedBigArray<byte>(length);
BigArray<byte> pinned = GC.AllocateBigArray<byte>(length, pinned: true);
这样你可以控制分配是否清零、是否允许未初始化、以及是否固定。pinned 适合需要把指针传给非托管代码的互操作场景;未初始化分配适合那种马上会覆盖整块内存、不需要清零的性能敏感场景,尤其是在大分配的情况下。
写在最后
有了 BigArray<T>、BigSpan<T> 和 BigMemory<T>,我们就可以用接近普通数组的方式处理超大的连续托管内存。
通常不太建议随意使用巨大的数组。它会让 GC 压力更大,随机访问模式也可能比小数组慢。但有些场景确实需要大块连续数据,性能很重要,但能不能分配到需要的内存更重要。如果连内存都分配不出来,后面的优化也谈不上。