想象你是一艘深海潜水艇的艇长。Rust 就是这艘艇的自动航行系统——它内置了一整套传感器和规则引擎(所有权、借用检查器),确保你的代码像艇身一样坚固,绝不会有数据竞争或悬空指针这类「漏水」事故。自动模式很安全,但有些任务它做不了:比如绕过体检直接读取一块原始内存、与海底的未知生物(C 库)对话、调整全船共享的仪表盘。这时候,你需要一把特殊的钥匙——unsafe。它打开驾驶舱里五个手动装置:解裸指针、调用 unsafe 函数、修改可变静态变量、实现 unsafe trait、访问联合体字段。每次用完,你都要在航行日志上签字负责,因为从手动打开发动机的那一刻起,编译器这个副舰长就把安全检查的职责全部移交给了你。

岗位关联

对 AI 从业者而言,理解 unsafe 是读懂 PyTorch、llama.cpp 等高性能推理引擎底层的钥匙,也是从「调库选手」进阶为「系统级优化者」的必经关卡。

目录
  1. 可视化演示
  2. 概览与代价
  3. 解裸指针
  4. unsafe函数
  5. 可变静态变量
  6. unsafe trait
  7. 联合体字段
  8. 最小化范围
  9. 审计验证
  10. AI 应用场景
  11. Python 演示
  12. 常见误区
  13. 面试题

可视化演示

展示Rust unsafe关键字解锁的五大超能力:解裸指针、调用unsafe函数、访问可变静态变量、实现unsafe trait、访问联合体字段,及其安全封装策略。

本节配有交互动效(进入视口后自动加载,约 3KB)。

'+captions.ptr+'

概览与代价

潜水艇的自动航行系统由三套互锁装置构成:所有权系统确保每件设备只有一个主人,借用检查器确保你借出的工具不会被别人偷偷扔掉,类型系统确保你不会把鱼雷塞进氧气阀。这三者组合起来,让编译器能够在代码跑起来之前就把「漏水隐患」全部排查干净。但总有些操作是自动系统刻意拒绝的——比如直接查看一块未经登记的原始内存,或者跟一座没有应答协议的陌生基站通信。

unsafe 关键字就是打开驾驶舱检修门的钥匙。它本身并不关闭自动航行,只是为你打开五个手动装置:解裸指针、调用 unsafe 函数、访问可变静态变量、实现 unsafe trait、访问联合体字段。进入 unsafe 块后,编译器不再替你检查内存安全,转而要求你以艇长身份亲自确认每一项操作的合法性。 这正是「五大超能力」的代价:你接管了安全检查,也就接管了出错的责任。

从编程模型上看,Rust 对一段代码安全性的定义,可以简化为「程序满足编译器施加的别名与生命周期约束」:

回到艇长的比喻:自动航行时你只管下达指令;走进检修门动手修理时,你必须亲手确认氧气阀的朝向、线路的绝缘、螺栓的扭矩。unsafe 不是让你乱来的许可,而是把「自动检查」换成「人工确认」的责任交接仪式。

Safe(P)=Aliasing(P)  ∧  Lifetime(P)  ∧  TypeCorrect(P){\displaystyle {\text{Safe}}(P)={\text{Aliasing}}(P)\;\wedge \;{\text{Lifetime}}(P)\;\wedge \;{\text{TypeCorrect}}(P)}
程序 P 是安全的,当且仅当它同时满足别名规则(同一时刻只有一个可变引用)、生命周期规则(引用不得越过数据的存活期)和类型规则。unsafe 相当于从这三个合取项中临时拿掉一个或几个,要求你手动补上等价保证。
  1. 别名规则 Aliasing一个可变引用(类似「唯一钥匙」)存在时,不得同时存在其他引用指向同一份数据。
    Aliasing(P):¬∃mut&x,&x{\displaystyle {\text{Aliasing}}(P):\neg \exists {\text{mut}}\&x,\&x}
  2. 生命周期规则 Lifetime引用的存活范围必须被包含在所指向数据的存活范围之内。
    Lifetime(P):’a⊑’b{\displaystyle {\text{Lifetime}}(P):{\text{'a}}\sqsubseteq {\text{'b}}}
  3. 类型正确 TypeCorrect操作的数据类型与声明一致,不把整数当指针用。
    TypeCorrect(P):∀e, typeof(e)∈decl(e){\displaystyle {\text{TypeCorrect}}(P):\forall e,\,{\text{typeof}}(e)\in {\text{decl}}(e)}

