首页>后端>正文

突破 .NET 数组 20 亿上限的托管大数组实现方案

Feng 91 阅读 后端

问题的根源不在元素个数而在字节数

.NET 里数组、集合、Span 以及大量 BCL API 都建立在 32 位长度和索引之上。GitHub 上关于 64 位数组支持的讨论最终以 won’t fix 收场,原因很直接:改动会波及运行时、GC、JIT、类型系统、反射和现有代码库。

绕开这个限制的常见做法有两种。一是分配非托管内存再用类封装,二是用交错数组模拟。前者手动管理内存容易出错,且只适合非托管数据,string、object 这类引用类型根本走不通。后者虽然避开了非托管内存,但本质是一组数组的集合,需要管理每个内部数组的大小,访问时处理跨段边界,和期待连续内存的 API 配合起来非常别扭。

换个角度想:限制针对的是数组的元素个数,不是元素背后的字节数。byte[1024] 存 1024 字节,int[1024] 存 4096 字节,数组长度相同,只是每个元素更大。那么让一个数组元素代表多个逻辑元素,问题就转化了。

struct TwoBytes
{
 public byte A;
 public byte B;
}

一个包含 20 亿个 TwoBytes 的数组能存下 40 亿个字节,它仍然是一个托管数组对象。

用 InlineArray 构造块类型

.NET 8 开始提供的 InlineArrayAttribute 让 struct 可以表示固定数量的重复字段,不必手写每个字段。

using System.Runtime.CompilerServices;

[InlineArray(4)]
struct FourBytes
{
 private byte _first;
}

这个特性同样适用于引用类型和泛型:

[InlineArray(4)]
struct FourStrings
{
 private string _first;
}

[InlineArray(4)]
struct FourElements<T>
{
 private T _first;
}

一个 FourElements<T> 数组的每个物理元素容纳四个逻辑 T。物理数组接近 20 亿个块,四倍宽度的块就能表示接近 80 亿个逻辑元素。BigArray<T> 的核心思路就在这里:底层是托管数组,但数组元素类型不一定是 T 本身,也可能是块类型,真实逻辑长度单独记录。

块长度的组合策略

最直观的做法是为每种块长度定义一个类型,从 ElementChunk1<T> 到 ElementChunk65535<T>,显然不现实。而且运行时有一个硬性限制:作为数组元素的值类型不能超过 65,535 字节。对某个 T,块长度上限是 65535 / Unsafe.SizeOf<T>()。byte 可以用 65,535,8 字节类型可以用 8,191,32 字节类型可以用 2,047。

块结构体本身可以嵌套组合:

[InlineArray(2)]
struct ElementChunk2<T>
{
 private T _first;
}

[InlineArray(3)]
struct ElementChunk3<T>
{
 private T _first;
}

ElementChunk2<ElementChunk3<T>> 表示 2 个包含 3 个值的块,即 6 个逻辑 T。byte 能用的最大块长度 65,535 可以写成:

ElementChunk3<ElementChunk5<ElementChunk17<ElementChunk257<byte>>>>

因为 3 × 5 × 17 × 257 = 65535。只保留一组质数长度的基础块类型,通过嵌套组合出其他长度,覆盖 1 到 65,535 之间需要的块长度只需要 85 个基础块类型,从 ElementChunk2<T> 到 ElementChunk8191<T>。

分配时计算请求的逻辑长度需要多少个物理块:

var chunkSize = 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],
 // ...
 65535 => new ElementChunk3<ElementChunk5<ElementChunk17<ElementChunk257<T>>>>[chunks],
};

chunks 是真实托管数组的长度,和 BigArray<T> 暴露的逻辑长度不同。逻辑长度 10,000、块大小 4,095 时,分配 3 个物理块,最后一个块只用到一部分,真正的逻辑终点由 _length 记录。多分配的空间很小,最坏情况是逻辑长度刚好比块大小的整数倍多 1 且块大小为 65,535,最后一个块只使用 1 个字节。

类型加载的陷阱与延迟分配

假设 T 是 64 位运行时上的 object,引用是 8 字节,合法块长度是 8,191。ElementChunk8191 合法,但 ElementChunk3<ElementChunk5<ElementChunk17<ElementChunk257<object>>>> 包含 65,535 个 object 引用,作为数组元素的值类型占用 8 × 65535 = 524,280 字节,运行时创建数组时会抛 TypeLoadException。关键问题是:代码不会执行和类型不会被加载不能画等号。一个方法里引用了多个已构造的泛型数组类型,JIT 和类型加载器在导入或编译方法时仍可能碰到非法组合:

AllocateArray<object>();// 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 里拿到对应的分配器,只调用这个分配器。每个分支返回一个静态 lambda,lambda 里只分配一种块类型:

