.NET 9(2024.11)· 两个每天都会用到的改进
C# 1.0 起,params 只能用于数组。每次调用——编译器在后台分配一个堆数组:
// 看起来人畜无害:
void Log(string format, params string[] args)
=> Console.WriteLine(format, args);
Log("用户 {0} 登录", userId, ip); // 编译器生成: new string[] { userId, ip }
Log("订单 {0} 金额 {1}", orderId, amount);
Log("心跳"); // 即使只传 0 个参数也分配: Array.Empty<string>()
如果 Log 在热路径上被调用每秒数千次——这些数组分配会触发频繁 GC。.
C# 13 的核心创新:params 参数可以是 ReadOnlySpan<T>——编译器在栈上构造 Span,零堆分配。
// 只能:params T[]
void Print(params int[] numbers) {
foreach (var n in numbers)
Console.Write(n + " ");
}
Print(1, 2, 3);
// IL: newarr 0x3
// stloc.0 (T[])
// → 堆分配 int[3]
// 现在可以:params ReadOnlySpan<T>
void Print(params ReadOnlySpan<int> numbers) {
foreach (var n in numbers)
Console.Write(n + " ");
}
Print(1, 2, 3);
// IL: ldloca _tmp (栈上 struct)
// call MemoryMarshal.CreateReadOnlySpan
// → 零堆分配
这是用户最关心的部分——编译器做了三件事:
// 你写的代码:
Print(1, 2, 3);
// 编译器生成(概念等效):
// ① 生成一个内联固定大小缓冲区 struct
ref struct __ValueArray3<int> {
public int Item0, Item1, Item2;
}
// ② 在栈上分配该 struct,逐项赋值
var _tmp = new __ValueArray3<int>();
_tmp.Item0 = 1; _tmp.Item1 = 2; _tmp.Item2 = 3;
// ③ 用 MemoryMarshal 创建指向该缓冲区的 Span
var span = MemoryMarshal.CreateReadOnlySpan(
ref _tmp.Item0, 3);
Print(span);
// 优化:同一方法内多次调用复用同一个缓冲区
// (取所有调用中参数数量最大的那一次作为缓冲区大小)
答案是编译时静态分析——编译器扫描调用方方法内所有的 Print(...) 调用点,逐个数字面量参数个数,取最大值。纯编译时行为,零运行时开销。
// 编译器看到 MyMethod 里有 3 个 Print 调用点:
void MyMethod()
{
Print(1, 2, 3); // ← 3 个参数
Print(4, 5, 6, 7, 8); // ← 5 个参数 → max = 5
Print(9); // ← 1 个参数
}
// 编译器:max = 5 → 在 MyMethod 栈帧上分配大小为 5 的缓冲区
// 三次调用复用同一块栈空间,不是每次调用都分配新的
| params 类型 | 分配位置 | 典型场景 |
|---|---|---|
ReadOnlySpan<T> | 栈(零分配)⭐ | 只读遍历——最常见的 params 场景 |
Span<T> | 栈(零分配) | 需要对元素做 in-place 修改 |
T[] | 堆(传统行为) | 兼容旧代码,仍然是合法选择 |
IEnumerable<T> | 堆(编译器合成数组) | 接受 LINQ 查询结果直接传入 |
IReadOnlyList<T> | 堆(编译器合成数组) | 随机访问 + 只读语义 |
IReadOnlyCollection<T> | 堆 | 只要 Count,不关心索引 |
ICollection<T> | 堆 | 需要 Add 方法的集合 |
IList<T> | 堆 | 随机访问 + 可变语义 |
任何有 Add 的集合类型 | 堆 | 自定义集合(如 ConcurrentBag<T>) |
params ReadOnlySpan<T>;接受 LINQ 结果 → params IEnumerable<T>。其余情况几乎不用。
// 你的日志包装器——热路径上每秒数千次调用
public static void Log(
LogLevel level,
[InterpolatedStringHandlerArgument("")] ref MyInterpolatedStringHandler msg)
{ ... }
public static void Log(
LogLevel level,
params ReadOnlySpan<object> args) // ← 零分配!
{
// 遍历 args 构建日志消息——不分配堆数组
foreach (var arg in args)
WriteArg(arg);
}
// 调用——和以前完全一样简洁
Log(LogLevel.Info, userId, operation, elapsed);
Log(LogLevel.Error, requestId, ex.Message);
Log(LogLevel.Debug); // 空参也不分配
// 场景:要把 LINQ 筛选的结果传给 params 方法
void Display(params IEnumerable<string> items)
=> Console.WriteLine(string.Join(", ", items));
var users = GetUsers();
// C# 12:必须 .ToArray() — 多一次拷贝
// Display(users.Where(u => u.Active).Select(u => u.Name).ToArray());
// C# 13:直接传 LINQ 结果
Display(users.Where(u => u.Active).Select(u => u.Name));
// 省掉 .ToArray()——编译器帮你合成存储
C# 13 的编译器优先绑定到 params ReadOnlySpan<T> 版本(而非旧的 params T[])。这导致一个具体问题:
// .NET 8:绑定到 string.Join(string, params string[])
// .NET 9:绑定到 string.Join(string, params ReadOnlySpan<string>)
// 表达式树中 ref struct 非法 → 编译错误!
Expression<Func<string, string, string>> join =
(x, y) => string.Join("", x, y);
// CS8640: 表达式树不能包含 ref struct
// 修复:显式传数组,强制绑定到旧版本
Expression<Func<string, string, string>> join =
(x, y) => string.Join("", new[] { x, y });
| 场景 | params ReadOnlySpan<T> | 原因 |
|---|---|---|
| 普通同步方法 | ✅ 零分配 | Span 在栈上,不逃逸 |
async 方法 | ❌ CS4007 | Span 不能跨越 await 边界 |
迭代器(yield return) | ❌ CS4007 | Span 不能跨越 yield 边界 |
| 表达式树 | ❌ CS8640 | 表达式树不能包含 ref struct |
传递给 IEnumerable<T> 参数 | ✅(需显式转换) | Span 实现了 IEnumerable,但需 using |
对于 async/迭代器场景,退回到 params T[] 或 params IEnumerable<T>——性能差异在 I/O 场景下可忽略。
从 C# 1.0 起,lock 语句编译为 Monitor.Enter / Monitor.Exit。这个 20 年前的 API 有固有的设计缺陷:
// .NET 8 及以前——看起来正常:
private readonly object _sync = new();
lock (_sync) { /* 关键区 */ }
// 编译器生成(简化):
bool lockTaken = false;
try {
Monitor.Enter(_sync, ref lockTaken);
/* 关键区 */
} finally {
if (lockTaken) Monitor.Exit(_sync);
}
| 问题 | 说明 |
|---|---|
| ① 类型不安全 | lock(this) / lock(typeof(Foo)) 仍然合法,但公认是坏实践 |
| ② Monitor 是遗留 API | Monitor.Enter 用 ref bool 模式——不是现代 .NET 风格 |
| ③ 装箱 | lock 关键字要求引用类型,锁值类型时会装箱 |
| ④ 退化可能 | 把 Lock 隐式转换为 object 后 lock——失去优化,回退到 Monitor |
| ⑤ 无法区分用途 | 一个 object 到底是锁、缓存键、还是普通状态?阅读代码时无法判断 |
| ⑥ 平台未特化 | Monitor 是托管实现——无法利用 OS 原生锁特性 |
private readonly object _sync = new();
lock (_sync)
{
// 关键区
_counter++;
}
// IL 生成 Monitor.Enter/Exit
// 每次 Enter ~20ns
private readonly Lock _sync = new();
lock (_sync)
{
// 关键区——语法完全相同
_counter++;
}
// IL 生成 using (EnterScope())
// 每次 Enter ~15ns(≈25% 更快)
// ==================== lock (object) ====================
// 编译器生成:
object _sync = ...;
bool lockTaken = false;
try
{
Monitor.Enter(_sync, ref lockTaken);
/* 你的代码 */
}
finally
{
if (lockTaken) Monitor.Exit(_sync);
}
// ==================== lock (Lock) ====================
// 编译器生成:
Lock _sync = ...;
using (Lock.Scope __scope = _sync.EnterScope())
{
/* 你的代码 */
}
// Dispose() 自动释放锁——没有 try/finally 样板
// Lock.Scope 是 ref struct——零堆分配
using 模式替代了 Monitor.Enter/Exit。编译器识别 Lock 类型并特殊处理——不只是换了 API,而是换了编译策略。Lock.Scope 是 ref struct,不会装箱,不会逃逸到堆。
// Step 1:改字段类型
// Before: private readonly object _sync = new();
private readonly Lock _sync = new();
// Step 2:lock 语句完全不变
lock (_sync) { /* 不变 */ }
// 就这么简单。IDE0330 代码风格规则会自动提示你改。
| 陷阱 | 错误示例 | 正确做法 |
|---|---|---|
| ① 不要转为 object | lock ((object)_sync) |
lock (_sync)——编译器会对 Lock→object 转换产生警告 |
| ② 不支持 async | lock (_sync) { await … } |
异步锁用 SemaphoreSlim(Lock 是线程亲和锁) |
| ③ 不要混用 | 同一受保护资源用 Lock 又用 Monitor | 统一用 Lock——旧代码里有 Monitor.Wait/Pulse 的除外 |
ManualResetEventSlim 或 Channel<T> 替代 Wait/Pulse 模式。
SemaphoreSlim 是 .NET 中的轻量级异步信号量——一个计数器,允许多个线程/任务同时进入关键区。和 Lock 的关键区别:支持 await。
private readonly Lock _sync = new();
// ❌ 不能 await——编译错误
lock (_sync) {
await db.SaveAsync(); // 不允许
}
private readonly SemaphoreSlim _sem = new(1, 1); // 初始1, 最大1 = 互斥
// ✅ 可以 await
await _sem.WaitAsync();
try {
await db.SaveAsync();
} finally {
_sem.Release(); // ⚠️ 必须手动释放!
}
Lock / lock | SemaphoreSlim | |
|---|---|---|
| 同时进入数 | 1(互斥) | 可配 1 ~ N |
| 支持 async | ❌ | ✅ WaitAsync() |
| 释放方式 | 离开 lock 块自动释放 | 必须手动 Release() |
| 典型场景 | 同步互斥(热路径首选) | 异步互斥 · 限制并发数 |
new SemaphoreSlim(1, 1) + WaitAsync() + try/finally Release();限制并发 → new SemaphoreSlim(5)(最多 5 个同时发 HTTP 请求等)。Release() 不会自动调用——出了作用域不释放,不像 lock 用 using 就能自动清理,所以 try/finally 是标配。
params ReadOnlySpan<int> 作为参数?lock (lockObj) 且 lockObj 是 Lock 类型时,生成什么代码?lock(object) 迁移到 Lock 的理由?| 特性 | 一句话 | 日常频率 |
|---|---|---|
| params 集合 | params 参数改用 ReadOnlySpan → 零堆分配;LINQ 结果直传 IEnumerable → 省掉 .ToArray() | ⭐⭐⭐⭐⭐ |
| 新 Lock 对象 | 把 object _sync 改成 Lock _sync——lock 语法不变,编译器自动切到更快的实现 | ⭐⭐⭐⭐ |
\e 转义、方法组自然类型改进、partial 属性、OverloadResolutionPriority。params T[] 和 object _sync——评估哪些可以升级到 C# 13 写法。