解裸指针

驾驶舱里有一排被透明防护罩盖住的接线端子——它们就是裸指针 *const T 和 *mut T。自动模式下,你只能通过按钮(引用 &T)操作设备:按下按钮,系统帮你找到对应线路,松手就断开。但有些底层场景,比如与 C 库交换原始内存地址、手写内存池,你必须掀开防护罩,直接捏住那根裸露的铜线。

裸指针与引用的本质区别在于:引用保证「这根铜线永远接着有效的设备,且不会同时被两个人拧」,而裸指针只是一根铜线的坐标。编译器不再帮你验证它是否还连着设备、是否还处于有效电压范围内——这一切都要你自己在解引用(捏住铜线)之前确认。

解引用裸指针的语法非常简单,但隐含的义务不简单:

回到接线端子的画面:掀开防护罩前,你必须用万用表量过电压、确认线缆没有裸露在外的破损。Rust 团队有一条经验法则——永远不要在 unsafe 块里执行超过一行「确认逻辑」的操作,把裸指针的创建与解引用分开,让每一次危险动作都有据可查。

∗const T→unsafe  的  解引用&T    需满足: 非空、内存已分配、未悬空{\displaystyle *{\text{const}}\,T \xrightarrow {{\text{unsafe}}\;{\text{的}}\;{\text{解引用}}} \&T \;\; \text{需满足: 非空、内存已分配、未悬空}}
将裸指针 *const T 转换为引用 &T 的过程必须在 unsafe 块中进行,并且需要你手动保持三个条件:指针非空、指向的内存仍然有效、没有悬空。任何一条不满足,程序就会像潜水艇撞上暗礁一样——未定义行为。
  1. 转换操作符*const T 表示「指向 T 的只读裸指针」,*&* 表示解引用后再取引用。
    p:∗const T  ⇒  p∗{\displaystyle p: *{\text{const}}\,T \;\Rightarrow\; p^* }
  2. 非空条件指针不得为 null,否则解引用就是读取地址 0。
    p≠null{\displaystyle p\neq {\text{null}}}
  3. 有效条件指针指向的内存必须仍然归本程序所有,且类型正确。
    valid(p)  ∧  align(p){\displaystyle {\text{valid}}(p)\;\wedge \;{\text{align}}(p)}

unsafe函数

有些设备一旦启动就进入高能状态,比如应急动力系统的反应堆——自动系统拒绝一键启动,因为启动后的安全完全取决于启动前的准备。Rust 里这种设备就是 unsafe fn:一个标注了 unsafe 的函数。调用它不需要在 unsafe 块里,但调用者必须亲自保证函数文档里列出的前置条件。

比如标准库的 slice::from_raw_parts,它接受一个指针和一个长度,直接为你构造出一个切片。它的前提条件包括:指针必须非空且对齐、长度对应的内存必须已初始化、整个生命周期内该内存不得被并发写。这些条件编译器无法验证,所以这个函数被标注为 unsafe——调用它,就像你在艇长日志上签字确认「反应堆冷却水已注满」。

函数调用的安全问题可以形式化为:

用反应堆来记忆:unsafe fn 的文档注释就是操作规程,调用前逐条核对是你的义务。标准库中大量 unsafe fn 的典型应用是性能临界区——例如从已对齐的缓冲区构造切片、绕过边界检查直接索引,为 AI 推理引擎省下每一次内存访问的验证开销。

SafeCall(f,args)≡⋀i=1nPrei(argi){\displaystyle {\text{SafeCall}}(f, {\text{args}}) \equiv \bigwedge_{i=1}^{n} {\text{Pre}}_{i}(arg_{i})}
安全地调用一个 unsafe 函数 f,等价于同时满足它声明的全部 n 条前置条件。每一条都需要调用者自己验证,编译器不代劳。
  1. 前置条件集合每个 unsafe 函数必须用文档注释列出所有调用前提。
    {Pre1,Pre2,…,Pren}{\displaystyle \{{\text{Pre}}_{1}, {\text{Pre}}_{2}, \dots , {\text{Pre}}_{n}\}}
  2. 合取关系所有条件必须同时成立,缺一条就可能引发 UB。
    ⋀i Prei(argi){\displaystyle \bigwedge_{i}\,{\text{Pre}}_{i}(arg_{i})}
  3. 义务转移条件不满足时调用,责任在调用者而非函数作者。
    UB  if  ∃i:¬Prei{\displaystyle {\text{UB}}\;{\text{if}}\;\exists i:\neg {\text{Pre}}_{i}}

