You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

深度调用栈中传递class/record struct数据结构的性能对比探究

原始问题与代码(2024.06.12)

声明: 这是一个偏学术性的问题,代码经过人为构造,但源自真实业务场景。

在我们的一款应用中,需要批量处理数据,并将大量对象依次传递到深度调用栈中。当前使用的数据结构较为基础,定义如下:

public record DataStructure(
    string Name,
    int Age,
    string Address = "123 Main St",
    string City = "Anytown",
    string State = "WA",
    string Zip = "98052",
    string Country = "USA");

我现在疑惑的是,使用record struct避免堆分配的优势,是否能抵消方法进出时拷贝对象的成本?

为此我编写了如下微基准测试:

using BenchmarkDotNet.Attributes;
namespace BenchmarkClassStruct;

[MemoryDiagnoser]
public class Benchmarks
{
    private const int MaxCount = 1_000_000;

    [Benchmark]
    public void RunWithStructs()
    {
        for (var i = 0; i < MaxCount; i++)
        {
            var data = new DataAsStruct("John Doe", i);
            DataAsStruct result = Step1WithStructs(data);
        }
    }

    [Benchmark]
    public void RunWithClasses()
    {
        for (var i = 0; i < MaxCount; i++)
        {
            var data = new DataAsClass("John Doe", i);
            DataAsClass result = Step1WithClasses(data);
        }
    }

    private DataAsClass Step1WithClasses(DataAsClass data) => Step2WithClasses(data);
    private DataAsClass Step2WithClasses(DataAsClass data) => Step3WithClasses(data);
    private DataAsClass Step3WithClasses(DataAsClass data) => Step4WithClasses(data);
    private DataAsClass Step4WithClasses(DataAsClass data) => Step5WithClasses(data);
    private DataAsClass Step5WithClasses(DataAsClass data) => data with { Name = "Jane Doe" };

    private DataAsStruct Step1WithStructs(DataAsStruct data) => Step2WithStructs(data);
    private DataAsStruct Step2WithStructs(DataAsStruct data) => Step3WithStructs(data);
    private DataAsStruct Step3WithStructs(DataAsStruct data) => Step4WithStructs(data);
    private DataAsStruct Step4WithStructs(DataAsStruct data) => Step5WithStructs(data);
    private DataAsStruct Step5WithStructs(DataAsStruct data) => data with { Name = "Jane Doe" };
}

public record struct DataAsStruct(
    string Name,
    int Age,
    string Address = "123 Main St",
    string City = "Anytown",
    string State = "WA",
    string Zip = "98052",
    string Country = "USA");

public record class DataAsClass(
    string Name,
    int Age,
    string Address = "123 Main St",
    string City = "Anytown",
    string State = "WA",
    string Zip = "98052",
    string Country = "USA");

通过BenchmarkRunner.Run<Benchmarks>();运行测试后,得到如下结果:

MethodMeanErrorStdDevGen0Allocated
RunWithStructs218.9 us3.57 us3.34 us--
RunWithClasses23,081.9 us423.57 us633.97 us11000.0000144000012 B

这个结果让我意外,record struct不仅耗时更少,内存占用也更低——我原本以为record struct的按值拷贝语义会导致处理速度变慢,正如微软文档所述:

避免数据拷贝:拷贝较大的struct类型可能会影响某些代码路径的性能。你可以添加ref修饰符,通过引用而非值传递大型数据结构。

我现在有两个疑问:

  1. 根据微软文档,什么时候struct会被视为“较大”,导致拷贝成本不再可忽略?
  2. 是否存在基准测试未体现的“隐形”开销(内存或时间方面)?

更新后的代码(2024.06.13)

以下是为避免方法内联而更新的版本:

using System.Runtime.CompilerServices;
using BenchmarkDotNet.Attributes;
namespace BenchmarkClassStruct;

[MemoryDiagnoser]
public class Benchmarks
{
    private const int MaxCount = 1_000_000;

    [Benchmark]
    public int RunWithStructs()
    {
        var sum = 0;
        for (var i = 0; i < MaxCount; i++)
        {
            var data = new DataAsStruct("John Doe", i);
            DataAsStruct result = Step1WithStructs(data);
            sum += result.Age;
        }
        return sum;
    }

    [Benchmark]
    public int RunWithClasses()
    {
        var sum = 0;
        for (var i = 0; i < MaxCount; i++)
        {
            var data = new DataAsClass("John Doe", i);
            DataAsClass result = Step1WithClasses(data);
            sum += result.Age;
        }
        return sum;
    }

