.NET 内存性能实战:Span<T>、ArrayPool、GC 与 LOH 控制

很多服务在压测里吞吐不错,一上生产就抖。根因常常不是业务逻辑,而是内存分配模式不健康。

典型表现:

  • GC 频率偏高
  • P99 周期性尖峰
  • 单机吞吐不稳定

这篇文章重点讲"如何减少无意义分配",并把优化控制在团队可维护范围内。

问题背景

真实现场:一次常规版本上线后,接口平均耗时变化不大,但 P99 从 120ms 拉到 380ms,最后定位到的是字符串拼接和临时数组在高峰时段触发了更频繁的 GC。

一个日志清洗或协议解析接口,经常会写出这样的代码:

csharp 复制代码
var parts = line.Split('|');
var user = parts[0].Trim();
var action = parts[1].Trim();
var ts = DateTime.Parse(parts[2]);

逻辑简单,但每次请求都在制造短命对象。高并发下,这些小分配会形成大压力。

原理解析

代际 GC

短命对象主要在 Gen0/Gen1 回收。分配速率过高时,GC 会频繁触发,吞吐被打断。

LOH(大对象堆)

大于约 85KB 的对象进入 LOH。大对象频繁创建和释放,会导致更重的回收成本和内存碎片问题。

Span<T>ArrayPool<T>

  • Span<T> 在不额外分配的前提下切片处理内存
  • ArrayPool<T> 复用数组,避免重复 new 大缓冲区

示例代码

先定义记录:

csharp 复制代码
public sealed record AuditLog(string User, string Action, DateTime Timestamp);

使用 ReadOnlySpan<char> 解析:

csharp 复制代码
public static bool TryParseAuditLog(string line, out AuditLog? log)
{
    log = null;
    ReadOnlySpan<char> span = line.AsSpan();

    var p1 = span.IndexOf('|');
    if (p1 < 0) return false;

    var p2 = span[(p1 + 1)..].IndexOf('|');
    if (p2 < 0) return false;
    p2 += p1 + 1;

    var user = span[..p1].Trim();
    var action = span[(p1 + 1)..p2].Trim();
    var tsText = span[(p2 + 1)..].Trim();

    if (!DateTime.TryParse(tsText, out var ts)) return false;

    log = new AuditLog(user.ToString(), action.ToString(), ts);
    return true;
}

对于大文本拼接,使用池化数组:

csharp 复制代码
using System.Buffers;
using System.Text;

public static string BuildCsvLine(IReadOnlyList<string> columns)
{
    var pool = ArrayPool<char>.Shared;
    var buffer = pool.Rent(4096);

    try
    {
        var pos = 0;
        for (var i = 0; i < columns.Count; i++)
        {
            var col = columns[i].AsSpan();
            col.CopyTo(buffer.AsSpan(pos));
            pos += col.Length;

            if (i < columns.Count - 1)
            {
                buffer[pos++] = ',';
            }
        }

        return new string(buffer, 0, pos);
    }
    finally
    {
        pool.Return(buffer, clearArray: true);
    }
}

工程实践建议

先定位热点,再做低层优化

优先用以下工具定位:

  • dotnet-counters
  • dotnet-trace
  • PerfViewSpeedScope

不要把 Span<T> 当"默认写法"。

池化有收益,也有代价

ArrayPool 会提升复杂度。只有在高频路径或大数组场景才值得引入。

限制 LOH 对象生成

  • 避免一次性拼接超大字符串
  • 大缓冲优先池化
  • 流式处理替代整块加载

团队约束建议

建立两层规范:

  • 普通代码优先可读性
  • 热路径允许低层优化,但必须配压测数据

评论区讨论

  • 你们线上更常见的是 CPU 打满,还是 GC 抖动导致的延迟尖峰?
  • 在你的团队里,Span<T> 属于默认工具,还是只在热点路径使用?
  • 如果一个优化能省 8% 分配但明显降低可读性,你会怎么取舍?

总结

内存优化不是炫技,目标是把分配曲线压平,让系统在高峰期也能稳定响应。

Span<T>ArrayPool<T> 非常有价值,但前提是你清楚热点在哪、收益有多大、复杂度是否可接受。工程上,量化收益永远比"看起来高级"更重要。

相关推荐
数据的世界014 小时前
C#4.0权威指南第12章:接口
开发语言·c#
c#上位机7 小时前
C#读取保存图像踩坑之FileStream类
开发语言·c#
manyikaimen11 小时前
博派智能-运动控制技术-RTCP-五轴联动
c++·图像处理·qt·算法·计算机视觉·机器人·c#
武藤一雄1 天前
C# 异步回调与等待机制
前端·microsoft·设计模式·微软·c#·.netcore
乱蜂朝王1 天前
使用 C# 和 ONNX Runtime 部署 PaDiM 异常检测模型
开发语言·c#
JosieBook1 天前
【C#】VS中的 跨线程调试异常:CrossThreadMessagingException
开发语言·c#
追雨潮1 天前
BGE-M3 多语言向量模型实战:.NET C# 从原理到落地
开发语言·c#·.net
CheerWWW1 天前
GameFramework——Download篇
笔记·学习·unity·c#
格林威1 天前
ZeroMQ 在视觉系统中的应用
开发语言·人工智能·数码相机·机器学习·计算机视觉·c#·视觉检测
格林威1 天前
工业相机图像采集:如何避免多相机数据混乱
人工智能·数码相机·opencv·机器学习·计算机视觉·c#·视觉检测