可变静态变量

驾驶舱最显眼的位置挂着一块全船共享的气压表——任何舱室都能读数,理论上任何舱室也都能调整它。在 Rust 里,static mut 就是这样一块全局共享的可变仪表。自动系统绝不允许这种装置存在,因为如果两个线程同时拧动这块表,你根本无法判断最终读数是谁拧出来的——这就是数据竞争。

声明一个可变静态变量本身不需要 unsafe,但读取或写入它的值必须在 unsafe 块中。因为编译器无法追踪所有线程对它的访问顺序。数据竞争是 Rust 安全系统最深恶痛绝的未定义行为之一,它比单纯的内存泄漏更危险——可能导致读取到半更新状态、指令重排引发的逻辑错乱。

它的风险模型可以写成:

在 AI 场景中,全局缓存计数器、推理批次统计这类跨线程共享状态,几乎总能找到比 static mut 更安全的替代品:AtomicUsize 处理计数、OnceLock 处理一次性初始化、Mutex<Option<T>> 处理复杂可变状态。不到万不得已,不要拧那块共享气压表。

Race  ⟺  ∃i,j, Threadiwrite  ∥  Threadjread/write{\displaystyle {\text{Race}} \iff \exists i,j,\, {\text{Thread}}_{i}{\text{write}}\; \parallel \; {\text{Thread}}_{j}{\text{read/write}}}
当且仅当存在两个线程,一个在写而另一个在读或写同一个可变静态变量、且没有同步机制时,就发生了数据竞争。unsafe 只是打开了访问闸门,并没有为你加锁。
  1. 写与读并发两个线程对同一地址的访问在时间轴上重叠。
    writei∥readj{\displaystyle {\text{write}}_{i} \parallel {\text{read}}_{j}}
  2. 无序访问访问之间没有 happens-before 关系,无法确定先后顺序。
    ¬(hb(i,j)∨hb(j,i)){\displaystyle \neg ({\text{hb}}(i,j)\vee {\text{hb}}(j,i))}
  3. 结果不确定最终读取值取决于调度器,导致逻辑不可复现。
    val=undefined{\displaystyle {\text{val}}={\text{undefined}}}

unsafe trait

潜水艇上有些特种设备需要额外资质才能操作,比如深潜服——只有经过训练的蛙人才能穿。Rust 用 unsafe trait 来表达这类「需要实现者亲自保证安全契约」的能力标记。实现一个 unsafe trait,就等于你向编译器宣誓:「我保证我的类型满足这条 trait 的全部安全义务。」

最典型的是 Send 和 Sync。Send 表示「这个类型的值可以安全地转移到另一个线程」;Sync 表示「这个类型的值可以被多个线程同时借用来访问」。编译器会自动为绝大多数类型推导这两种 trait,但如果你手写了一个包含裸指针的结构体,编译器就无法自动判断——它不知道你的裸指针背后是否藏着非线程安全的数据。

unsafe trait 的实现义务可以表示为:

用蛙人来记忆:穿上深潜服前,你要保证自己的训练合格、装备无破损。实现 unsafe trait 不是在告诉编译器「我很安全」,而是在告诉编译器「我把安全责任全部包下了,你不用再检查」。这比任何普通 trait 都严肃——所以 Send 和 Sync 的文档被称为「写给编译器看的宣誓书」。

