Lesson 18: C# 13 — params 集合 + 新 Lock 对象

.NET 9(2024.11)· 两个每天都会用到的改进

前置:已完成 C# 12 全部四课(1417)。本课进入 C# 13 的两个核心特性——params 集合和新 Lock 对象。
阅读:Microsoft Learn: C# 13 新增功能 · NDepend: C# 13 params collections

一、params 集合 params Collections 每日用 性能

1.1 痛点:你每天都在无意中分配数组

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。.

问题不在于"分配一次"——而在于每次调用都分配。一个循环里调 1000 次 = 1000 个堆数组。params 是最隐蔽的 GC 压力源之一。

1.2 解决方案:params 现在可以基于 Span

C# 13 的核心创新:params 参数可以是 ReadOnlySpan<T>——编译器在栈上构造 Span,零堆分配。

😣 C# 12 — 每次都分配数组

// 只能: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]

😎 C# 13 — 栈上 Span,零分配

// 现在可以: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
// → 零堆分配

1.3 编译器到底生成了什么?How the Compiler Lowers This

这是用户最关心的部分——编译器做了三件事:

// 你写的代码:
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);

// 优化:同一方法内多次调用复用同一个缓冲区
// (取所有调用中参数数量最大的那一次作为缓冲区大小)
关键事实:缓冲区在栈上(ref struct 不能逃逸到堆),Span 指向栈内存。方法返回后缓冲区自动消失——零 GC。编译器还在同一方法内复用缓冲区,多次调用只需一块栈空间。

🔍 深入:编译器怎么知道"参数数量最大值"?

答案是编译时静态分析——编译器扫描调用方方法内所有的 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 的缓冲区
// 三次调用复用同一块栈空间,不是每次调用都分配新的
关键限制:这个优化是调用方方法级别的,不是跨方法的。每个调用方各自扫描自己的调用点,各自在各自的栈帧里分配,互不影响、也不共享。

为什么不用最大可能值?编译器不需要——所有调用点在编译时都已知。如果某次调用需要更多参数,那个调用点本身就可见,max 自然变大。不存在"运行时突然冒出一个新调用点"的情况。

1.4 支持的类型完整列表

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>。其余情况几乎不用。

1.5 实战场景一:零分配日志/格式化

// 你的日志包装器——热路径上每秒数千次调用
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);  // 空参也不分配

1.6 实战场景二:用 IEnumerable<T> 直接传 LINQ 结果

// 场景:要把 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()——编译器帮你合成存储

1.7 ⚠️ 重大变更:重载决议优先 Span,表达式树可能报错

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 });
如果你用表达式树(EF Core、AutoMapper、动态查询等场景):重新编译 .NET 9 后可能突然报 CS8640 / CS9226。修复很简单——显式构造数组即可。但需要知道为什么才能定位到问题。
来源:Microsoft: params 重载决议重大变更

1.8 限制速查

