在 .NET 上构建超大托管数组

.NET 数组的上限

这些年经常看到有人抱怨 .NET 数组的最大长度。

在 .NET 里,数组、集合、Span 以及很多相关 API 都是围绕 32 位长度和索引设计的。GitHub 上曾经有一个很长的 issue 讨论 64 位数组支持,但最后以 "won't fix" 关闭,因为这件事会牵涉到运行时、GC、JIT、类型系统、反射以及大量现有代码。

常见的解决办法大概有两类:一类是分配非托管内存,再用一个类包起来;另一类是用交错数组模拟一个更大的数组。这两种方案在某些场景下都能用,但代价也很明显。

手动管理内存很容易出错,而且它更适合非托管数据。像 stringobject 这样的引用类型就不适合这个方向。交错数组避开了非托管内存,但本质上仍然是一组数组。你需要管理每个内部数组的大小,访问时要处理跨段边界,和那些期待连续内存区域的 API 配合起来也很别扭。

于是我决定自己做一个方案:

  • 能容纳超过 20 亿个元素,并且仍然用一个索引访问。
  • 索引应该跟架构相关:32 位系统上保持普通数组的限制,64 位系统上可以支持更大的范围。
  • 支持 stringobject 之类的引用类型。
  • 连续托管内存分配。
  • 由 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 个逻辑 TElementChunk23<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、ToArrayToBigArray 以及只读转换。实现内部如果需要调用只接受 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 压力更大,随机访问模式也可能比小数组慢。但有些场景确实需要大块连续数据,性能很重要,但能不能分配到需要的内存更重要。如果连内存都分配不出来,后面的优化也谈不上。

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

相关推荐
雨落倾城夏未凉5 天前
第四章c#方法-参数数组和可选参数(16)
后端·c#
唐青枫6 天前
线程不是越多越快:C#.NET Thread 生命周期、同步与后台工作线程实战
c#·.net
唐青枫7 天前
别只会反射:C#.NET Emit 动态生成代码实战详解
c#·.net
Caco_D7 天前
一行代码抓遍全网 20 个热榜!Aneiang.Pa 4.0 发布 — 极简 .NET 爬虫库
爬虫·.net
咕白m6258 天前
.NET 环境下 Word 超链接批量提取方案
c#·.net
用户91721561902118 天前
C# 通信协议增量解析:用状态机处理半包和粘包
c#
小码编匠8 天前
C# 工控上位机必备:数据转换工具类与十个核心模块
后端·c#·.net