ImplSafe(T)≡Invariant1(T)∧Invariant2(T)∧⋯∧Invariantk(T){\displaystyle {\text{ImplSafe}}(T) \equiv {\text{Invariant}}_{1}(T)\wedge {\text{Invariant}}_{2}(T)\wedge \cdots \wedge {\text{Invariant}}_{k}(T)}
为类型 T 实现 unsafe trait,相当于承诺 T 同时满足该 trait 文档中列出的全部不变式。例如实现 Send 时,承诺 T 内部不含任何共享可变状态。
  1. 义务清单每个 unsafe trait 的文档中都列明实现者的责任条款。
    {Invariant1(T),…,Invariantk(T)}{\displaystyle \{{\text{Invariant}}_{1}(T),\ldots,{\text{Invariant}}_{k}(T)\}}
  2. 全称承诺所有条款必须为真,编译器会假设它们成立。
    ∀i: Invarianti(T){\displaystyle \forall i:\,{\text{Invariant}}_{i}(T)}
  3. 违反后果承诺被打破时,使用者可能遭遇数据竞争甚至崩溃。
    ⊤(T)⊢Safe(T){\displaystyle \top (T)\vdash {\text{Safe}}(T)}

联合体字段

潜水艇的储物舱里有几种多功能隔间:同一个空间,既可以存放压缩食品(按字节解读),也可以存放医疗包(按另一种格式解读)。Rust 的 union 就是这样的内存视角转换器——它在同一块内存上定义了多个字段类型,但同一时刻只有一个字段的值被「认为是有效的」。

与 enum 不同,union 没有标签来记录当前存的是哪一种类型。你得自己记住上次写入的是哪个字段。读取任何字段都被视为潜在危险,因为编译器无法确认这块内存当前的有效类型。如果按错误类型解读数据,就像把压缩食品包装当医疗包打开——里面的东西完全不可预期。

联合体的大小由最大字段决定,访问规则可以表述为:

联合体在 AI 底层主要出现在 FFI 场景:与 C 库交换「多态数据」时,C 侧经常用 union 表达不同类型的数据载荷。安全 Rust 中几乎总是优先用 enum——它用额外的一个字节记录当前类型标签,把「我自己记住」变成「编译器帮我记住」,代价仅仅是几字节内存。

size(U)=max⁡i  size(Fi),read(Fi)  requires  unsafe{\displaystyle {\text{size}}(U)=\max_{i}\;{\text{size}}(F_{i}),\qquad {\text{read}}(F_{i})\;{\text{requires}}\;{\text{unsafe}}}
联合体 U 的大小等于其所有字段 F 中最大的那个的大小;而读取任一字段都必须在 unsafe 块中进行,因为当前有效类型只有你自己知道。
  1. 大小规则所有字段共享同一块起始地址,总大小按最大字段分配。
    size(U)=max⁡{s1,s2,…,sn}{\displaystyle {\text{size}}(U)=\max \{s_{1},s_{2},\dots ,s_{n}\}}
  2. 写入规则写入某个字段是安全的,因为写操作定义了该块内存的新类型。
    ¬unsafe(write(Fi)){\displaystyle \neg {\text{unsafe}}({\text{write}}(F_{i}))}
  3. 读取规则读取前必须由程序员保证最近一次写入的是同一字段。
    unsafe(read(Fi)){\displaystyle {\text{unsafe}}({\text{read}}(F_{i}))}

最小化范围

手动驾驶最危险的地方在于操作面太大——如果整个检修舱都敞开着,你很难盯住每一个阀门。Rust 社区的经验法则是:让 unsafe 块尽可能小,最好小到只有一两行。与其把整个函数标为 unsafe,不如只在真正需要解除检查的那一行包上 unsafe 块,其余部分继续享受自动安全检查。

这个原则可以形式化为「危险暴露面与 unsafe 代码量成正比」:

典型的安全封装模式是:内部函数处理裸指针的一切细节,外部函数只接受普通引用并返回安全类型。这样调用者永远接触不到 unsafe,而出 bug 时你只需要审查那一个块——把检修舱压缩成只能伸进一只手的小窗口,盯起来就轻松多了。

Risk∝unsafeLines×APISurface{\displaystyle {\text{Risk}} \propto {\text{unsafeLines}} \times {\text{APISurface}}}
代码的风险等级与 unsafe 代码行数及对外暴露的安全接口面积成正比。unsafe 行数越少,能出错的地方就越少;同时,如果对外只暴露安全函数,调用者完全感知不到底层的危险操作。
  1. 代码行数因子unsafe 块内的每一行都需要人工验证。
    unsafeLines=count(unsafe-block){\displaystyle {\text{unsafeLines}}={\text{count(unsafe-block)}}}
  2. 接口面积因子公开的 API 边界越大,可能被误用的入口越多。
    APISurface=pub fn count{\displaystyle {\text{APISurface}}={\text{pub}}\,{\text{fn}}\,{\text{count}}}
  3. 风险递减策略把 unsafe 藏进最内层,用安全函数包装。
    Risk→min⁡  ⟺  unsafeLines→1,  API→safe{\displaystyle {\text{Risk}}\to \min \iff {\text{unsafeLines}}\to 1,\;{\text{API}}\to {\text{safe}}}

