新语法分两种:一种是纯糖,编译后和你手写的一模一样;另一种是编译器真的替你生成了新的 IL------后者有性能特征、有边界、有坑。这篇按这个分类,把 C# 12 / .NET 9 主要新特性一次讲透。
一、事故开篇:一次「语法升级」引发的内存暴涨
团队升级 .NET 9 后,有人把热路径上的日志拼接全改成了集合表达式:
csharp
// 重构前
var parts = new List<string> { evt.ShipId, evt.Code, evt.OpUser };
// 重构后(觉得更现代)
string[] parts = [evt.ShipId, evt.Code, evt.OpUser];
看起来只是写法不同。但这段代码在同步消息解码热路径上每秒执行 4 万次。压测对比:
- 重构前:List 初始容量已知,GC Gen0 回收正常;
- 重构后:
string[]每次分配数组没问题------真正的问题在另一处,有人对不定长拼接也用了集合表达式 + 扩展 spread:
csharp
var all = [.. header, .. body, .. trailer]; // 三次 IList 扩散拼接
spread 对 IEnumerable<T> 参数走的是逐元素枚举 + 动态扩容路径,编译器无法预知总长度,每次拼接都产生中间数组。热路径每秒 4 万次 × 3 段拼接,Gen0 从每分钟 200 次涨到 900 次,P99 延迟抖动明显。
教训很简单:语法糖不是性能中性的。你得知道它展开成什么。
💬 互动一下:你们团队升级新语言版本后,有没有做过「语法现代化」重构?重构前后跑过基准测试吗,还是默认「写法等价、行为等价」?
二、集合表达式(Collection Expressions):展开规则与性能边界
csharp
int[] arr = [1, 2, 3];
List<int> list = [1, 2, 3];
Span<int> span = [1, 2, 3]; // ✅ C# 12 支持栈分配 span
int[] merged = [.. arr, 4, .. list]; // spread 展开
编译器展开规则(必须记住的三条):
- 目标类型决定后端 :
int[]→new int[3]{...};List<int>→ 带集合初始化器的 List;Span<int>/ReadOnlySpan<int>→[InlineArray]风格的栈上内联存储,零堆分配; - 字面量定长 → 精确容量分配,无扩容浪费;
- spread(
..x)分两种路径 :- 参数是数组/
List<T>/Span 等已知长度类型(实现ICollection<T>.Count)→ 先查 Count、一次分配、批量拷贝,高效; - 参数是
IEnumerable<T>(LINQ 结果、迭代器方法返回值)→ 无法预知长度,走动态扩容,可能多次分配 + 中间数组。
- 参数是数组/
热路径建议:
csharp
// ✅ 推荐:span 目标类型,栈分配零 GC
ReadOnlySpan<string> keys = ["shipId", "code", "opUser"];
// ⚠️ spread 前先物化:让编译器看得到长度
var materialized = query.ToArray();
var all = [.. existing, .. materialized];
// ❌ 避免:IEnumerable 直接 spread,热路径反复枚举
var all = [.. existing, .. items.Where(x => x.Active)];
一个鲜为人知的好用法------[.. x] 是最高性能的集合拷贝写法之一 ,编译器对数组 spread 生成 Array.Copy/Span.CopyTo,比 .ToList()、.ToArray() 少一层抽象。
三、主构造函数(Primary Constructors):类和 record 不是一回事
C# 12 把主构造函数从 record 扩展到了普通 class/struct:
csharp
public class OutboxRelayService(
PmsShipDbContext db,
IShoreApiClient shore,
ILogger<OutboxRelayService> log) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken ct)
{
// 构造参数在整个类体内可直接捕获使用
var pending = await db.Outbox.Where(m => !m.Sent)
.Take(50).ToListAsync(ct);
log.LogInformation("待发消息 {n} 条", pending.Count);
// ...
}
}
机制要点(和 record 主构造函数的本质区别):
- record 的主构造函数参数会生成公开属性 (
public string Name { get; init; }); - class 的主构造函数参数只是捕获的构造参数,不生成属性 ------编译器为每个被使用的参数生成一个私有的合成字段,等价于你手写
private readonly PmsShipDbContext _db;+ 构造函数赋值; - 参数在类体内任何地方可用,包括字段初始化器。
三个必须知道的坑:
- 参数被闭包/方法多次使用时,捕获字段的时机:主构造参数在构造完成前就被字段初始化器使用是允许的,但注意顺序------字段初始化器在基类构造之后、构造函数体之前执行;
- 可变状态陷阱:class 主构造参数不是 readonly 语义强制,如果你在方法里「重新赋值」同名参数是做不到的(它不是字段名可见),但如果把它存到另一个可变字段,行为同普通类;
- DI 作用域与释放:主构造参数捕获的是注入实例引用,生命周期语义完全由 DI 容器决定------Scoped 服务注入 Singleton 依然是经典陷阱(参见《DI 陷阱》篇),语法变简单了不代表这个坑消失了。
适用判断:服务类、依赖注入类适合用 (少写样板);领域实体、需要多个构造函数重载、需要构造参数校验的类不适合 ------主构造函数里没法塞校验逻辑(record 可以用 with 后校验,class 不行)。
四、field 上下文关键字:半行代码省一个字段
C# 13(.NET 9 配套语言版本)允许在属性访问器中直接用 field 指代编译器合成的后备字段:
csharp
public string? Currency
{
get => field;
set
{
if (value is not null && !CurrencyCodes.IsValid(value))
throw new DomainException($"非法币种代码: {value}");
field = value;
}
}
展开后等价于手写 private string? _currency; + getter/setter。价值在三个场景:
- setter 校验/规范化(上面例子);
- 延迟初始化 :
get => field ??= LoadDefault();; - 变更通知 :
set { if (field != value) { field = value; PropertyChanged?.Invoke(...); } }。
⚠️ 注意:field 是上下文关键字,如果你历史代码里已经有名为 field 的变量/参数,会冲突------编译器按标识符解析优先,旧代码不会破,但新代码里别再起这个名。
五、params 集合:告别 params T[] 的强制数组分配
C# 13 之前,params 只能跟数组------调用方传几个零散参数,编译器每次调用都 new 一个数组:
csharp
// 旧:每次调用分配 string[]
void Log(string message, params object[] args);
Log("出库", shipId, code, qty); // new object[3]
C# 13 允许 params Span<T> / params ReadOnlySpan<T> / params IEnumerable<T>:
csharp
// 新:span 重载,编译器在栈上构造内联 span,零堆分配
void Log(string message, params ReadOnlySpan<object> args);
机制:调用处编译器把零散参数打包成 [InlineArray] 栈结构(见下节),方法内以 span 访问。日志、格式化、参数拼接这类高频调用是最大受益者------这正是 .NET 9 框架库内部大量日志/插值处理器采用的模式。
注意边界:params Span<T> 的参数不能被异步方法在 await 之后使用(栈内存生命周期);需要逃逸到堆上的场景还是得用数组重载。
六、InlineArray:结构体数组的栈上革命
csharp
[InlineArray(4)]
struct FourShipIds
{
private int _first; // 编译器把这个结构展开成 4 个连续字段
}
// 使用
var ids = new FourShipIds();
for (var i = 0; i < 4; i++) ids[i] = i;
ReadOnlySpan<int> span = MemoryMarshal.CreateSpan(ref ids[0], 4);
机制:[InlineArray(n)] 告诉编译器在类型内联布局 n 个同类型槽位,没有数组对象头、没有堆分配、可隐式转 Span 。这是集合表达式能写 Span<int> span = [1,2,3] 和 params ReadOnlySpan<T> 能栈分配的底层基础设施。
实战价值:固定容量的小缓冲区(解析二进制协议帧、日志参数包、临时查找表)从 stackalloc + 手写指针升级为类型安全的结构体。
边界:容量是编译期常量;它是 struct,拷贝是整体拷贝(别当引用传来传去);长度大于 1KB 要小心栈溢出(ASP.NET Core 线程默认栈 1MB,但异步状态机/线程池线程栈更紧张)。
七、ref struct 与 ref field(C# 13)
历史痛点:ref struct(Span 家族)不能作为类字段 ------因为类字段在堆上,而 ref struct 引用的栈内存可能已经失效。C# 13 允许在 ref struct 内部声明 ref field:
csharp
public ref struct FrameBuffer
{
private readonly ref byte _start; // 引用外部缓冲区,不拥有内存
private readonly int _length;
public FrameBuffer(ref byte start, int length)
{
_start = ref start;
_length = length;
}
public Span<byte> Span => MemoryMarshal.CreateSpan(ref _start, _length);
}
意义:高性能解析器可以包装调用方传入的缓冲区而不拷贝 (System.IO.Pipelines、Utf8JsonReader 风格的自定义解析器)。规则没变:ref struct 仍然不能逃逸到堆(不能装箱、不能做类字段、不能进 lambda 闭包、不能在 async 方法中跨 await)------ref field 只是让 ref struct 内部可以持有引用了。
八、System.Threading.Lock:.NET 9 的新一代锁
csharp
private readonly Lock _gate = new();
using (_gate.Enter()) // 临界区,using 自动释放
{
_counter++;
}
// 异步友好场景仍用 SemaphoreSlim;Lock 是纯线程同步原语
if (_gate.TryEnter()) { try { ... } finally { _gate.Exit(); } }
机制与价值:
- 新的
Lock类型是** monitors(lock(obj)背后的 Monitor)的现代替代品**,API 直接返回Scope(实现IDisposable),杜绝「忘了 Exit」; - 线程在进入锁时如果需要等待,进入的是事件等待而非自旋-混合等待的旧路径,公平性和行为在 .NET 9 运行时有针对性优化;
- 最重要的语义优势:
lock(Monitor)用lock(this)、lock(typeof(T))、lock(string)会因外部对象被别人锁住而死锁(公开锁定对象);Lock类型是私有的、专用的,从类型系统上鼓励正确模式。
边界:它仍然是线程锁,不是异步锁 ------async 方法里锁内不能 await,跨异步临界区继续用 SemaphoreSlim.WaitAsync() 或 Channel。别看着 API 新就把 SemaphoreSlim 全换掉。
九、HybridCache:.NET 9 官方「缓存防击穿」方案
承接《缓存实战》篇的手写单飞(single-flight),.NET 9 直接内置:
csharp
builder.Services.AddHybridCache(options =>
{
options.DefaultEntryOptions = new HybridCacheEntryOptions
{
Expiration = TimeSpan.FromMinutes(5),
LocalCacheExpiration = TimeSpan.FromMinutes(2) // 本地副本更短
};
});
// 使用:同 key 并发请求只执行一次工厂,其余等待同一结果
var spare = await cache.GetOrCreateAsync(
$"spare:{code}",
async ct => await db.Spares.FirstAsync(x => x.Code == code, ct),
tags: ["spare"], // 标签失效:备件更新时按 tag 批量清除
cancellationToken: ct);
机制:
- 两级缓存:进程内 L1(内存)+ 分布式 L2(IDistributedCache,可接 Redis);L1 未命中查 L2,再未命中执行工厂;
- Stampede 保护内建 :同 key 并发调用合并为一次工厂执行,其余请求等待同一 Task------这正是缓存篇手写
SemaphoreSlim/LazyCache要解决的问题; - 标签失效 :
RemoveByTagAsync("spare")一键清整类缓存,不用再维护 key 列表; - 与 AOT 兼容、无反射序列化硬依赖(参数走 JSON 源生成可配)。
十、TimeProvider:时间终于可以被注入了
测试定时逻辑的千年难题------DateTime.Now / Task.Delay 无法控制。.NET 8 引入、.NET 9 完善的 TimeProvider:
csharp
public class OutboxRelayService(TimeProvider time, /* ... */) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
// ... 补发逻辑
await Task.Delay(TimeSpan.FromSeconds(5), time, ct); // 可被测试控制
}
}
}
// 测试:假时间快进
var fake = new FakeTimeProvider();
var svc = new OutboxRelayService(fake, /* ... */);
await svc.StartAsync(CancellationToken.None);
fake.Advance(TimeSpan.FromMinutes(30)); // 瞬间「过」30 分钟
// 断言重试退避、定时窗口行为,测试几秒跑完真实几十分钟的逻辑
生产注入 TimeProvider.System(内置单例)。这是 .NET 在可测试性上补齐的重要一块------凡是写 DateTime.UtcNow、Task.Delay、PeriodicTimer 的新代码,默认注入 TimeProvider。
十一、其他高价值特性速览
| 特性 | 版本 | 一句话机制/用法 |
|---|---|---|
Task.WhenEach |
.NET 9 | await Task.WhenEach(tasks) 按完成顺序 逐个处理,不用 WhenAny + 移除已完成 手写循环 |
Task 等待后 ConfigureAwait(ConfigureAwaitOptions) |
.NET 8 | ConfigureAwaitOptions.SuppressThrowing 可 await 已取消任务不抛异常,批量等待场景简化 |
Enumerable.CountBy / AggregateBy |
.NET 9 | 原生分组计数/聚合,返回键值对枚举,比 GroupBy().Select(g => new{g.Key, g.Count()}) 少一层分配 |
ReadOnlySet<T> |
.NET 8 | 终于有了只读集合接口,暴露集合依赖不再被迫给 ISet<T> |
searchValues |
.NET 8/9 | SearchValues.Create("ABCDEF") 向量化字符/字节查找,解析器热路径神器(JSON 库内部在用) |
JSON 多态 JsonUnmappedMemberHandling |
.NET 8 | 反序列化遇到未知成员可配置抛异常------接口契约严格校验 |
TensorPrimitives |
.NET 8/9 | 内置 SIMD 向量化数值运算,数值计算场景免引第三方库 |
System.Net.Http 状态码与 HTTP/3 完善 |
.NET 9 | HttpVersionPolicy、连接池指标更完善;QUIC 开箱稳定性提升 |
WhenEach 对比手写(并发处理船端批量上传时非常顺手):
csharp
// .NET 9:谁先传完处理谁,失败不影响其他
await foreach (var task in Task.WhenEach(uploadTasks))
{
var result = await task; // 已完成,立即返回或抛异常
MarkShipResult(result.ShipId, result.Success);
}
十二、AOT / 性能视角下的新特性收益
和《Native AOT》《源生成器》篇串起来看:
- 集合表达式 → Span 目标类型 = 栈分配零 GC,AOT 友好(无反射);
params ReadOnlySpan<T>+ InlineArray = 高频调用零分配,日志库已在用;HybridCache无反射硬依赖,AOT 可直接用;Lock、TimeProvider、Task.WhenEach都是纯运行时/编译期特性,不引入任何反射,AOT 零警告;- 反向注意:主构造函数 + DI 本身没问题,但如果配合「程序集扫描自动注册」(Scrutor 模式)依然触发反射------AOT 篇讲过,那类注册要换源生成。
十三、十个踩坑
- IEnumerable 直接 spread 进集合表达式------动态扩容 + 多次枚举,热路径性能倒退。spread 前先 ToArray/ToList 物化。
- 把 class 主构造函数当 record 用------以为参数成了公开属性,序列化、绑定、映射全找不到属性。class 主构造参数只生成私有捕获字段。
- 主构造函数里塞校验逻辑------class 主构造没有函数体可写,需要校验的要么用普通构造函数,要么用工厂方法 + FluentValidation(参见验证体系篇)。
field关键字与历史变量名冲突 ------旧代码里名叫 field 的参数/局部变量语义微妙变化,升级后全文搜field标识符确认。params Span<T>参数在 await 之后使用------栈内存随方法帧失效,编译报错或行为异常;需要跨 await 用数组重载。- InlineArray 容量拍脑袋给大------每实例都是栈上 n 槽,1024 个 long 就是 8KB,嵌套调用几层层就栈溢出。小缓冲专用,一般 <256 字节。
- ref struct 装箱/闭包------仍会编译错误,但错误信息晦涩;记住铁律:ref struct 不离开栈帧。
Lock当异步锁用 ------using (_gate.Enter())里面 await 编译不过/语义错误;异步临界区继续 SemaphoreSlim。- HybridCache 工厂里写副作用 ------Stampede 合并意味着工厂可能不执行(请求复用别人的结果),工厂必须是纯读取;写操作、计数、日志放工厂外面。
- 升级后不跑基准就「现代化重写」------语法等价不等于性能等价(开篇事故)。热路径改动必须 BenchmarkDotNet 前后对比。
十四、落地路线与 Checklist
- 目标框架升级到 .NET 8/9 后,先开
<LangVersion>latest</LangVersion>并全量回归测试 - 热路径日志/格式化改
params ReadOnlySpan<T>重载,Benchmark 验证分配下降 - 固定小缓冲区评估
stackalloc→[InlineArray]类型化改造 - 服务类依赖注入样板评估主构造函数;领域实体保持传统写法
- 手写缓存单飞逻辑评估替换为
HybridCache(两级缓存 + 标签失效 + 防击穿三合一) - 所有新建定时/延迟逻辑注入
TimeProvider,测试用FakeTimeProvider -
lock(new object())私有锁场景逐步换System.Threading.Lock;异步锁不动 - 批量并发等待的
WhenAny循环换Task.WhenEach - AOT 项目:新特性全部反射-free,放心用;但别借此放松 2147 警告治理
- 「语法现代化」PR 必须附带热路径基准对比,不接受无测量的重构
十五、小结
判断新语法该不该用,回到那个分类:
- 纯糖 (主构造函数样板简化、
field后备字段):放心用,收益是可读性; - 改了 IL 分配特征的 (集合表达式 spread 路径、
params Span、InlineArray、HybridCache):先知道它展开成什么,再决定在什么路径用------热路径是收益最大的地方,也是踩坑最响的地方; - 改了语义模型的 (
Lock、TimeProvider、ref field):理解边界再迁移,别全局替换。
语言进化的方向很清晰:让编译器多干活(源生成、内联展开)、让分配尽量留在栈上、让时间和并发可测试、让框架内置最佳实践。 跟上的方式不是背语法表,而是每用一个新特性,都能回答那句老问题:它编译之后到底是什么?
💬 互动一下:这篇里的特性,你们生产代码已经用上了哪些?哪个是你看了之后打算下周就引进项目的?