场景params ReadOnlySpan<T>原因
普通同步方法✅ 零分配Span 在栈上,不逃逸
async 方法❌ CS4007Span 不能跨越 await 边界
迭代器(yield return❌ CS4007Span 不能跨越 yield 边界
表达式树❌ CS8640表达式树不能包含 ref struct
传递给 IEnumerable<T> 参数✅(需显式转换)Span 实现了 IEnumerable,但需 using

对于 async/迭代器场景,退回到 params T[]params IEnumerable<T>——性能差异在 I/O 场景下可忽略。

二、新 Lock 对象 System.Threading.Lock 每日用

2.1 痛点:lock(object) 的六宗罪

从 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 是遗留 APIMonitor.Enter 用 ref bool 模式——不是现代 .NET 风格
③ 装箱lock 关键字要求引用类型,锁值类型时会装箱
④ 退化可能Lock 隐式转换为 object 后 lock——失去优化,回退到 Monitor
⑤ 无法区分用途一个 object 到底是锁、缓存键、还是普通状态?阅读代码时无法判断
⑥ 平台未特化Monitor 是托管实现——无法利用 OS 原生锁特性

2.2 解决方案:System.Threading.Lock

😣 .NET 8 — object + Monitor

private readonly object _sync = new();

lock (_sync)
{
    // 关键区
    _counter++;
}

// IL 生成 Monitor.Enter/Exit
// 每次 Enter ~20ns

😎 .NET 9 — Lock + C# 13

private readonly Lock _sync = new();

lock (_sync)
{
    // 关键区——语法完全相同
    _counter++;
}

// IL 生成 using (EnterScope())
// 每次 Enter ~15ns(≈25% 更快)

2.3 编译器生成对比——这是精华

// ==================== 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——零堆分配
关键差异:新 Lock 用 using 模式替代了 Monitor.Enter/Exit。编译器识别 Lock 类型并特殊处理——不只是换了 API,而是换了编译策略。Lock.Scope 是 ref struct,不会装箱,不会逃逸到堆。

2.4 迁移:一行改动

// Step 1:改字段类型
// Before:  private readonly object _sync = new();
private readonly Lock _sync = new();

// Step 2:lock 语句完全不变
lock (_sync) { /* 不变 */ }

// 就这么简单。IDE0330 代码风格规则会自动提示你改。

2.5 ⚠️ 三个陷阱

陷阱错误示例正确做法
① 不要转为 object lock ((object)_sync) lock (_sync)——编译器会对 Lock→object 转换产生警告
② 不支持 async lock (_sync) { await … } 异步锁用 SemaphoreSlim(Lock 是线程亲和锁)
③ 不要混用 同一受保护资源用 Lock 又用 Monitor 统一用 Lock——旧代码里有 Monitor.Wait/Pulse 的除外
Monitor.Wait / Pulse / PulseAll:这是 Lock 唯一没覆盖的功能。如果你在用这些方法做线程信号——暂时保留旧的 lock(object)。Lock 目前只替代 Monitor.Enter/Exit,不替代信号机制。新代码优先用 ManualResetEventSlimChannel<T> 替代 Wait/Pulse 模式。

🔍 补充:SemaphoreSlim 是什么?

SemaphoreSlim 是 .NET 中的轻量级异步信号量——一个计数器,允许多个线程/任务同时进入关键区。和 Lock 的关键区别:支持 await

🔒 Lock(线程亲和)

private readonly Lock _sync = new();

// ❌ 不能 await——编译错误
lock (_sync) {
    await db.SaveAsync(); // 不允许
}

🚦 SemaphoreSlim(异步安全)

private readonly SemaphoreSlim _sem = new(1, 1); // 初始1, 最大1 = 互斥

// ✅ 可以 await
await _sem.WaitAsync();
try {
    await db.SaveAsync();
} finally {
    _sem.Release();  // ⚠️ 必须手动释放!
}
Lock / lockSemaphoreSlim
同时进入数1(互斥)可配 1 ~ N
支持 asyncWaitAsync()
释放方式离开 lock 块自动释放必须手动 Release()
典型场景同步互斥(热路径首选)异步互斥 · 限制并发数
用法速记:异步互斥 → new SemaphoreSlim(1, 1) + WaitAsync() + try/finally Release();限制并发 → new SemaphoreSlim(5)(最多 5 个同时发 HTTP 请求等)。Release() 不会自动调用——出了作用域不释放,不像 lockusing 就能自动清理,所以 try/finally 是标配。

三、小测验

1/4 · params ReadOnlySpan<T> ——以下哪个方法不能使用 params ReadOnlySpan<int> 作为参数?
2/4 · params 重载决议——.NET 9 上重新编译旧代码,以下哪个代码会报错
3/4 · System.Threading.Lock ——编译器见到 lock (lockObj) 且 lockObj 是 Lock 类型时,生成什么代码?
4/4 · Lock 迁移——以下哪项不是lock(object) 迁移到 Lock 的理由?

四、C# 13 本课小结

特性一句话日常频率
params 集合params 参数改用 ReadOnlySpan → 零堆分配;LINQ 结果直传 IEnumerable → 省掉 .ToArray()⭐⭐⭐⭐⭐
新 Lock 对象object _sync 改成 Lock _sync——lock 语法不变,编译器自动切到更快的实现⭐⭐⭐⭐

五、下一步

💬 有问题?params Span 和 async 的交互规则?Lock 和 SemaphoreSlim 的选型决策?编译器生成的细节?随时问。