审计验证

手动驾驶不等于盲目驾驶。优秀的艇长每次手动操作后都会进行系统自检:先用仪器扫描一遍,再亲自复查关键节点。Rust 生态为 unsafe 代码提供了同样分层级的验证工具链。

第一层是 Miri——Rust 官方提供的「未定义行为探测器」。它通过解释执行代码,精确追踪每次内存访问是否违反安全规则,能捕获悬空指针、对齐错误、未初始化读取等问题。第二层是 Sanitizer 系列(如 AddressSanitizer、ThreadSanitizer),在编译时插入检查代码,运行时捕捉越界访问和数据竞争。第三层才是人工审查——逐行核对 unsafe 块的前置条件。

验证的深度也可以看作一个递进链:

用潜水艇来收尾:Miri 就像声呐扫描整片海域,ASan 像实时监测船体应力,人工审查则是艇长拿着对照表亲手拧一遍关键螺栓。三者配合,才能把手动驾驶的风险压到最低。 这也是为什么 Rust 标准库的 unsafe 代码经过二十年锤炼后,仍能保持极高可信度——每一行都经过了这三级检查的洗礼。

Assurance:  Miri  →  ASan/TSan  →  CodeReview  →  FormalProof{\displaystyle {\text{Assurance}}:\;{\text{Miri}}\;\to \;{\text{ASan}}/{\text{TSan}}\;\to \;{\text{CodeReview}}\;\to \;{\text{FormalProof}}}
从无到有的安全证明分为四个层级:Miri 动态解释、运行时的内存/线程消毒器、人工代码审查、以及形式化证明。AI 工程师至少应做到前两层,涉及核心内存池的代码应到达第三层。
  1. 动态检测层Miri 在解释器里逐指令验证未定义行为。
    Miri:bytecode⇒UB-free?{\displaystyle {\text{Miri}}: {\text{bytecode}}\Rightarrow {\text{UB-free?}}}
  2. 运行时消毒层ASan/TSan 在编译产物中插桩监测。
    {\displaystyle {\text{ASan}}: {\text{mem-access}}\Rightarrow {\text{valid}}}}
  3. 人工审查层对照 unsafe 文档的契约清单逐条打勾。
    Review:preconditions⇒checked{\displaystyle {\text{Review}}: {\text{preconditions}}\Rightarrow {\text{checked}}}

AI 应用场景

高性能推理引擎的内存池

阶段:模型部署与推理优化

解决的问题:AI 推理(如 llama.cpp 的 KV cache)需要频繁为 token 缓存分配和释放内存块。传统 safe Rust 每次分配都要经过所有权检查和潜在边界检查,在每 token 毫秒级延迟的推理路径上开销不可忽略。用 unsafe 裸指针实现内存池,可以在初始化时一次性申请大块内存,后续分配只用指针偏移计算,避免运行时检查。

具体案例:llama.cpp 的 KV cache 管理器用一个 static mut 风格的全局缓存池记录每个 token 的 key/value 张量位置,推理循环中通过裸指针直接读写缓存块。这些 unsafe 操作被封装在 CachePool::insert 和 CachePool::get 两个安全方法内,外部推理代码完全感知不到指针的存在。

深度学习框架的 C FFI 绑定

阶段:框架底层开发与算子实现

解决的问题:PyTorch 等框架需要调用 cuBLAS、cuDNN 等 C 语言高性能库。Rust 绑定层必须将 Rust 的张量数据指针传给 C 函数——这个过程中创建裸指针、在 C 侧写数据后再转回切片,全部依赖 unsafe。正确封装后,上层 Rust 代码保持完全安全。

具体案例:tch-rs 项目在调用 ATen 库时,用 unsafe 块将 Tensor 内部数据指针传给 C++ 侧,并通过安全包装函数保证「调用期间张量不被释放、不被并发修改」。所有 unsafe fn 的文档都明确列出了指针有效期和别名约束。