internal static Func<int, bool, bool, Array> CreateBigArrayAllocator<T>(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:

[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,代码选择分支并创建 ElementChunk8191<object>[],给 byte 用的分支仍然存在但不会在 object 路径上被加载。

这里有一个 NativeAOT 下的实际约束值得注意:InlineArray 泛型实例在 AOT 编译期会被静态展开,如果块类型嵌套层次过深,AOT 编译器需要为每个可能的泛型实例生成代码,编译时间和产物体积都会显著增长。实践中建议在 NativeAOT 场景下限制块类型的嵌套深度,或者对 T 的类型做更细粒度的分组,避免一次性实例化全部 510 个分配器分支。

BigArray 的存储与索引

有了块机制,BigArray<T> 本身保持得很小,只保存两个字段:

internal readonly Array _storage;
internal readonly nint _length;

普通长度下分配 ElementChunk1<T>[],布局接近带一层包装的普通 T[]。更大长度下计算块长度,分配选中的块数组,逻辑长度记录为 nint。_storage 声明为 Array 类型,实际运行时类型取决于 T。

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 的引用,然后用引用偏移往后移动:

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 的输入先被验证,实现使用引用偏移避免每次逻辑访问再走普通数组边界检查。数据引用通过把数组数据开头重新解释为 T 得到:

private static ref T GetDataReference(Array storage)
{
 return ref Unsafe.As<byte, T>(ref MemoryMarshal.GetArrayDataReference(storage));
}

连续存储这个特性在这里至关重要。拿到第一个数据引用后,Unsafe.Add(ref first, index) 移动 index 个逻辑 T 元素,跨块只是在同一段数组数据区里继续往前走。不需要像交错数组包装器那样每次访问做除法和取余。

最大长度跟架构相关:

public static nint MaxLength =>
 nint.Size == 4
 ? Array.MaxLength
 : GetChunkLength() * (nint)Array.MaxLength;

32 位运行时上 nint 无法表示更大的索引空间,保持普通数组限制。64 位运行时上最大长度随块大小增长。对 byte 大约是 Array.MaxLength × 65535,对 long 或对象引用大约是 Array.MaxLength × 8191。byte 理论上可以表示接近 128 TiB,准确说是 127.998 TiB。这是理论上限,机器仍然需要真的有足够内存。

BigSpan 与 BigMemory

只有持有存储的类型还不够,普通 .NET 代码还会用 Span<T>、Memory<T> 传递视图。

BigSpan<T> 是面向超大连续区域的栈上视图:

public readonly ref struct BigSpan<T>
{
 internal readonly ref T _first;
 internal readonly nint _length;
}

基本形状和 Span<T> 一样,起始引用加长度,区别是长度和索引用 nint。它不拥有内存,只查看由别的对象保持存活的内存。

BigArray<byte> buffer = new((nint)Array.MaxLength + 1);
BigSpan<byte> span = buffer.AsBigSpan();
span[Array.MaxLength] = 1;

BigMemory<T> 和 BigReadOnlyMemory<T> 是可以保存的视图,记录底层托管数组、起始偏移和长度:

internal readonly Array? _storage;
internal readonly nint _start;
internal readonly nint _length;

Span 属性生成 BigSpan<T> 或 BigReadOnlySpan<T>。_storage 保存底层托管数组,所以 BigMemory<T> 可以放进字段或从方法返回,同时让存储对 GC 可见。

BigMemory<byte> page = buffer.AsBigMemory(0, 4096);
page.Span.Fill(0);

API 设计沿用普通 Span/Memory 的习惯:切片、复制、搜索、排序、trim、split、ToArray、ToBigArray 以及只读转换。内部调用只接受 Span<T> 的 BCL API 时,把数据拆成能放进 int 的片段处理。

nint offset = (nint)3_000_000_000L;
Span<byte> window = buffer.AsSpan(offset, length: 1024);

分配 API 与固定内存的代价

最简单的分配方式是构造函数:

nint length = (nint)3_000_000_000L;
BigArray<byte> buffer = new(length);

对应的 GC 辅助方法:

nint length = (nint)3_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 适合把指针传给非托管代码的互操作场景,未初始化分配适合马上覆盖整块内存的性能敏感场景。

但 pinned 参数为 true 时有实际风险需要评估。固定一个超过 85,000 字节的大对象会进入大对象堆(LOH),而 LOH 默认不压缩。固定后 GC 无法移动这个对象,如果频繁分配和释放不同大小的固定大数组,堆上会出现无法回收的碎片空洞。在长时间运行的服务里,这可能导致后续分配失败,即使总可用内存充足。实践中如果确实需要固定,建议在进程启动阶段一次性分配并长期持有,而不是反复创建销毁。未初始化分配同样要谨慎:GC.AllocateUninitializedArray 返回的内存可能包含敏感数据的残留,如果这块内存会被序列化或跨进程传递,必须先清零。

适用边界

有了 BigArray<T>、BigSpan<T> 和 BigMemory<T>,可以用接近普通数组的方式处理超大的连续托管内存。

但巨大的数组不是随便该用的。它会让 GC 压力更大,随机访问模式也可能比小数组慢。有些场景确实需要大块连续数据,性能很重要,但能不能分配到需要的内存更重要。如果连内存都分配不出来,后面的优化也谈不上。

源代码已开源在 GitHub,只想使用的话可以从 NuGet 引用包。

ID:6495

 

Feng
这位作者很神秘,还没有填写简介。
▲