    [MethodImpl(MethodImplOptions.NoInlining)]
    private DataAsClass Step1WithClasses(DataAsClass data) => Step2WithClasses(data);
    [MethodImpl(MethodImplOptions.NoInlining)]
    private DataAsClass Step2WithClasses(DataAsClass data) => Step3WithClasses(data);
    [MethodImpl(MethodImplOptions.NoInlining)]
    private DataAsClass Step3WithClasses(DataAsClass data) => Step4WithClasses(data);
    [MethodImpl(MethodImplOptions.NoInlining)]
    private DataAsClass Step4WithClasses(DataAsClass data) => Step5WithClasses(data);
    [MethodImpl(MethodImplOptions.NoInlining)]
    private DataAsClass Step5WithClasses(DataAsClass data) => data with { Name = "Jane Doe", Age = data.Age + 1 };

    [MethodImpl(MethodImplOptions.NoInlining)]
    private DataAsStruct Step1WithStructs(DataAsStruct data) => Step2WithStructs(data);
    [MethodImpl(MethodImplOptions.NoInlining)]
    private DataAsStruct Step2WithStructs(DataAsStruct data) => Step3WithStructs(data);
    [MethodImpl(MethodImplOptions.NoInlining)]
    private DataAsStruct Step3WithStructs(DataAsStruct data) => Step4WithStructs(data);
    [MethodImpl(MethodImplOptions.NoInlining)]
    private DataAsStruct Step4WithStructs(DataAsStruct data) => Step5WithStructs(data);
    [MethodImpl(MethodImplOptions.NoInlining)]
    private DataAsStruct Step5WithStructs(DataAsStruct data) => data with { Name = "Jane Doe", Age = data.Age + 1 };
}

public record struct DataAsStruct(
    string Name,
    int Age,
    string Address = "123 Main St",
    string City = "Anytown",
    string State = "WA",
    string Zip = "98052",
    string Country = "USA");

public record class DataAsClass(
    string Name,
    int Age,
    string Address = "123 Main St",
    string City = "Anytown",
    string State = "WA",
    string Zip = "98052",
    string Country = "USA");

现在的测试结果如下:

MethodMeanErrorStdDevGen0Allocated
RunWithStructs14.82 ms0.259 ms0.454 ms-6 B
RunWithClasses22.57 ms0.442 ms0.491 ms11000.0000144000012 B

问题解答

1. 什么时候struct会被视为“较大”?

没有绝对的字节数阈值,但行业共识和微软实践建议是:当struct大小超过16字节时,拷贝成本开始逐渐显现;超过64字节时,拷贝开销通常会超过堆分配+GC的开销(尤其在高频调用场景下)。

这个数值不是固定的,还要结合以下因素判断:

  • 调用频率:如果struct只是偶尔传递,哪怕几百字节的拷贝也不会有明显影响;但百万级高频调用下,即使32字节的struct拷贝累积起来也会成为性能瓶颈。
  • CPU缓存效率:struct大小如果刚好能放进CPU的L1/L2缓存(通常L1为32-64KB,每个缓存行64字节),拷贝速度会快很多;如果struct跨缓存行,拷贝效率会下降。
  • 内容构成:如果struct包含引用类型(比如示例中的string),拷贝时仅复制引用(64位系统为8字节),实际数据在堆上,所以实际拷贝开销比看起来的小。你的DataAsStruct总大小为7个string引用(7*8=56字节)+1个int(4字节)=60字节,接近64字节但未跨缓存行,拷贝开销仍可控。

2. 基准测试是否存在未体现的“隐形”开销?

你的基准测试覆盖了核心场景,但有几个潜在隐形开销需要注意:

内存方面

  • 栈空间压力:在深度递归调用中传递大型struct,栈空间可能快速耗尽(栈大小通常仅1MB左右),导致栈溢出异常。而class是堆分配,不占用栈空间,递归深度可以更大。
  • GC隐性开销:测试中class的GC开销已被BenchmarkDotNet统计,但生产环境中GC停顿可能更严重——比如高并发场景下,Gen0 GC频繁触发会导致短暂线程暂停,而struct完全没有这方面问题。

时间方面

  • 拷贝累积效应:如果调用栈更深(比如10层以上),或struct大小超过64字节,拷贝累积时间会显著增加。比如128字节的struct,每层拷贝需要2个缓存行,10层就是20次缓存行拷贝,开销会超过class的引用传递。
  • ref传递额外成本:如果为避免struct拷贝使用ref/in/out修饰符,需注意引用传递带来的安全检查开销(比如readonly检查),以及可能的别名问题(多个引用指向同一struct实例,修改会影响其他地方)。

另外,更新版本中添加了NoInlining更贴近真实调用栈场景,但要注意:JIT编译器在实际生产环境中可能对某些方法进行内联优化,从而消除struct拷贝开销——比如方法内联后,struct可直接在寄存器中传递,无需拷贝到栈上。


内容的提问来源于stack exchange,提问作者mu88

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.22 10:57:02