Python 演示

python65 行
# 模拟 Rust unsafe 最小化原则的 Python 演示# 思路:把 "危险操作" 隔离在私有方法中,对外只暴露安全接口  class MemorySlab:    """模拟 Rust 中基于裸指针的内存池     内部 _raw_* 方法相当于 unsafe 块中的裸指针操作,    外部只能通过安全方法 allocate/store/load/free 访问。    """     def __init__(self, capacity):        self._storage_map = {}  # 模拟原始内存地址空间        self._capacity = capacity     def _raw_write(self, addr, value):        # 模拟 unsafe: 直接写内存,不做任何检查        self._storage_map[addr] = value     def _raw_read(self, addr):        # 模拟 unsafe: 直接读内存,不验证是否已释放        return self._storage_map.get(addr)     def allocate(self):        # 安全接口:扫描空闲地址,相当于内存池的分配器        for addr in range(self._capacity):            if addr not in self._storage_map:                return addr        raise MemoryError("内存池已满")     def store(self, addr, value):        # 安全接口:写入前检查地址仍有效(防止 use-after-free)        if addr not in self._storage_map:            raise RuntimeError("use-after-free: 向已释放的地址写入")        self._raw_write(addr, value)     def load(self, addr):        # 安全接口:读取前检查地址仍有效        if addr not in self._storage_map:            raise RuntimeError("use-after-free: 读取已释放的地址")        return self._raw_read(addr)     def free(self, addr):        # 安全接口:释放前检查地址确实存在(防止 double free)        if addr not in self._storage_map:            raise RuntimeError("double free: 重复释放")        del self._storage_map[addr]     def demo(self):        # 演示整个流程:分配 -> 写入 -> 读取 -> 释放 -> 错误捕获        a = self.allocate()        print(f"[安全] 分配地址 {a}")        self.store(a, 42)        print(f"[安全] 写入 42,读取得到 {self.load(a)}")        self.free(a)        print(f"[安全] 释放地址 {a}")        try:            self.load(a)        except RuntimeError as exc:            print(f"[拦截] 释放后再读取被拒绝: {exc}")  if __name__ == "__main__":    slab = MemorySlab(8)    slab.demo()
目的:用 Python 对象模拟 Rust 的内存池安全封装——危险操作(_raw_*)被私有化,外部只能调用安全方法,完美复刻「最小化 unsafe 范围」的设计思想。
  1. class MemorySlab对应 Rust 中一个安全封装的内存池结构体。
  2. self._storage_map = {}模拟内存地址空间,键为地址、值为数据。
  3. def _raw_write(self, addr, value)模拟 unsafe 块中的裸指针写操作,不做任何检查。
  4. def _raw_read(self, addr)模拟 unsafe 中的裸指针读,可能读到已释放数据。
  5. def allocate(self)安全接口,相当于内存池分配器,返回一个可用地址。
  6. if addr not in self._storage_map:这是关键安全检查,等价于 Rust 安全包装中的「有效性验证」。
  7. raise RuntimeError("use-after-free")模拟 Rust 中依赖安全检查拦截未定义行为。
  8. def demo(self)演示完整的分配-使用-释放生命周期。
  9. slab.demo()程序入口,执行整个安全流程演示。

常见误区

误区「只要函数里出现 unsafe 关键字,整个函数的所有操作都不再受编译器保护,我必须手动检查每一行代码。」

正解实际上,只有 unsafe {} 块内部的操作才免除编译器的检查,块外的代码依然享有全部安全保障。这就像驾驶舱里只有一个红色旋钮需要手动操作,舱内其他自动仪表仍在正常工作。你只需要为旋钮的转向负责,不需要重新检查整艘船的电路。

为何常见:因为 unsafe 关键字的视觉冲击力太强,加上很多教程把「unsafe 函数」和「unsafe 块」混为一谈,容易让人误以为整个作用域都进入了无人监管状态。

误区「我创建了一个裸指针,并用它修改了数据,借用检查器应该能帮我发现悬空指针或重复释放的问题。」

