阶段四 · 全局状态

全局变量与延迟初始化

一句话总结

Rust 的全局常量用 const, 全局静态值用 static.
可变的全局状态在 Rust 里是"危险品"--因为它天然违背借用规则(谁都能改, 无法保证独占).
现代 Rust 用 OnceLock / LazyLock 安全地实现"全局单例 + 延迟初始化", 避开了裸 static mut 的陷阱.

const 与 static: 两种全局值

先分清两个最基础的全局声明:

const MAX_USERS: u32 = 10_000;          // 编译期常量
static GREETING: &str = "Hello"; // 静态变量
const static
本质 编译期常量, 使用处内联(像每处都复制一份) 程序中唯一的内存位置, 有固定地址
生命周期 无固定地址, 概念上"无处不在" 'static, 活整个程序
可变性 永远不可变 默认不可变; static mut 可变但 unsafe
类比 Go Go 的 const Go 的包级 var(但默认不可变)

绝大多数"全局只读配置"用 const 即可. static 用在需要"唯一固定地址"的场景(比如要取它的引用).

为什么"全局可变状态"在 Rust 里很难

这是本篇的思想核心. 在 Go 里, 一个包级 var counter int 谁都能读写, 稀松平常(并发下自己加锁). 但在 Rust 里, 这种"全局可变变量"直接撞上借用规则的铁律:

全局可变 = 天然的'共享 + 可变'

一个可变全局变量, 意味着程序里任何地方都能拿到它的可变引用--这正是借用规则要禁止的"既共享又可变".

更要命的是在多线程下: 多个线程同时改一个全局变量就是赤裸裸的数据竞争. 所以裸的 static mut 在 Rust 里访问它必须用 unsafe, 编译器等于在说: "我没法保证它安全, 自己担责."

static mut COUNTER: u32 = 0;

fn increment() {
unsafe { // 必须 unsafe -- 编译器无法保证无数据竞争
COUNTER += 1;
}
}
// 现代 Rust 几乎从不这样写, unsafe 全局可变是最后手段

static mut 像一栋楼里没有任何门锁的公共储物间: 谁都能进, 谁都能改, 还可能两个人同时伸手.
Rust 看到它就亮红灯(unsafe), 逼着换一种"带门禁"的方案.
下面这些工具, 就是给全局状态装上门禁.

OnceLock: 安全的全局单例

真实需求往往不是"频繁修改的全局变量", 而是"全局只初始化一次, 之后只读"的单例--比如全局配置, 连接池, 正则表达式. std::sync::OnceLock 正为此设计: 它是一个"只能被写入一次"的容器, 且线程安全.

use std::sync::OnceLock;

static CONFIG: OnceLock<String> = OnceLock::new();

fn get_config() -> &'static String {
// get_or_init: 首次调用时初始化, 之后直接返回已有值(线程安全)
CONFIG.get_or_init(|| {
println!("初始化配置(只会打印一次)");
String::from("loaded config")
})
}

// 多次, 多线程调用 get_config(), 初始化闭包只执行一次

对应 Go 的 sync.Once

OnceLock + get_or_init 的语义, 正是 Go 里 sync.Once + 包级 var 的组合: 保证某段初始化逻辑在并发下恰好执行一次.

区别是 Rust 把"只写一次"做进了类型--拿不到它的可变引用, 只能 init 一次, 之后只读, 从类型上杜绝了"初始化后被意外篡改".

LazyLock: 声明即带初始化逻辑

如果初始化逻辑固定, LazyLock(Rust 1.80+ 稳定)更简洁--它把初始化闭包直接写在声明处, 首次访问时自动执行:

use std::sync::LazyLock;
use std::collections::HashMap;

// 声明时就绑定初始化逻辑, 首次解引用时才真正执行
static TABLE: LazyLock<HashMap<&str, i32>> = LazyLock::new(|| {
let mut m = HashMap::new();
m.insert("one", 1);
m.insert("two", 2);
m
});

fn main() {
println!("{:?}", TABLE.get("one")); // 首次访问触发初始化
println!("{:?}", TABLE.get("two")); // 复用已初始化的值
}

选型小结

  • 编译期常量const
  • 全局只读, 初始化逻辑固定LazyLock(最省心)
  • 全局只读, 初始化需运行期参数/时机OnceLock + get_or_init
  • 全局可变 → 用 staticMutex/RwLock/原子类型(见下), 不要static mut

历史上社区曾长期依赖第三方库 lazy_static / once_cell 来做这些, 如今标准库的 OnceLock/LazyLock 已能覆盖绝大多数场景, 优先用标准库.

确实需要全局可变: 加锁或用原子

如果真的需要可变的全局状态, 正确做法是把它包进线程安全的内部可变容器--这正好复用了第 10 篇的工具:

use std::sync::Mutex;

// 全局可变: static + Mutex, 安全且无需 unsafe
static COUNTER: Mutex<u32> = Mutex::new(0);

