Appearance
改进 C# 内存安全性
原文:Improving C# Memory Safety
翻译:璀境石
校对:DeepSeek V4 Flash+Pro、Qwen3.6 Plus
日期:2026年5月21日
我们正在大幅改进 C# 中的内存安全性。unsafe 关键字正在被重新设计,用以告知调用者他们负有维持安全性必须履行的义务,并通过一种新的安全注释风格来加以文档化。该关键字的适用范围将从标记指针扩展至任何以编译器无法验证为安全的方式与内存进行交互的代码。编译器将强制要求使用 unsafe 关键字来封装不安全操作。其结果是,安全契约和假设将变得可见且可审查,而不再是由惯例来隐含。
我们计划在 .NET 11 中以预览版的形式发布新模型和新语法(名义上作为 C# 16 特性),并在 .NET 12 中作为正式版发布。它最初将是可选择启用的(opt-in),并可能在后续版本中成为默认行为。我们将像对待可空引用类型那样更新模板以启用新模型。早期编译器实现已经合并到 main 分支,并且正在逐步成型。
C# 1.0 引入了 unsafe 关键字,作为在类型、方法以及方法内部代码块上建立不安全上下文的方式,让开发者可以选择最方便的粒度。不安全上下文授予对指针功能的访问权限。标记为 unsafe 的方法可以在其签名和实现中使用这些功能,而未标记的方法则不能。我们还提供了一组不安全的类型,例如 System.Runtime.CompilerServices.Unsafe 和 System.Runtime.InteropServices.Marshal,这些类型需要按照惯例小心使用。
此后,unsafe 关键字在 Rust 和 Swift 中被重用和改造,这些语言团队赋予了它更严格的、面向传播的语义。C# 16 遵循了同样的路径,在 .NET 运行时库中统一应用 unsafe(包括 Unsafe 和 Marshal 的成员),并且最接近 Rust 的实现。其结果是:unsafe 不再仅仅标记一种语法,而是开始标记一种契约——一种编译器无法验证、需要熟练开发者去阅读并遵守的契约。
C# 默认就已经阻止不安全代码。大多数开发者在启用新模型时不会察觉到任何变化,因为他们并没有启用或使用不安全的 API。当启用 C# 16 安全模型后,默认阻止的范围将覆盖更大的表面积。新模型建立了强大的护栏,这些护栏是可见的、可审查的,并且由编译器强制执行。它也是强制推行工程和供应链标准的重要工具。多年来,内存安全已经成为业界和政府日益关注的优先事项,而随着软件生产规模的扩大速度超过人工审查速度,AI 辅助代码生成又为这一问题增添了新的维度。
安全性
之前的一篇文章讨论了 .NET 中的结构性安全机制:
安全性由语言和运行时的组合来强制执行……变量要么引用存活的对象,要么为 null,要么已超出作用域。内存默认自动初始化,因此新对象不会使用未初始化的内存。边界检查确保使用无效索引访问元素时不会读取未定义的内存——这通常由差一错误引起——而是会抛出
IndexOutOfRangeException。
C# 对常规安全代码已经提供了强大的安全强制执行。新模型使开发者和 AI 代理能够准确地标记不安全代码中的安全边界。编写不安全代码有两个原因:与原生代码互操作,以及在某些情况下为了性能。Go、Rust 和 Swift 也针对这些情况提供了不安全的代码子集。语言通常无法帮助你编写不安全代码;它的作用是明确不安全代码在哪里被使用,以及它是如何转换回安全代码的。
如果从另一个领域来考虑,编程安全性可能更容易理解。道路设计师通过绘制禁止跨越到对向车道的实心黄线或白线来提高安全性。驾驶员理解并遵守这一惯例。高速公路使用护栏通过结构性分离来提供安全性,这种分离即使在驾驶员未保持清醒合规的情况下也能继续发挥作用。高速公路的例子告诉我们,更高的速度意味着更高的风险。
编程也有自己的"事故",而且与内存有关。每个应用程序都有可能访问 数 GB 的虚拟内存。写入或读取任意内存会导致任意行为(未定义行为,即 UB,是行业术语),这也是大多数安全漏洞的成因。在安全代码中无法访问任意内存,但在不安全代码中,这始终是一种潜在的可能。
模型概要
.NET 程序需要遵守一个核心不变式:每次内存访问都必须针对存活的内存:即在访问时已分配、已初始化且可用的内存。安全代码从设计上保证遵守这一不变式:编译器规则和运行时检查相结合,使得意外访问成为不可能。不安全代码是指任何可能违反该不变式的操作,通常是通过读取或写入尚未存活的内存,或将内存置于后续访问会失败的状态。
不安全代码可以通过互操作、NativeMemory 或开发者手动管理来读取或写入任意内存。不变式仍然必须成立。编译器在那里无法检测到 UB,因此验证的负担转移到了开发者身上。
应对这一风险的方案是一套分层机制,有意且透明地将不安全性沿着调用图向上推送,每一层使下一层成为可能:
- 内部
unsafe { }块:每一个不安全操作(调用unsafe成员、解引用指针以及其他unsafe操作)必须出现在一个内部unsafe { }块中。这是基本机制。不安全操作在语法上被标记、限定作用域且可审查。 - 传播(Propagation):在封闭方法的签名上添加
unsafe,会将内部块的义务重新发布给它自己的调用者,除非这些义务已被解除。这将调用图划分为安全方法、unsafe方法以及它们之间的边界方法。开发者可以通过任意数量的中间层链接传播,直到有人决定停止。 - 安全文档:每个
unsafe成员都应带有/// <safety>块:这是被调用者与调用者之间的正式契约。编写它是一项受到强烈鼓励的最佳实践,分析器可以对其缺失发出标记。 - 在边界处消除(Suppression):一个包含内部
unsafe块但不在自己签名上标记unsafe的方法,是不安全代码与安全代码之间的边界。它通过运行时输入守卫、静态推理或来自上游 API 的已文档化不变式(例如malloc保证返回的指针至少在size字节范围内有效),来解除被调用者的已文档化义务。正确解除才是使安全调用者真正安全的关键。
你必须逐层走完才能获得全部价值。只做一半的工作,得到的收益远不到一半。正确地走完每一层,你就有了一条贯穿调用图的、可供他人审查并可能改进的连贯推理线。
编写不安全代码是一项特殊技能,需要对这一不变式以及许多陷阱有深刻的理解。新模型使不安全代码更易于推理和审查,而不是更容易编写——它强制采用一种正式的、可见的结构。关键字和编译器强制执行本身并不是安全性;它们是脚手架,引导开发者去明确表达并遵守安全性。
C# 1.0 将一类"指针功能"归类到 unsafe 之下:声明和解引用指针类型、获取变量地址、将 stackalloc 用于指针、对任意类型使用 sizeof,以及多年来添加的其他功能,包括抑制某些编译器错误。新模型则更加精细和选择性更强。
相对于 C# 1.0 规则的变更包括:
unsafe类型修饰符将产生错误。不安全范围下移到单个方法、属性和字段,使其契约可见且最小化指定。委托也不能是不安全的,因为它们具有类型形状。- 静态构造函数或终结器上不允许使用
unsafe。它们的调用没有可以被包裹在unsafe { }块中的调用点模式,因此签名标记无法传播任何内容。 new()泛型约束仅匹配安全的无参构造函数;无参构造函数为unsafe的类型无法满足new()。- 新的
safe关键字允许开发者在编译器要求明确选择的地方声明某个声明是安全的。目前唯一的适用场景是extern声明,它们必须被标记为safe或unsafe,包括LibraryImport分部方法声明。 - 成员上的
unsafe不再建立不安全上下文。现在在不安全调用点处必须使用内部unsafe块。 - 签名中的指针类型不再传播不安全性。只有指针解引用才是不安全的,因此
byte*参数本身不会向调用者传播不安全性。对于新代码,避免使用IntPtr来表示指针;更倾向于使用类型化指针如byte*,或对真正不透明的指针使用void*。对于现有的基于IntPtr的 API,考虑添加指针类型的重载,并隐藏或软废弃IntPtr版本。对于不透明句柄,更倾向于使用SafeHandle。nint和IntPtr在元数据中无法区分,因此当参数确实是原生大小的整数时,应明确地加以文档说明。
采用新模型需要通过一个新的可选择启用的项目级属性。详情请参见§ 项目级选择启用。
模型实践
不安全代码显著提高了风险,并且在某些维度上始终是无界的。最好的不安全 API 的设计目标是使无界性尽可能狭窄:将能够推入签名的部分推入签名,在方法体内解除能够解除的部分,然后只给调用者留下一个小型的、定义明确的任务让他们自己处理。
Encoding.GetString(byte*, int) 就是一个很好的例子。
csharp
public unsafe string GetString(byte* bytes, int byteCount)
{
ArgumentNullException.ThrowIfNull(bytes);
ArgumentOutOfRangeException.ThrowIfNegative(byteCount);
return string.CreateStringFromEncoding(bytes, byteCount, this);
}该方法清晰地传达了 API 的期望:byte* 参数表明它是一个原始的非托管缓冲区,配对的 byteCount 精确地说明了 API 将读取多少字节。方法体解除了它能够处理的部分:空指针或负长度会被异常拒绝。这些守卫消除了 string.CreateStringFromEncoding 静默读取任意内存的一部分情况。GetString 返回一个新的 string,消除了缓冲区的任何别名或生命周期问题。
调用者承担单一且明确的义务:从 bytes 开始的 byteCount 个字节必须是可读内存。传入大于缓冲区长度的值属于未定义行为:解码器可能会遇到不可读内存而崩溃,或者它可能读取缓冲区末端之后碰巧存在的任意数据并返回一个由外来字节构成的字符串。在现有模型中,签名中的 byte* 阻止了从安全代码中调用此 API。在新模型下,签名中的指针本身不再暗示不安全性;GetString 将被显式标注为 unsafe,因此它仍然无法从安全代码中被调用。
"更好的不安全"并非由更危险或更不危险来定义,而是由对不安全性的描述更充分还是更不充分来定义;锋利的刀能做出最精细的切割,而钝刀只会撕裂。
Marshal.ReadByte 则是一个更值得警惕的案例。
csharp
public static unsafe byte ReadByte(IntPtr ptr, int ofs)
{
try
{
byte* addr = (byte*)ptr + ofs;
return *addr;
}
catch (NullReferenceException)
{
throw new AccessViolationException();
}
}Marshal.ReadByte 的调用者传入一个 IntPtr 和偏移量,两者共同定位到程序被允许读取的一个字节。与 GetString 相比,值得警惕的区别在于 ReadByte 不执行任何输入验证,并且目前可以从安全代码中调用。try/catch 子句不提供任何安全性保障,它只是用于改变异常类型,而且仅针对一种不当行为的场景。这被认为是可接受的,原因是按照惯例,Marshal 和 Unsafe 被理解为调用时是不安全的。
我们可以进一步剖析这个方法。当前 ReadByte 上的 unsafe 签名为实现建立了一个不安全上下文,但没有创建调用者契约或文档化调用者警告。现有模型通过签名中的指针类型传播不安全性,但 IntPtr 绕过了这一规则;该 API 实际上是在"走私"指针。
新模型弥补了这一漏洞。它将不安全性扩大到涵盖任何可能违反存活内存不变式的操作(而不仅仅涉及指针类型的操作),并使 unsafe 签名标记成为成员契约,内部 unsafe 块封装不安全操作。它还将 IntPtr 和 byte* 等指针的安全性特征对齐:两者都可以在 unsafe 块之外被持有、赋值和暴露在签名中;真正不安全的是指针解引用。
根据以下模拟代码,ReadByte 在新模型下的变化如下:
csharp
/// <summary>Reads a single byte from unmanaged memory.</summary>
/// <safety>
/// The sum of <paramref name="ptr"/> and <paramref name="ofs"/> must address a byte
/// the caller is permitted to read.
/// </safety>
public static unsafe byte ReadByte(IntPtr ptr, int ofs)
{
try
{
byte* addr = (byte*)ptr;
unsafe
{
// SAFETY: relies on caller obligation.
return addr[ofs];
}
}
catch (NullReferenceException)
{
throw new AccessViolationException();
}
}让我们深入探讨实现细节。强制类型转换 (byte*)ptr 是指针操作,不是解引用;IntPtr 和 byte* 形状相同,只是表示方式不同;它们本质上都是一个数字。不安全性只在一行代码上:return addr[ofs]。此时开发者需要确认 addr + ofs 指向的是可读内存,因为索引操作解引用了该地址。byte* → byte 需要将内存从指针地址复制到值类型中。这就是危险操作所在。
新模型之所以有效,是因为指针解引用 addr[ofs] 被包裹在了一个 unsafe 块中,使不安全性暴露出来。unsafe 签名成为调用者契约,迫使调用者也用 unsafe 块包裹他们的调用,并提醒他们查看被调用者的 safety 文档。
从严格的"最小 unsafe 块"角度来看,+ ofs 算术运算应该放在块外面,因为算术运算本身不是解引用。我们更倾向于将 addr[ofs] 放在一起:索引就是间接寻址(根据规范,addr[0] 等同于 *addr),并且将它们组合在一起使得正在被读取的确切地址在访问点可见。我们期望随着时间的推移,这类选择会被编入不安全编码指南中。
违反规则是编译错误,而非警告。该模型不是"君子协定"。以上面的 Marshal.ReadByte 为例:它被标记为 unsafe,因为它的实现解引用了一个调用者提供的不透明指针。在新模型中,它将继续被标记为 unsafe,因为它将指针有效性义务传递给了调用者。这一义务之前是由惯例来理解的。现在编译器要求 Marshal.ReadByte 将该义务以契约的形式公开。
传播与消除
Rust 建立的安全标记系统是传播与消除的良好指南。C# 16 采用了相同的方法和语法。unsafe 关键字有两种用法。第一种是内部的 unsafe 块,用于包裹不安全操作,通常是由于调用了另一个不安全方法和/或解引用指针。第二种是外部的 unsafe 签名标记,用于定义调用者契约。
要将不安全性传播给调用者,开发者在成员签名上添加 unsafe;要将不安全性作为实现细节消除掉,则省略 unsafe。成员签名上 unsafe 的有无(对于包含内部 unsafe 的方法而言)就是编译器判断传播或消除的信号。传播将不安全性向上推给调用者,而消除则通过提供与安全调用者兼容的接口来封顶不安全性。
C# 1.0 模型
C# 1.0 在类型或成员上使用 unsafe 表示"从此处开始为不安全上下文"。它不告知也不改变调用者契约。指针是 C# 1.0 中唯一的传播机制。内部 unsafe 可以用于收紧不安全性的作用域。
让我们从一段在 C# 1.0 模型中合法的代码开始。
csharp
void Caller()
{
M();
}
unsafe void M() { }Caller 可以不加任何仪式地调用 unsafe M。
原因有二:
unsafe被用来为整个方法创建内部unsafe块,而不是用于定义调用者契约。M不暴露指针,因此不传播不安全性。
这个例子类似于 ReadByte。Caller 可以像调用 M 一样自由地调用 ReadByte。但由于指针的使用,它不能以同样的方式调用 Encoding.GetString。
我们需要审视现有模型以理解为什么要转向新模型。M 和 Caller 的角色和责任仅由惯例指定。没有关于 M 应该向 Caller 传达的安全顾虑或义务的标准,也没有关于 Caller 如何满足其安全调用者期望的标准。简言之,没有一个整体系统来推动开发者实现真正的安全性,也没有实现直接审计的能力。目前的安全性由理解如何定义义务和风险的熟练工程师来实施,而没有编译器的帮助。
C# 16 模型
新模型将方法签名上的 unsafe 用作面向调用者的传播机制。省略 unsafe 则用于传达消除。
前面例子中的 Caller 需要调整为以下 Caller1 或 Caller2。
csharp
/// <safety>
/// Caller must satisfy obligation 1
/// </safety>
unsafe void Caller1()
{
unsafe
{
// SAFETY: Obligation is passed to caller.
M();
}
}
void Caller2()
{
if (/* obligation 1 not satisfied */) throw new Exception();
unsafe
{
// SAFETY: obligation 1 is discharged by the check above
M();
}
}
/// <safety>
/// Caller must satisfy obligation 1
/// </safety>
unsafe void M() { }M 和 Caller1 都将不安全性传播给它们的调用者。Caller2 消除了其被调用者的不安全性,是一个不安全边界方法。这两种形式都是 Caller 的有效替代。开发者根据是否可能或需要验证义务 1 来决定哪种更合适。如果调用者义务仍然存在,则 Caller1 是正确的选择。在传播和消除之间进行选择并非由编译器强制(或建议),而是需要仔细判断。
Caller1 带有两个 unsafe 标记是有意为之:外部的一个投射调用者契约,内部的一个限定不安全操作的作用域。在 unsafe 成员内部,如果在不安全操作处省略内部 unsafe 块,则是编译错误;签名标记本身不再建立不安全上下文。这种外部传播/内部限定作用域的形态与 Rust 的 unsafe fn / unsafe { } 以及 Swift 的 @unsafe / unsafe expr 相匹配。
Caller2 可以从安全代码调用,不对它的调用者施加任何义务,也不要求在它们的调用点使用 unsafe 块。
该模型适用于任何调用者。上面的例子演示了同一类型上的调用者。该模型在跨类型、跨项目和跨包时统一适用。它也适用于源生成器。目前没有计划提供范围性的选择退出机制。
这种强制仅在编译时执行。该模型不引入新的运行时检查,也没有性能影响;现有的导致异常的运行时检查(如 IndexOutOfRangeException 和 ArgumentNullException)保持不变。
.NET 运行时库将选择启用。这对于为调用者建立模型基础是必要的。消费已选择启用的库并不要求你的项目也选择启用,反之亦然。跨程序集的行为取决于哪一方选择了启用:
- 已启用的调用者,已启用的被调用者。 新模型。被调用者的
unsafe标记通过元数据传递,调用者必须将调用包裹在unsafe { }块中;没有的话,调用就是编译错误。 - 已启用的调用者,未启用(遗留)的被调用者。 兼容模式。编译器将签名中带有指针类型的任何被调用者成员视为
unsafe,要求在调用点处有包围的unsafe { }块。非指针不安全表面(IntPtr/nint参数、P/Invoke 签名等)不会被标记,因为遗留程序集没有元数据来区分它。兼容模式防止了"安全性倒退"——即在新模型启用时,遗留包的不安全 API 会悄无声息地失去其由指针驱动的unsafe传播。 - 未启用的调用者,已启用的被调用者。 不强制执行新模型的
unsafe标记;遗留调用者无法解读它们。C# 1.0 的指针规则仍然适用:在签名中暴露指针类型的被调用者仍然要求遗留调用者处于unsafe上下文中。存在的差距是新模型中签名里没有指针类型的unsafe方法(例如unsafe byte ReadByte(IntPtr, int))。这些方法变得可以从遗留安全代码中调用。
运行时库的迁移已经在进行中:reduce-unsafe 标签跟踪了从库中移除不安全代码的 PR 运行列表,包括像 #127394(用 BitConverter 等效方法替换 MemoryMarshal.Read/Write)和 #127485(从 IBinaryInteger.TryReadBigEndian 中移除不安全代码)这样的替换。这次迁移也表明工业代码可以迁移到安全模式。你的不安全代码很可能也可以。
总结一下与 C# 1.0 的变更:
- 成员签名上的
unsafe现在定义了一个面向调用者的契约,将不安全性沿着调用图向上传播。C# 1.0 仅用它来建立不安全上下文。 - 每次调用
unsafe成员时都需要一个unsafe块。
跨语言比较:传播
C#、Rust 和 Swift 之间的差异既微妙又有启发性。C# 16 仅在成员上出现 unsafe 关键字时传播不安全性;指针类型和其他不安全类型的参数本身不传播。Rust 的行为相同:普通 fn 上的 *const u8 参数不传播任何内容。Swift 是个例外:签名中出现的任何 @unsafe 类型都会隐式使声明变为 @unsafe,除了显式的 @unsafe 属性之外。
Swift 的隐式模型导致需要使用 @safe 作为广泛适用的选择退出机制,用于封装不安全性的 API(例如 Array.withUnsafeBufferPointer)。C# 和 Rust 都包含一种窄化的正向 safe 形式用于互操作(FFI),但出于不同的原因。Rust 在 unsafe extern 块内的 safe fn 是对默认值的覆盖。该块默认是不安全的,而 safe 将单个声明选择退出,在形态上类似于 Swift 的 @safe。C# 16 用于 LibraryImport 声明的 safe extern 不是覆盖。它是关于整个声明的陈述,而且是必需的,因为该语言偏向于显式标记,不允许开发者将外部声明的安全性隐含处理。
每个 LibraryImport 分部方法必须标记为 safe 或 unsafe:
csharp
[LibraryImport("libc")]
internal static safe partial int getpid();
[LibraryImport("libc", StringMarshalling = StringMarshalling.Utf8)]
internal static unsafe partial nint strlen(byte* str);getpid 没有参数并返回一个基本类型;作者证明该调用是安全的,安全调用者可以不加任何仪式地使用它。strlen 接受一个原生代码将解引用的原始指针;作者无法在边界处解除该义务,因此该声明传播 unsafe,并且 <safety> 块指明了调用者的义务。省略这两个修饰符将导致编译错误——开发者必须做出选择。
让我们看一个传播的例子。一个简短的 Rust 程序(2024 edition)会同时触发 unsafe_op_in_unsafe_fn 警告(在 unsafe fn 函数体内执行不安全操作但没有内部 unsafe 块)和一个硬性 E0133 错误(在没有 unsafe 块的安全上下文中调用 unsafe fn):
bash
$ cat main.rs
/// # Safety
///
/// `bytes` must be non-null and point to at least one readable byte.
pub unsafe fn first_byte(bytes: *const u8) -> u8 {
// No inner `unsafe { }`: warns under `unsafe_op_in_unsafe_fn` (edition 2024).
*bytes
}
fn main() {
let data = [42u8];
// No `unsafe { }` around the call: hard error E0133.
let value = first_byte(data.as_ptr());
println!("{value}");
}
$ cargo build
Compiling unsafe_demo v0.1.0 (/private/tmp/unsafe-demo)
warning[E0133]: dereference of raw pointer is unsafe and requires unsafe block
--> src/main.rs:6:5
|
6 | *bytes
| ^^^^^^ dereference of raw pointer
|
= note: raw pointers may be null, dangling or unaligned; they can violate aliasing rules and cause data races: all of these are undefined behavior
note: an unsafe function restricts its caller, but its body is safe by default
--> src/main.rs:4:1
|
4 | pub unsafe fn first_byte(bytes: *const u8) -> u8 {
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
= note: for more information, see <https://doc.rust-lang.org/edition-guide/rust-2024/unsafe-op-in-unsafe-fn.html>
= note: `#[warn(unsafe_op_in_unsafe_fn)]` (part of `#[warn(rust_2024_compatibility)]`) on by default
error[E0133]: call to unsafe function `first_byte` is unsafe and requires unsafe block
--> src/main.rs:12:17
|
12 | let value = first_byte(data.as_ptr());
| ^^^^^^^^^^^^^^^^^^^^^^^^^ call to unsafe function
|
= note: consult the function's documentation for information on how to avoid undefined behavior
For more information about this error, try `rustc --explain E0133`.
warning: `unsafe_demo` (bin "unsafe_demo") generated 1 warning
error: could not compile `unsafe_demo` (bin "unsafe_demo") due to 1 previous error; 1 warning emitted这种体验与我们所计划的非常相似。关键区别是这两种情况在 C# 16 中都将是错误。
综上所述,C# 和 Rust 代码偏向于简单的显式规则,可以说需要的领域知识更少。一个例证是,在 C# 16 和 Rust 中,使用 grep 作为安全审计工具是合理的,因为显式关键字充当了查询可以轻松抓住的锚点。
项目级选择启用
C# 16 安全模型有两个项目级开关。它们是独立的,服务于不同的目的。
第一个开关是一个新的可选择启用的属性(最终名称将随 .NET 11 预览版确定)。关闭时,遗留的 C# 1.0 规则继续生效;开启时,新的调用者不安全规则适用。这个开关决定什么算作不安全以及它是如何传播的。
第二个开关是现有的 <AllowUnsafeBlocks> 属性。它默认为 false(在所有 C# 版本下),并控制项目源代码中 unsafe 关键字的每次出现:在新规则下包括成员签名、内部块、字段和 safe extern 声明。从另一个项目调用不安全的 API 也算在内,因为调用点需要一个内部 unsafe { } 块。因此,使用默认设置的项目不能使用任何不安全 API。
这两个属性的组合如下:
- 新属性开启,
<AllowUnsafeBlocks>关闭(默认)。 最安全的配置。项目参与新模型且不允许任何不安全代码。你可以确定你的代码没有调用Marshal.ReadByte或任何其他unsafe成员。 - 新属性开启,
<AllowUnsafeBlocks>开启。 项目参与新模型且允许不安全代码。 - 新属性关闭,
<AllowUnsafeBlocks>关闭。 遗留模型继续适用。项目不能使用指针类型。 - 新属性关闭,
<AllowUnsafeBlocks>开启。 遗留模型继续适用。项目可以使用指针类型。
我们希望所有人都迁移到新模型。我们也预计随着时间的推移,启用 <AllowUnsafeBlocks> 的项目会越来越少。这正是我们对自己代码所做的事情。
为了帮助迁移,我们计划发布一个 dotnet format 修复器,对尚未开启新属性的项目进行尽最大努力的自动迁移:将不安全调用点包裹在 unsafe { } 块中、将 unsafe 修饰符从类型移到其成员上,以及类似的机械性重写。修复器无法推断安全义务或编写 <safety> 块;这些工作仍由开发者完成。它是一个起点,让代码在新规则下能够编译,而不是完成全部迁移。
对于 AI 代理生成代码,核心问题是由谁负责确定是否编写了不安全代码。在新模型下,这是编译器的职责。只要你没有设置 AllowUnsafeBlocks=true,编译器将拒绝编译任何不安全代码。没有代码审查能比得上编译错误的效率。内存安全审计从检查每个差异简化为检查一个项目属性。
跨语言比较:默认值
这些差异同样微妙且重要。我们可以沿着两个安全维度来分析这三种语言:严格传播(不安全性传播的积极程度以及什么算作不安全)和彻底禁止不安全代码。对于每个轴,更安全的姿态要么是默认值,要么可作为选择启用。
| 语言 | 严格传播 | 仅安全代码 |
|---|---|---|
| C# | 选择启用(C# 16 模型) | 默认(AllowUnsafeBlocks=false) |
| Rust | 默认(唯一模型) | 选择启用(#![forbid(unsafe_code)]) |
| Swift | 选择启用(-strict-memory-safety) | 选择启用(无标准开关) |
C# 16 将通过新的安全关键字启用严格模型。AllowUnsafeBlocks=false 仍然是默认值。在新模型下,它的作用更大,因为它控制的 unsafe 操作集合要大得多。
Rust 只有一种安全模型,而且是严格的。编译器默认允许在任何 crate 中使用 unsafe,需要 #![forbid(unsafe_code)] lint 来禁用它。
Swift 也提供了一个严格的选择启用模式(-strict-memory-safety,SE-0458),可以按文件或按模块设置,将隐式不安全性转换为诊断。
这些比较并不是真正意义上可比的,因为它们是多维的。Rust 具有最强的默认立场。我们的观点与内存安全连续体一致:更严格的默认值更好。我们的意图是让新的 C# 安全模型成为新的常态。我们将首先在模板中启用它。由于不安全代码默认已经被禁止,我们引入更严格的安全模型会相对简单,并且我们期望因为这会有良好的采用率。
安全文档
按字面理解"unsafe"这个词很容易,但它会误导人。它的意思是"关闭安全装置"。安全代码被编译器已知遵守已定义的安全模型,而不安全代码则不是这样。对于不安全代码,了解的负担落在了开发者身上。了解的第一步是阅读专门的安全文档。编写得当的不安全代码会文档化调用者的义务:调用者必须满足哪些条件代码才能正确运行。
缺少或文档编写不当的不安全代码是不安全可调用的,因为调用者只能靠猜测。代码审计人员会特别关注这一点。Rust 社区早已如此:Google 和 Mozilla。
分析器将标记缺失的 /// <safety> 块。
Rust 安全注释
我们将依赖 Rust 提供规范的例子,因为它已经非常成熟。Rust 使用安全注释来证明不安全代码是可靠的(sound)。
一个不安全的 Rust 函数,as_bytes_mut:
rust
/// Converts a mutable string slice to a mutable byte slice.
///
/// # Safety
///
/// The caller must ensure that the content of the slice is valid UTF-8
/// before the borrow ends and the underlying `str` is used.
///
/// Use of a `str` whose contents are not valid UTF-8 is undefined behavior.
///
/// ...
pub unsafe fn as_bytes_mut(&mut self) -> &mut [u8] {
// SAFETY: the cast from `&str` to `&[u8]` is safe since `str`
// has the same layout as `&[u8]` (only libstd can make this guarantee).
// The pointer dereference is safe since it comes from a mutable reference which
// is guaranteed to be valid for writes.
unsafe { &mut *(self as *mut str as *mut [u8]) }
}Clippy 强制执行这一约定。没有 # Safety 部分的不安全函数会触发 missing_safety_doc lint:
bash
$ cat main.rs
#![deny(clippy::missing_safety_doc)]
pub unsafe fn first_byte(bytes: *const u8) -> u8 {
unsafe { *bytes }
}
fn main() {
let data = [42u8];
let value = unsafe { first_byte(data.as_ptr()) };
println!("{value}");
}
$ cargo clippy
Checking unsafe_demo v0.1.0 (/private/tmp/unsafe-demo)
error: unsafe function's docs are missing a `# Safety` section
--> src/main.rs:3:1
|
3 | pub unsafe fn first_byte(bytes: *const u8) -> u8 {
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
= help: for further information visit https://rust-lang.github.io/rust-clippy/rust-1.95.0/index.html#missing_safety_doc
note: the lint level is defined here
--> src/main.rs:1:9
|
1 | #![deny(clippy::missing_safety_doc)]
| ^^^^^^^^^^^^^^^^^^^^^^^^^^
error: could not compile `unsafe_demo` (bin "unsafe_demo") due to 1 previous error如果你是 Rust 新手,是的,它有 /// 文档注释。它还有属性,用于建议的安全标签。
函数上方的 /// # Safety 块文档化了正式的契约性调用者义务。阅读安全注释是调用者的责任。忽略这一点可能导致编写不正确且具有未定义后果的不安全代码。如果出现问题,责任归于调用者。这就是为什么我们将此功能称为"调用者不安全"。
/// 注释会被直接复制到 as_bytes_mut 的公共 Rust 文档中。安全注释从代码中提取出来,放到调用者可以看到的公共门户中。这强烈表明了它们的重要性以及为什么它们需要与普通注释区分开来。
该示例还包含第二种更内部的安全注释类型。函数体内的 // SAFETY: 注释是给代码库的开发者或审计人员看的;它们概述了安全假设,而非调用者义务。编译器不读取、不要求也不认可这些注释。它们是一种约定。
两种注释风格都很重要。它们共同讲述了一个关于安全性的两面故事,锚定在调用图上。
通过 unsafe 块,我们向 Rust 声明我们已经阅读了函数的文档,我们理解如何正确使用它,并且我们已经验证我们正在履行函数的契约。
来源:调用不安全函数或方法
Rust Book 中的这段摘录清楚地表明,安全性依赖于一个始于编译器诊断但不止于此的过程。相应的 Rust lint(unsafe_op_in_unsafe_fn)在较早的版本中默认为 allow,因此缺少内部 unsafe 块会被静默接受。2024 版本将其提升为默认警告,这是一种兼容性妥协,使现有的 crate 能够在版本边界上继续构建。C# 16 没有同样的历史包袱,将其设为编译错误。
C# 安全注释
C# 使用两种安全注释风格,如下面 ReadByte 模拟代码所示:
csharp
/// <summary>Reads a single byte from unmanaged memory.</summary>
/// <safety>
/// The sum of <paramref name="ptr"/> and <paramref name="ofs"/> must address a byte
/// the caller is permitted to read.
/// </safety>
public static unsafe byte ReadByte(IntPtr ptr, int ofs)
{
try
{
byte* addr = (byte*)ptr;
unsafe
{
// SAFETY: relies on caller obligation.
return addr[ofs];
}
}
catch (NullReferenceException)
{
throw new AccessViolationException();
}
}签名上方的 /// <safety> 块是正式的调用者契约。方法体内的 // SAFETY: 注释是一个内部说明,指明不安全操作所依赖的内容。
仅看签名 unsafe byte ReadByte(IntPtr, int),你只能看到形状,看不到安全契约。/// <safety> 块才是契约,这就是为什么分析器会标记其缺失。教训是,了解不安全 API 的形状是必要的,但不足以编写正确的代码。编写不安全代码需要戴上安全眼镜。
这里命名了一个剩余义务:ptr + ofs 必须指向一个可读字节。调用者必须解除它。签名上的 unsafe 关键字就是向调用者揭示这一义务的方式。// SAFETY: 注释指明了解引用所依赖的内容:调用者已为该义务提供了安全守卫。
考虑调用者传入 IntPtr 参数时可能处于的状态:
IntPtr.Zero(空指针): 解引用会在运行时的空值检查守卫页上触发陷阱,并表现为NullReferenceException,catch 将其转换为AccessViolationException。移除catch不会改变安全性,只会改变异常类型。- 指向未映射内存的指针(未初始化、已释放或垃圾值):解引用触发硬件访问违规。在大多数平台上这会终止进程;catch 甚至可能不会执行。
- 指向调用者不拥有的已映射内存的指针(别人的缓冲区、GC 堆、代码段):解引用可能会成功。已映射的页面仍然可能是不可读的(例如守卫页),在这种情况下行为与上一点相同。当它确实成功时,
ReadByte会从内存中返回一个具有任意值的任意字节。没有异常,没有警告。这是教科书式的 UB 结果;程序带着被破坏的假设继续运行。最坏的情况是它读取了被程序解释为有效值的内存。 - 调用者正确知道指向可读字节的指针: 按预期工作。
try/catch 处理第一种状态,对第二种状态无法优雅处理,对第三种状态完全不可见。这些都不是验证。契约传递到调用者那里,关于缓冲区的来源、长度和生命周期的信息可用于排除危险状态。/// <safety> 块使该契约可见。调用者需要理解并防范这些情况。
安全守卫
文档命名义务。守卫解除义务。这一模式在不安全边界处最为重要,在这里开发者声明不安全代码已与编译器提供的安全性保持一致。边界也是审查应该开始的地方。有了好的文档作为指南,审查者可以判断代码是否合规。
有人可能会问,为什么不安全方法不包含足够的 if 检查来消除对调用者义务的需求。对于 ReadByte,方法内部的任何 if 检查都无法验证调用者提供的 IntPtr 是否指向可读内存:运行时根本不知道调用者分配了什么、在哪里、分配了多久。只有调用者能够唯一地确定维持安全性同时最大化性能所需的最小检查集合。
注:这些边界方法/函数没有标准名称。Rust 文档称它们为"安全元素"。本文称它们为"不安全边界方法":处于安全代码和不安全代码边界处的方法,不安全性在这里被消除。标签不安全是有意为之:这些方法保留了 unsafe 修饰方法的所有危险能力;它们只是不将其传播给调用者。
Rust 安全守卫
另一个 Rust 示例,str.split_at:
rust
pub fn split_at(&self, mid: usize) -> (&str, &str) {
// is_char_boundary checks that the index is in [0, .len()]
if self.is_char_boundary(mid) {
// SAFETY: just checked that `mid` is on a char boundary.
unsafe { (self.get_unchecked(0..mid), self.get_unchecked(mid..self.len())) }
} else {
slice_error_fail(self, 0, mid)
}
}不安全边界函数通常只有 // SAFETY: 注释;它们不施加自己的义务。正式的 /// 风格保留给 unsafe 方法,其义务由边界来解除。传播的函数必须标记为 unsafe。
split_at 中的 if self.is_char_boundary(mid) 检查是一个守卫,用于维护它所调用的不安全代码的安全性。它确保分割在字符边界上,因为 Unicode 字符可以是多字节的。如果该测试失败,程序会通过 slice_error_fail panic。一个 panic 会崩溃程序以防止未定义行为。
一个为了避免未定义行为而 panic 的程序,远比放任未定义行为发生的程序可靠。
C# 安全守卫
Rust 中相同的边界模式也适用于 C#:相同的 // SAFETY: 约定,相同的在签名上省略 unsafe 标记。
csharp
// Converts a substring of this string to an array of characters. Copies the
// characters of this string beginning at position sourceIndex and ending at
// sourceIndex + count - 1 to the character array buffer, beginning
// at destinationIndex.
//
public void CopyTo(int sourceIndex, char[] destination, int destinationIndex, int count)
{
ArgumentNullException.ThrowIfNull(destination);
ArgumentOutOfRangeException.ThrowIfNegative(count);
ArgumentOutOfRangeException.ThrowIfNegative(sourceIndex);
ArgumentOutOfRangeException.ThrowIfGreaterThan(count, Length - sourceIndex, nameof(sourceIndex));
ArgumentOutOfRangeException.ThrowIfGreaterThan(destinationIndex, destination.Length - count);
ArgumentOutOfRangeException.ThrowIfNegative(destinationIndex);
unsafe
{
// SAFETY: the bounds checks above ensure that `count` characters
// starting at `sourceIndex` are in range of this string, and that
// `count` characters starting at `destinationIndex` fit in `destination`.
Buffer.Memmove(
destination: ref Unsafe.Add(ref MemoryMarshal.GetArrayDataReference(destination), destinationIndex),
source: ref Unsafe.Add(ref _firstChar, sourceIndex),
elementCount: (uint)count);
}
}这里的每个 ThrowIf* 调用都是内存安全守卫。每一个都支撑着原始 Buffer.Memmove 调用所假设的一个不变式:
ThrowIfNull(destination):没有它,MemoryMarshal.GetArrayDataReference(null)就是 UB。ThrowIfNegative(count):没有它,(uint)count会静默将负值环绕为一个巨大的elementCount,导致越界复制,这就是 UB。ThrowIfNegative(sourceIndex)和ThrowIfNegative(destinationIndex):没有它们,Unsafe.Add(ref …, negativeIndex)会将引用偏移出存储的前端,导致的读取或写入就是 UB。- 两个
ThrowIfGreaterThan检查在上述负值检查之上叠加(并依赖运行时Length在[0, int.MaxValue]范围内的不变式,这样Length - sourceIndex不会溢出),以将count限制在源和目标剩余容量之内。没有它们,复制可能会越过任何一个缓冲区的末端,导致的读取或写入就是 UB。
这些检查是组合的。每一个只有在先前的检查已经排除了某些类别的输入时才足够。改变这个链条中的任何一环(切换到无符号索引类型,或改变运行时对 Length 的保证),安全推理就必须重新推导。
ThrowIf* 方法是 Rust panic 辅助函数(如 slice_error_fail)的 C# 对应物;两者都在边界处崩溃程序而不是让 UB 发生,并且都被提取到单独的函数中,以使冷路径远离热代码。
不安全的字段
字段值得专门讨论。当字段的声明类型不表达封闭类型所维护且下游代码所依赖的不变式时,该字段需要标记为 unsafe。不安全性存在于类型系统所看到的与封闭类型所承诺的之间的鸿沟中。
最简单的情况是持有原生指针的字段。下面的例子是模拟代码;它不像其他例子那样源自 dotnet/runtime。
csharp
public class NativeBuffer : IDisposable
{
/// <safety>
/// Must be null or point to a buffer of Length bytes.
/// </safety>
private unsafe byte* _ptr;
public int Length { get; }
public NativeBuffer(int length)
{
ArgumentOutOfRangeException.ThrowIfNegative(length);
unsafe
{
// SAFETY: NativeMemory.Alloc throws OutOfMemoryException on failure rather than
// returning null (unlike the malloc it wraps), so on return _ptr points to `length` bytes.
_ptr = (byte*)NativeMemory.Alloc((nuint)length);
}
Length = length;
}
public byte ReadAt(int index)
{
ArgumentOutOfRangeException.ThrowIfNegative(index);
ArgumentOutOfRangeException.ThrowIfGreaterThanOrEqual(index, Length);
unsafe
{
ObjectDisposedException.ThrowIf(_ptr is null, this);
// SAFETY: bounds checked above; null check just above; _ptr therefore points to Length bytes
return _ptr[index];
}
}
public void Dispose()
{
unsafe
{
// SAFETY: _ptr is null or was returned by NativeMemory.Alloc; Free accepts both
NativeMemory.Free(_ptr);
_ptr = null;
}
}
}该类是安全可调用的,unsafe 字段代表公共表面承担了有效性不变式。Length 是一个只读的自动属性,在构造时固定;它的不可变性是不变式的另一半,因为 _ptr 的大小义务是以 Length 来表述的。如果 Length 在构造后可以改变,它就需要自己的 unsafe 标记和 <safety> 块来保持两者的一致性。Dispose 有意将该不变式从"有效"削弱为"null 或有效",通过写入 null,这就是为什么 _ptr 不能是 readonly,以及为什么 ReadAt 在解引用之前检查 null。字段上的 unsafe 标记使两次写入(构造函数中的分配和 Dispose 中的失效)都可以在一个地方被审查。
运行时库中更惯用的情况是,字段的声明类型是合理的但不如类实际维护的类型精确。设计文档给出了这种模式的简化版本:一个泛型类持有一个 Array 字段,该字段必须始终包含 T[]。Array 是数组类型的 object;每个 T[] 都是一个 Array,因此将字段声明为 Array 在类型上是正确的,这样做可以避免泛型特化开销。C# 类型系统允许将任何数组赋给该字段,而类承诺始终恰好是 T[]。不安全性存在于这个鸿沟中:类型系统看不到更严格的不变式,而类负责维护它。
csharp
public class ArrayWrapper<T>
{
/// <safety>
/// Must always hold a value whose runtime type is T[].
/// </safety>
private readonly unsafe Array _array;
public ArrayWrapper(T[] items)
{
ArgumentNullException.ThrowIfNull(items);
unsafe
{
// SAFETY: items is statically T[], so the field invariant holds.
_array = items;
}
}
public T GetItem(int index)
{
unsafe
{
// SAFETY: _array is always a T[] per the field's <safety> block
var typedArray = Unsafe.As<T[]>(_array);
return typedArray[index];
}
}
}模式与 NativeBuffer 相同:一个带有已文档化不变式的 unsafe 字段,在边界处用 unsafe 块解除它,以及一个安全可调用的公共表面。
Rust 也在解决同样的问题,unsafe-fields 提案使用 Vec<T> 作为其动机案例。Vec<T> 带有一个不变式:data[i](对于 i < len)处的元素已初始化。今天,这个不变式只存在于注释和文字中。没有任何东西阻止一个方法(甚至是私有方法)在完全安全的代码中使 len 和 data 不同步:
rust
pub struct Vec<T> {
data: Box<[MaybeUninit<T>]>,
len: usize,
}
impl<T> Vec<T> {
// Safe code, but the next read is undefined behavior.
pub fn evil(&mut self) {
self.len += 2;
}
}建议的未来形态通过将不变式移入类型系统,将两个字段都标记为 unsafe:
rust
struct Vec<T> {
// SAFETY: The elements `data[i]` for
// `i < len` are in a valid state.
unsafe data: Box<[MaybeUninit<T>]>,
unsafe len: usize,
}有了这个改动,对 len 或 data 的任何写入都必须在 unsafe 块内进行;evil 不再按原样编译。这两个字段在同一位置、针对同一契约一起被审查。这正是 NativeBuffer 通过将 unsafe byte* _ptr 与固定的 Length 配对所获得的好处,也是 ArrayWrapper<T> 通过将 readonly unsafe Array _array 与始终为 T[] 的承诺配对所获得的好处。
你可能会说"你仍然可以用 unsafe 编写 evil,它仍然会导致 UB"。是的。整个前提就是不安全代码被标记且易于审计。这是所有这些语言中安全性的基础。
关于字段上 unsafe 的一些经验法则:
- 写入是主要动机。 字段上的
unsafe强制每次写入进入一个可审查的上下文,契约在此可见,建立(至少)成员到成员的纪律来保持不变式完整。例如,在NativeBuffer例子中对_ptr的写入会违反Length。 - 只读字段满足了大部分相同需求。 将
unsafe readonly视为契约加上内置守卫是有帮助的:unsafe命名不变式,而readonly是防止构造后写入违反它的守卫。去掉readonly,契约仍然存在;只是必须以更难的方式解除——通过审查每个写入点。上面的ArrayWrapper<T>例子正是出于这个原因使用readonly unsafe。Rust 通过 unsafe-fields 设计公理正在收敛到相同的形态:标记保留,但它控制的操作(写入、重新初始化)正是不可变性已经阻止的那些。 - 私有不是免死金牌。 很容易假设因为字段是私有的,类型自己的方法就可以被信任来维护不变式。这是旧的
unsafe类型模型。在新模型中,成员之间的交互本身就是一个契约表面;一个方法的正确写入可能被另一个方法的不协调写入所撤销。不安全性是为了保护契约免受任何可能违反它的代码的侵害,包括类型内部的代码。
迁移演练
理解该模型的最佳方式是迁移一些现有代码。这正是 .NET 团队在运行时库中所做的事情。选择一个 unsafe API,跟踪到调用者,然后决定迁移是否可以内联解除被调用者的义务,还是必须向上传播它们。每个调用者都是一个边界候选位置;迁移回答的就是该位置是否属于边界。
本节是推测性的。模型尚未最终确定,运行时库也尚未迁移。这些例子是有根据的推测,旨在传达我们前进的方向以及新模型对现有代码意味着什么。
我们将在本节中迁移一些最终归结为 NativeMemory.Alloc 和 NativeMemory.Free 的方法。以下是这两个 NativeMemory 方法在新模型下的样子:
csharp
public static void* Alloc(nuint byteCount);
/// <safety>
/// The caller must ensure:
///
/// - <paramref name="ptr"/> was returned by <see cref="Alloc(nuint)"/> (or a
/// compatible allocator) and has not already been freed.
/// - No live pointer or span aliases the storage at the time of this call.
/// </safety>
public static unsafe void Free(void* ptr);这种不对称是有意为之。Alloc 变为安全方法。它返回一个 void*,但持有指针本身并不不安全;不安全性在于最终的解引用,由调用者包裹。未能释放是内存泄漏,不是安全性问题。(Alloc 也与 malloc 不同,它在失败时抛出 OutOfMemoryException 而不是返回 null,因此调用者不需要对返回值进行守卫。)Free 保持 unsafe,因为它带有真正的前置条件:指针必须是由兼容的分配器返回且尚未释放的,并且没有其他东西可以别名为该存储。<safety> 块使这些义务在每一位调用者和审查者面前都可见。
现在我们跳到一个调用者。这是在新模型下 FileVersionInfo 的构造函数的模拟代码。该构造函数将原生版本信息 blob 解析为此对象的字符串和整数字段(_companyName、_fileVersion、_fileMajor 等);分配只是保存 blob 的临时缓冲区,供 GetVersionInfoForCodePage 从中读取。
当前签名:private unsafe FileVersionInfo(string fileName)。它之所以是 unsafe,仅仅是为了建立一个不安全上下文。
以下是更新后的签名和实现,包含内部安全注释。
csharp
private FileVersionInfo(string fileName)
{
_fileName = fileName;
uint infoSize = Interop.Version.GetFileVersionInfoSizeEx(
Interop.Version.FileVersionInfoType.FILE_VER_GET_LOCALISED, _fileName, out _);
if (infoSize != 0)
{
unsafe
{
// SAFETY:
// - bounds: `infoSize` is the size returned by GetFileVersionInfoSizeEx
// and is the same value passed to both Alloc and GetFileVersionInfoEx,
// so all reads through `memPtr` stay within the allocated range.
// - lifetime: `memPtr` is freed in the finally before this constructor
// returns, and never escapes; every consumer (GetLanguageAndCodePage,
// GetVersionInfoForCodePage) is called from within this method and
// writes its results into this object's fields.
void* memPtr = NativeMemory.Alloc(infoSize);
try
{
if (Interop.Version.GetFileVersionInfoEx(
/* flags */ default, _fileName, 0U, infoSize, memPtr))
{
uint lcp = GetLanguageAndCodePage(memPtr);
_ = GetVersionInfoForCodePage(memPtr, lcp.ToString("X8")) ||
(lcp != 0x040904B0 && GetVersionInfoForCodePage(memPtr, "040904B0")) ||
(lcp != 0x040904E4 && GetVersionInfoForCodePage(memPtr, "040904E4")) ||
(lcp != 0x04090000 && GetVersionInfoForCodePage(memPtr, "04090000"));
}
}
finally
{
NativeMemory.Free(memPtr);
}
}
}
}该构造函数是一个可靠的不安全边界。剩余的不安全性(通过 memPtr 读取的互操作调用,以及最后的 Free)在内联解除:
- 边界检查: 单个
infoSize值从大小查询调用流入Alloc,并流入每个通过memPtr读取的互操作调用;三个用途通过名称联系在一起,因此读取保持在分配的范围内。 - 生命周期:
try/finally保证Free在构造函数返回之前执行,即使互操作调用抛出异常。指针永远不会逃逸;使用它的每个辅助函数都在此方法内调用,因此在Free之后没有别名存活。
构造函数上没有 unsafe 标记,没有 <safety> 块;不安全性完全密封在方法体内。新模型中不安全的构造函数是可能的(它们将义务传播给实例化该类型的代码),但这个不需要是不安全的。
现在是 FixedMemoryKeyBox 的模拟代码:
csharp
internal sealed class FixedMemoryKeyBox : SafeHandle
{
/// <safety>
/// Must equal the byte size of the allocation pointed to by <c>handle</c>.
/// </safety>
private readonly unsafe int _length;
internal FixedMemoryKeyBox(ReadOnlySpan<byte> key) : base(IntPtr.Zero, ownsHandle: true)
{
void* memory;
unsafe
{
// SAFETY:
// - alloc: NativeMemory.Alloc returns a pointer to key.Length writable bytes.
// - span: new Span<byte>(memory, key.Length) addresses exactly those bytes.
// - lifetime: ownership of the pointer transfers to this SafeHandle
// via SetHandle below; ReleaseHandle frees the allocation when
// the ref-count reaches zero.
// - _length: paired with the allocation made on this line.
memory = NativeMemory.Alloc((nuint)key.Length);
key.CopyTo(new Span<byte>(memory, key.Length));
_length = key.Length;
}
SetHandle((IntPtr)memory);
}
/// <safety>
/// The returned span aliases storage owned by this SafeHandle.
/// The caller must ensure:
///
/// - the span is not used after this SafeHandle is disposed;
/// - access is bracketed by <see cref="SafeHandle.DangerousAddRef"/> and
/// <see cref="SafeHandle.DangerousRelease"/> (or equivalent), so
/// disposal on another thread can't free the buffer mid-use.
/// </safety>
internal unsafe ReadOnlySpan<byte> DangerousKeySpan
{
get
{
unsafe
{
// SAFETY:
// - bounds: `_length` matches the allocation made in the ctor.
// - lifetime: NOT discharged here; propagated to the caller
// via the <safety> block above. The `Dangerous` prefix
// echoes that contract in the API name.
return new ReadOnlySpan<byte>((void*)handle, _length);
}
}
}
internal TRet UseKey<TState, TRet>(TState state, Func<TState, ReadOnlySpan<byte>, TRet> func)
{
bool addedRef = false;
unsafe
{
// SAFETY: AddRef holds the SafeHandle alive for the duration of
// the callback, so `DangerousKeySpan` aliases live storage. The
// span is not retained beyond `func`'s return.
try
{
DangerousAddRef(ref addedRef);
return func(state, DangerousKeySpan);
}
finally
{
if (addedRef)
{
DangerousRelease();
}
}
}
}
protected override bool ReleaseHandle()
{
unsafe
{
// SAFETY: SafeHandle's ref-counting guarantees no live span
// aliases `handle` at this point; the new Span<byte>(handle, _length)
// addresses the allocation made in the ctor.
CryptographicOperations.ZeroMemory(new Span<byte>((void*)handle, _length));
NativeMemory.Free((void*)handle);
}
return true;
}
public override bool IsInvalid => handle == IntPtr.Zero;
}FixedMemoryKeyBox 在一个类型中展示了两种边界,说明了两个方向:
DangerousKeySpan是调用者不安全的。边界检查通过_length字段不变式内联解除。int本身是安全的;安全性问题在于它与handle的耦合:它们是一对必须匹配的组合。生命周期未在此解除。该 Span 别名了 SafeHandle 所拥有的存储,并且存储有意地比属性调用存活更久。<safety>块命名了两个剩余义务:不要超过 SafeHandle 的生命周期,以及用DangerousAddRef/Release括住访问。编译器无法强制执行其中任何一项。该标记告诉调用者他们还有工作要做。UseKey是构建在其上的可靠边界。它通过在try/finally中用DangerousAddRef/Release括住回调来解除生命周期义务。传递给func的ReadOnlySpan<byte>凭借ref struct生命周期规则是安全的。从外部看,UseKey是安全可调用的;不安全性被密封在该括号内。
二进制分发
.NET 库通常以二进制形式分发。发布到 nuget.org 的流行库可能有零个或一千个警告,但你知道它有零个错误。编译错误是少数能在生产者与消费者之间可靠传递的信息之一。
C# 16 unsafe 大量依赖新的编译器错误。选择启用新模型意味着标注工作已经完成。检查一个项目是否使用了 unsafe 代码将变得非常简单。
例如,Swift 更依赖警告来推动内存安全采用。Swift 的负担要低得多,因为依赖项是以源代码形式分发的。在构建时,你可以以相同的保真度看到依赖项的错误和警告。Rust 也有源码分发的依赖项,但大量依赖错误。
我们正在考虑在 nuget.org 上添加徽章,以鼓励采用新的内存安全强制执行,并更容易找到已经这样做的库。采用了该模型的库和包将被相应标记,使检查和理解你的供应链的安全状态(就编译器所见而言)变得容易。
用旧模型编译的项目消费用新模型构建的包,以及反过来,这都是常见情况。如概要部分所述,两个方向是不对称的。已启用的项目对遗留包执行兼容模式规则:被调用者签名中的任何指针类型都要求在调用点处有包围的 unsafe { } 块。相反,遗留项目将已启用的包视为普通程序集,不受任何新诊断的影响。这种不对称是有意为之。已启用的一方承担安全保障,兼容模式确保该保障在消费遗留代码时不会悄悄降级。
剩余的设计空间
我们项目的意图是一次性部署新模型的所有方面,既是因为它只有在整体上才连贯一致,也是为了避免开发者需要逐步采用一系列破坏性变更。然而,有几个设计方面我们无法在 C# 16 中解决。
第一个是反射,它是模型中的一个例外。代码可以通过 MethodInfo.Invoke 调用 unsafe API,而不需要包围的 unsafe 块,并且反射写入可以违反 unsafe 字段上文档化的不变式。重度使用反射的代码应该审查不安全 API 调用以及绕过新模型所表达的契约的写入。我们可能会在后续版本中处理反射使用问题。
第二个是生命周期。Rust 通过其借用检查器来解决生命周期问题;我们不打算采用像借用那样全面的系统。C# 依赖 GC 和基于引用的所有权来覆盖部分相同的领域。我们正在考虑一个有针对性的所有权模型,如 .NET 中的内存安全中所述。我们稍后将发布相关的设计计划。
更强的生命周期强制执行的主要用例是 ArrayPool,尤其是 Rent 和 Return 方法。关键场景是返回数组后继续使用它。这是一个"释放后使用"违规。很容易弄错约定,我们自己的代码中也犯过这种错误。相比之下,只 Rent 不 Return 是泄漏,而不是内存安全违规。
类比
调用者不安全特性引发了许多类比。大多数在仔细审视后都站不住脚。
说法——可空引用类型类似于调用者不安全。 可空引用类型需要方法检查和潜在的签名更新才能正确参与该模型。它们也将可空性从被调用者推向调用者,并包含一种消除机制。
现实。 可空引用类型是一个使用点关注的问题,影响表达式的类型。它们根本不影响调用者的性质。可空消除(!)在单个表达式上操作;它告诉编译器该值在该点非空。对于不安全,没有表达式级别的捷径;每次调用 unsafe 成员都需要一个包围的 unsafe { } 块。不安全模型中的消除是一个有作用域的、可审查的区域,而不是逐值注释。
说法——Async 类似于调用者不安全。 Async 从方法传播到方法。async 关键字强制传播,就像 unsafe 一样。Task.Wait() 是消除机制。
现实。 async 关键字更类似于 C# 1.0 的 unsafe,因为它为方法建立了一个异步上下文,可以在其中使用 await;调用者看到的是方法的返回类型(Task/ValueTask)。传播机制是可等待的返回类型:类型系统本身。Task.Wait() 强制从异步转换到同步,这伴随着重大的权衡。它不是正式的消除机制。
说法——Swift 的类型级 @unsafe 就是 C# 1.0 的类型级 unsafe。 两种语言都在类型上使用关键字,所以 C# 16 移除类型级 unsafe 看起来像是放弃了 Swift 保留的东西。
现实。 这两个标记共享一个关键字和一个目标,但在语义上几乎是正交的。Swift 在类型上的 @unsafe 是一个面向调用者的契约:任何使用该类型的声明都会隐式变为 @unsafe,调用者必须将访问包裹在 unsafe 表达式中。C# 1.0 在类型上的 unsafe 是一个实现范围:它让类型的成员体使用指针,但不向调用者传播任何内容。C# 16 移除 C# 1.0 的形式是因为它不携带调用者信息。两者的处置方式也不同:Swift 的标记是非许可性的,在调用者身上增加义务,而 C# 1.0 的标记是许可性的,解锁类型成员内部的能力。Swift 的形式在两者中更注重安全优先。通过 C# 1.0 的视角来解读 Swift 的类型级标记是错误的视角。
AI 赋能
该模型增加了 AI 代理无法忽视的两样东西:一个被划分为安全、unsafe 和边界方法的调用图;以及一个在没有包围 unsafe 块的情况下拒绝 unsafe 调用的编译器。分析器还将为缺失的 <safety> 文档贡献警告。每一项都缩小了代理可以生成且通过构建的代码范围,特别是如果设置了 TreatWarningsAsErrors。针对 MemoryMarshal.ReadByte 生成代码的代理必须要么将 unsafe 向上传播给它的调用者,要么在边界处用守卫消除它。
<safety> 文档充当每个 API 的指令。即使有了这些,生成代码的代理有时仍然会漏掉守卫,而编译器不会注意到。具有明确信息的边界仍然有帮助:它告诉人类或代码审查代理守卫必须放在哪里以及它们应该保护什么。同样的动态也适用于可空引用类型和 AOT 分析器:更严格的语法缩小了搜索空间,模型输出也随之跟进。
代理可以通过两种关键方式颠覆该模型:
- 生成无法编译的代码。
- 将项目切换回旧模型和/或启用
AllowUnsafeBlocks。这类似于代理有时想要禁用TreatWarningsAsErrors或IsAotCompatible的情况。
这两种类别都很容易在代码审查中检测到,或在 git 历史中识别出来。"在代码审查中更容易检测到"是整个倡议的标语。
迁移到新模型也非常适合代理。将现有代码迁移到模型中以及在模型内编写新代码并不是不同的活动。一旦第一批 .NET 运行时库 API 被迁移,对旧代码和新代码的一致性就变成了一项统一的任务。
在 Rust 中已经成熟的模式(unsafe fn、unsafe {})可以干净地映射到 C# 风格的代码。代理可以在现有的语料库(Rust 的标准库、Swift 的标准库)上以及在 .NET 运行时库迁移时进行模式匹配。可以说,最高价值的模式匹配是安全文档的结构和惯用法。迁移的这一方面将是最难技能化的。如前所述,安全文档是新模型最关键的部分。
相关研究也得出了相同的结论:
- CRUST-Bench: A Comprehensive Benchmark for C-to-safe-Rust Transpilation(Khatry 等人,COLM '25)发现,带有编译器反馈的代理在 C 到安全 Rust 的代码库翻译上,成功率大约是单次生成的两倍。
- Gorilla: Large Language Model Connected with Massive APIs(Patil 等人,NeurIPS '24)表明,被赋予可检索 API 文档的大语言模型在调用 API 时比无助基线更可靠,幻觉更少。
- Do Users Write More Insecure Code with AI Assistants?(Perry 等人,CCS '23)发现,使用 AI 编码助手的开发者产生的代码安全性显著低于无辅助的对照组,同时却将自己的输出评价为更安全。这正是语言级安全强制执行旨在填补的鸿沟。
- Type-Constrained Code Generation with Language Models(Mündler 等人,PLDI '25)表明,将类型系统约束推入 LLM 解码(而不是依赖事后的编译器反馈)可以将编译错误减少一半以上,并在综合、翻译和修复中提高功能正确性。更丰富的语言规则塑造生成,而不仅仅是验证它。
- MultiPL-E: A Scalable and Extensible Approach to Benchmarking Neural Code Generation(Cassano 等人,TSE '23)表明,LLM 代码生成性能跟踪的是与高资源语言的语法接近度,而不仅仅是目标语言的训练量。这就是让 Rust 的
unsafe fn/unsafe {}语料库可以迁移到 C# 风格代码的杠杆。 - LLM Assistance for Memory Safety(Rastogi 等人,ICSE '25)解决了这个问题的迁移版本:推断将遗留 C 代码 Retrofit 到 Checked C 安全方言所需的源代码级标注。他们的工具在真实代码库(最多 20K LOC)上推断出了符号工具无法推断的 86% 的标注。同样形状的工作(命名现有代码已经隐式依赖的义务)正是迁移到新 C# 模型所需要的。
结语
新模型在当前使用 unsafe 的代码之上叠加了一组(可选择启用的)破坏性变更:成员签名上的 unsafe 定义了面向调用者的契约,每次调用 unsafe 成员时都需要一个 unsafe 块,每个 unsafe 成员都应带有 /// <safety> 块。三个较小的差异完善了该模型:unsafe 类型修饰符变为错误,新的 safe 关键字标记编译器无法自行分类其安全性的 extern 声明,签名中的指针类型不再自行传播不安全性。
我们设想了一个未来,C# 将成为以其类型和内存安全强制执行而被选择和瞩目的一组语言之一。通过此次模型变更,C#、Rust 和 Swift 拥有了更共同的安全词汇和工作流程。我们想象团队对其依赖项采用完整的供应链视图,无论是全链 C# 还是应用层 C# 加上系统层 Rust。我们自己的团队多年来已将大量 C++ 迁移到 C#,正是出于这个原因:安全的 C# 不承担内存安全审查负担。
一旦团队将代码库的子集迁移到新的安全模型,很可能会有更大的动力将全部代码及其依赖项也迁移过来。这对许多开发团队来说可能比想象中更容易。新模型在很大程度上保持了 C# 的原样,只调整了大多数开发者不会接触的 unsafe 模式,同时显著提高了语言的整体安全能力和姿态。我们相信,这一特性是我们在这个编码新时代中能够做出的最具杠杆效应的改变之一,用以提升开发者的信心。
本项目得益于以下人员的贡献:Andy Gocke、Egor Bogatov、Fred Silberberg、Jan Jones、Jan Kotas、Julien Couvreur、Mads Torgersen、Rich Lander、Tanner Gooding 以及其他人。