正解裸指针完全不受借用检查器的约束。你可以同时创建多个 *mut T、可以随意拷贝、可以在数据释放后仍然持有。编译器对这些行为保持沉默——这正是需要 unsafe 的原因。引用像系着安全绳的攀岩者(绳子的另一端在检查员手里),裸指针则是徒手攀岩者(你自己的手就是安全绳)。

为何常见:因为裸指针和引用的语法非常接近(*const T 与 &T),初学者容易默认它们遵循同一套规则,忽略了 * 前缀背后「脱离监管」的含义。

误区「我需要在多个线程间共享一个全局计数器,用 `static mut` 加 unsafe 块读写就行,反正我小心点就不会有数据竞争。」

正解数据竞争是未定义行为的一种,即使你「小心」,编译器和 CPU 也可能对你的访问进行重排,导致你精心设计的逻辑依然出错。正确做法是用 AtomicUsize 或 Mutex 这类同步原语,它们是安全的、编译器能验证的。手动拧共享气压表(static mut)之前,先确认你确实买不到更好的仪表(原子类型)。

为何常见:很多 C 语言背景的开发者习惯了「全局变量 + 锁」甚至「全局变量不加锁」的写法,初学 Rust 时自然想用最熟悉的工具,而 static mut 看起来就是最接近的对应物。

误区「实现 `unsafe trait Send` 不就是写一行 `unsafe impl Send for MyType {}` 吗?我写上就行,编译器又不会检查我。」

正解unsafe trait 的核心在于「编译器不检查,但你必须保证」。实现 Send 意味着你向编译器承诺:这个类型的所有字段在跨线程转移时不会引发数据竞争。如果承诺是假的——比如你悄悄藏了一个 Rc 或裸指针——那使用者的程序会在运行时莫名其妙崩溃。这就像签署安全责任书:不用考试,但出了事故你负责。

为何常见:因为 unsafe trait 的实现体往往是空的,从语法上看「好像啥也没干」,初学者自然低估了它的效力,忽略了文档中列出的义务清单。

误区「只要把热点循环里的代码包进 unsafe,性能就会提升,因为跳过了所有检查。」

正解unsafe 只会移除编译器的安全检查,不会改变代码本身的执行效率。边界检查在多数情况下会被 LLVM 优化掉(比如迭代器的 zip、get_unchecked 替代品),实际性能提升往往微乎其微。真正值得用 unsafe 的场景是:调用 C 库 FFI、实现自定义内存布局、需要并发无锁结构。为了「觉得快」而用 unsafe,是拿安全性换幻觉。

为何常见:因为「跳过检查 = 更快」的直觉很强烈,加上一些博文夸大 unsafe 的性能收益,导致大家忽略了编译器的优化能力。

面试题

Q1. 在 Rust 中,unsafe 关键字的核心作用是什么?

单选题 · easy

  • A.关闭编译器并加快程序运行速度
  • B.解除部分安全检查并转移责任给开发者← 正确答案
  • C.允许使用任何未定义的语法特性
  • D.将代码标记为仅供内部测试使用

答案:B。unsafe 并不是关闭编译器或加速,而是临时豁免借用检查等安全限制,同时把保证内存安全的责任交给开发者。A 错在「关闭编译器」,C 错在「未定义语法」,D 是误把 unsafe 当成了测试标记。它本质上是安全责任的交接仪式。

Q2. 下列哪一项不属于 Rust unsafe 的「五大超能力」?

单选题 · easy

  • A.解引用裸指针
  • B.调用 unsafe 函数
  • C.自动实现内存对齐← 正确答案
  • D.访问 union 的字段

答案:C。五大超能力是:解裸指针、调用 unsafe 函数、访问可变静态变量、实现 unsafe trait、访问联合体字段。内存对齐是编译器自动处理的职责,不属于 unsafe 解锁的能力。它像潜水艇的自动配平系统,不需要艇长手动操作。

Q3. 裸指针 *const T 与引用 &T 的最大区别是什么?

单选题 · mid

  • A.裸指针更快但占用更多内存
  • B.裸指针不受借用检查器约束← 正确答案
  • C.引用只能指向栈上的数据
  • D.裸指针必须手动释放内存

答案:B。裸指针与引用的本质区别在于借用检查器的监管范围:引用必须遵守所有权与借用规则,裸指针则完全不在监管内。A 中「更快」无依据,C 中引用同样能指向堆数据,D 中裸指针不负责内存释放——它只是地址坐标,不是分配器。