fn increment() {
*COUNTER.lock().unwrap() += 1; // 加锁修改, 线程安全
}

// 若只是简单计数, 还可用原子类型(更轻量):
use std::sync::atomic::{AtomicU32, Ordering};
static ATOMIC_COUNTER: AtomicU32 = AtomicU32::new(0);
fn bump() {
ATOMIC_COUNTER.fetch_add(1, Ordering::Relaxed); // 无锁原子操作
}

闭环: 全局状态也逃不开 Sync

注意: 能放进 static 的类型必须是 Sync(第 09 篇)--因为全局变量天然可被多线程访问.

Mutex, 原子类型都是 Sync 的, 所以能用; 而 RefCell 不是 Sync, 不能放进 static. 这再次印证: Send/Sync 是贯穿 Rust 并发的那条主线, 连全局状态都由它把关.

快速回顾

  • const vs static: const 是内联的编译期常量; static 是有唯一固定地址, 'static 的静态变量.
  • 全局可变很难: 它天然是"共享 + 可变", 违背借用规则; 裸 static mutunsafe, 应避免.
  • OnceLock: 只写一次的线程安全容器, get_or_init 实现单例, 对应 Go 的 sync.Once.
  • LazyLock: 声明处绑定初始化逻辑, 首次访问触发, 最省心; 优先用标准库而非旧的 lazy_static.
  • 真要全局可变: 用 static + Mutex/RwLock/原子类型, 绝不用 static mut.
  • Sync 把关: 放进 static 的类型必须是 Sync, 全局状态也被 Send/Sync 这条主线约束.

动手练习

  1. 说出 const MAX: u32 = 100static MAX: u32 = 100 的两点区别.
参考答案
  1. 内存: const 没有固定地址, 使用处被内联(概念上每处复制一份); static 在程序里有唯一固定的内存地址, 可以取它的引用 &MAX.
  2. 生命周期: static 的生命周期是 'static(活整个程序), const 不是"一个有地址的值", 不谈生命周期.

日常全局只读配置多数用 const 就够, 需要稳定地址/引用时才用 static.

  1. 为什么 static mut COUNTER: u32 = 0; 的访问必须放在 unsafe 块里?
参考答案

因为可变全局变量天然是"共享 + 可变"--程序任何地方都能拿到它的可变引用, 这违背借用规则; 在多线程下, 多个线程同时读写它就是数据竞争. 编译器无法静态保证对它的访问安全, 所以要求用 unsafe 把责任显式交给程序员.

现代 Rust 几乎从不用 static mut, 而用 static + Mutex/原子类型替代.

  1. LazyLock 定义一个全局的, 延迟初始化的 Vec<i32>(内容为 1..=5), 并在 main 中打印它的和.
参考答案
use std::sync::LazyLock;

static NUMBERS: LazyLock<Vec<i32>> = LazyLock::new(|| {
(1..=5).collect()
});

fn main() {
let sum: i32 = NUMBERS.iter().sum(); // 首次访问触发初始化
println!("{sum}"); // 15
}

初始化闭包只在首次访问 NUMBERS 时执行一次, 之后复用. 线程安全由 LazyLock 保证.

  1. 为什么可以把 Mutex<u32> 放进 static, 却不能放 RefCell<u32>?
参考答案

因为 static 变量天然可被多个线程访问, 要求其类型是 Sync(第 09 篇). Mutex 是 Sync(它用锁保证多线程安全访问), 所以能放; RefCell 不是 Sync(它的借用记账非线程安全), 编译器会拒绝把它放进 static.

这说明全局状态同样被 Send/Sync 这条主线把关--需要全局可变就用 Mutex 或原子类型这类 Sync 的内部可变容器.

  1. 需求: 实现一个全局的, 首次使用时才初始化的配置对象, 且初始化需要读取一个运行期才知道的值. 该选 LazyLock 还是 OnceLock? 为什么?
参考答案

OnceLock + get_or_init. 因为初始化依赖"运行期才知道的值"--LazyLock 的初始化闭包在声明处就写死了, 拿不到运行期参数; 而 OnceLock 把初始化推迟到调用 get_or_init(|| ...) 时, 闭包里可以捕获运行期得到的数据.

规律: 初始化逻辑/数据在编译期就固定 → LazyLock(更简洁); 初始化需要运行期的参数或时机 → OnceLock(更灵活).

进阶系列完成

本系列走完了 Rust 最硬核的一段: 生命周期, 智能指针, 并发三大支柱, 以及它们背后"所有权规则贯穿始终"的设计思想.

现在能读懂复杂的 borrow checker 报错, 能为共享数据选对工具, 能从设计层面理解 Send/Sync 为何让并发"无畏". 接下来可进入**「Rust 知识点突破」查漏补缺, 或挑战「异步 Rust 与 Tokio」**--它正建立在本系列的 Send/Sync 与生命周期之上.