Q4. 调用标准库的 slice::from_raw_parts 时,调用者需要自己保证什么?

单选题 · mid

  • A.指针非空、内存有效且已初始化← 正确答案
  • B.切片长度是偶数
  • C.数据必须存放在栈上
  • D.数据的所有权属于当前线程

答案:A。slice::from_raw_parts 是 unsafe fn,要求调用者保证指针非空、对齐、指向的内存已初始化且在生命周期内有效。这就像手动启动反应堆前确认冷却水已注满。B、C、D 都不是该函数的实际前置条件。

Q5. 关于 static mut,下列说法正确的有哪些?

多选题 · mid

  • A.读写它都需要 unsafe 块← 正确答案
  • B.多线程访问必然导致数据竞争
  • C.可以用 AtomicUsize 替代计数场景← 正确答案
  • D.它比 static 变量占用更少内存

答案:AC。static mut 的读写必须在 unsafe 块中(A 对);多线程访问如果没有任何同步则构成数据竞争,但「必然」过于绝对(B 错);原子类型是安全替代方案(C 对);内存占用与是否可变无关(D 错)。它就像共享气压表,每次拧动都要签字,能不用就不用。

Q6. 实现 unsafe trait 时,编译器对实现者的要求是什么?

单选题 · hard

  • A.编译器会逐字段检查安全性
  • B.实现者必须保证 trait 文档中的不变式← 正确答案
  • C.必须在 unsafe 块内编写实现体
  • D.实现体必须为空且只含标记

答案:B。unsafe trait 的语义是「信任声明、责任自担」——编译器不做检查,转而要求实现者保证文档中声明的每条安全不变式。A 错在编译器不检查;C 错在实现体本身不需要 unsafe 块;D 错在实现体可以包含方法定义。这像签署安全责任书:不考试,但出了事故你负责。

Q7. 与 enum 相比,union 最主要的缺点是什么?

单选题 · mid

  • A.占用更多内存空间
  • B.没有标签记录当前有效类型← 正确答案
  • C.无法存储整数类型的数据
  • D.不能用于结构体内部

答案:B。union 所有字段共享同一块内存,但没有判别标签来记录当前存的是哪个字段——读取时必须自己记住并写进 unsafe。enum 用额外字节记录标签,换取编译器的类型安全。A 反了(union 更省内存),C、D 均不符合事实。

Q8. 以下哪些操作属于 Rust unsafe 的「五大超能力」?

多选题 · easy

  • A.解引用裸指针← 正确答案
  • B.访问可变静态变量← 正确答案
  • C.自动推导泛型类型
  • D.实现 unsafe trait← 正确答案

答案:ABD。五大超能力为:解裸指针、调用 unsafe 函数、访问可变静态变量、实现 unsafe trait、访问联合体字段。A、B、D 都在其中;C「自动推导泛型」是类型推断的正常功能,与 unsafe 无关。

Q9. 最符合 Rust 社区「最小化 unsafe」原则的写法是?

单选题 · hard

  • A.把整个函数声明为 unsafe fn
  • B.将危险操作收进小块并封装安全接口← 正确答案
  • C.在所有代码前加上 unsafe 关键字
  • D.禁用编译器的安全检查

答案:B。最小化原则的核心是:unsafe 块尽可能小、危险细节藏在最里层、对外暴露安全 API。这样调用者感知不到危险,审查者只需盯住一小块代码。A 扩散了 unsafe 面,C 是所有操作都豁免检查,D 是关闭编译器的错误做法。

Q10. 哪些工具可以用于验证 Rust unsafe 代码的正确性?

多选题 · mid

  • A.Miri 未定义行为检测器← 正确答案
  • B.AddressSanitizer 内存消毒器← 正确答案
  • C.rustfmt 代码格式化工具
  • D.ThreadSanitizer 数据竞争检测器← 正确答案

答案:ABD。Miri 在解释器中检测未定义行为,ASan 和 TSan 在运行时捕获内存错误与数据竞争,都属于 unsafe 验证工具链。rustfmt 只负责格式化代码风格,不检测任何安全问题。这就像声呐、应力监测、人工复查的分工——格式化工具只是把日志写得整齐,不负责发现漏水。