如何在 Rust 中以惯用方式使用全局变量

如何在 Rust 中以惯用方式使用全局变量

在 Rust 中声明和使用全局变量可能比较棘手。Rust 通常通过强制我们非常明确地定义全局变量来确保其健壮性。

在本文中,我将讨论 Rust 编译器想要避免的陷阱。然后,我将向您展示针对不同场景的最佳解决方案。

关键要点

  • 在 Rust 中,声明全局变量时应使用 staticconst,因为在全局作用域中不允许使用 let
  • 对于线程安全 的运行时全局变量初始化,请考虑使用 std::sync::Once 或外部库,如 lazy_staticonce_cell
  • 由于存在潜在的安全问题,请避免直接使用 static mut;而是将访问操作包装在 unsafe 代码块中,或者使用互斥锁等同步原语。
  • 对于单线程应用程序,thread_local! 宏可以提供一种安全的方式来处理全局状态,而无需进行同步
  • 尽可能将全局变量重构为局部作用域,使用像 Arc 这样的智能指针来实现共享所有权和线程安全。
  • 了解 Rust 中 conststatic 的区别:const 变量是内联的且不可变的(const 在编译时直接使用变量内容替换了变量,存在多份数据),而 static 变量可以具有可变状态,并可通过原子类型或互斥锁等内部可变性选项来实现。

概述

在 Rust 中实现全局状态有很多种方法。如果您时间紧迫,这里快速概述一下我的建议。

您可以通过以下链接跳转到本文的特定章节:

Rust 中使用全局变量的初次尝试

我们先来看一个全局变量使用不当的例子。假设我想把程序的启动时间存储在一个全局字符串中。之后,我想从多个线程访问这个值。

Rust 初学者可能会想当然地使用 let 来声明全局变量,就像声明其他 Rust 变量一样。完整的程序可能如下所示:

rust 复制代码
use chrono::Utc;

let START_TIME = Utc::now().to_string();

pub fn main() {
    let thread_1 = std::thread::spawn(||{
        println!("Started {}, called thread 1 {}", START_TIME.as_ref().unwrap(), Utc::now());
    });
    let thread_2 = std::thread::spawn(||{
        println!("Started {}, called thread 2 {}", START_TIME.as_ref().unwrap(), Utc::now());
    });

    // Join threads and panic on error to show what went wrong
    thread_1.join().unwrap();
    thread_2.join().unwrap();
}

你自己去rust playgroud上试试吧!

这是 Rust 中无效的语法。let 不能用于全局作用域。我们只能使用 staticconst . 而const 声明的是一个真正的常量,而不是一个变量。只有 static 才能声明一个全局变量。

其背后的原因是, let 在运行时将变量分配到 上。请注意,即使在堆上分配变量,例如 let t = Box::new(); ,情况也是如此。在生成的机器代码中,仍然存在一个指向堆的指针,该指针最终会被存储在栈上。

全局变量存储在程序的数据段中。它们的地址是固定的,地址在程序执行期间不会改变。而 代码段可以包含常量地址,并且完全不需要占用栈空间。

好的,所以我们现在可以理解为什么需要不同的语法了。Rust 作为一种现代系统编程语言,希望非常明确地管理内存。

让我们再试一次 static

rust 复制代码
use chrono::Utc;

static START_TIME: String = Utc::now().to_string();

pub fn main() {
    // ...
}

编译器还不满意:

rust 复制代码
error[E0015]: calls in statics are limited to constant functions, tuple structs and tuple variants
 --> src/main.rs:3:24
  |
3 | static start: String = Utc::now().to_string();
  |                        ^^^^^^^^^^^^^^^^^^^^^^

嗯,所以静态变量的初始值不能在运行时计算。那或许就让它保持未初始化状态吧?

rust 复制代码
use chrono::Utc;

static START_TIME;

pub fn main() {
    // ...
}

这将产生一个新的错误:

rust 复制代码
Compiling playground v0.0.1 (/playground)
error: free static item without body
 --> src/main.rs:21:1
  |
3 | static START_TIME;
  | ^^^^^^^^^^^^^^^^^-
  |                  |
  |                  help: provide a definition for the static: `= <expr>;`

所以这个方法也不行!所有静态值必须在任何用户代码运行之前完全初始化并有效

如果你是从其他语言(例如 JavaScript 或 Python)转而使用 Rust,这或许会显得过于限制。但任何一位 C++ 高手都能跟你讲讲静态初始化顺序的惨痛教训,如果我们不小心,就可能导致未定义的初始化顺序。

例如,想象一下这样的情况:

rust 复制代码
static A: u32 = foo();
static B: u32 = foo();
static C: u32 = A + B;

fn foo() -> u32 {
    C + 1
}

fn main() {
    println!("A: {} B: {} C: {}", A, B, C);
}

由于循环依赖,这段代码片段中存在 不 安全的初始化顺序。

如果是 C++,它不关心安全性,结果将是 A: 1 B: 1 C: 2 它在任何代码运行之前进行零初始化,然后顺序在每个编译单元内从上到下定义。

至少结果已经明确。然而,当静态变量来自不同的 .cpp 文件,也就是不同的编译单元时,问题就出现了。这时,变量的顺序就无法确定,通常取决于编译命令行中文件的顺序。

在 Rust 中,零初始化是不允许的 。毕竟,零对于很多类型(例如 Box 来说都是无效值。此外,Rust 也不接受奇怪的顺序问题。只要我们避免使用 unsafe ,编译器就应该只允许我们编写合理的代码。这就是为什么编译器会阻止我们使用直接的运行时初始化。

但我能否通过使用 None (相当于空指针)来绕过初始化呢?至少这完全符合 Rust 的类型系统。我肯定可以把初始化代码移到 main 函数的顶部,对吧?

rust 复制代码
static mut START_TIME: Option<String> = None;

pub fn main() {
    START_TIME = Some(Utc::now().to_string());
    // ...
}

呃,我们遇到的错误是......

rust 复制代码
error[E0133]: use of mutable static is unsafe and requires unsafe function or block
  --> src/main.rs:24:5
  |
6 |     START_TIME = Some(Utc::now().to_string());
  |     ^^^^^^^^^^ use of mutable static
  |
  = note: mutable statics can be mutated by multiple threads: aliasing violations or data races will cause undefined behavior

此时,我可以把它放在一个 unsafe{...} 代码块里,这样就能正常运行。有时候,这确实是一个有效的策略,比如用来测试代码的其余部分是否按预期工作。但这并不是我想向你展示的惯用方法。所以,让我们来探讨一下编译器保证安全的解决方案。

重构示例

您可能已经注意到,这个例子完全不需要全局变量。而且通常情况下,如果我们能想到不用全局变量的解决方案,就应该避免使用它们。

这里的想法是将声明放在主函数内部:

rust 复制代码
pub fn main() {
    let start_time = Utc::now().to_string();
    let thread_1 = std::thread::spawn(||{
        println!("Started {}, called thread 1 {}", &start_time, Utc::now());
    });
    let thread_2 = std::thread::spawn(||{
        println!("Started {}, called thread 2 {}", &start_time, Utc::now());
    });

    // Join threads and panic on error to show what went wrong
    thread_1.join().unwrap();
    thread_2.join().unwrap();
}

唯一的问题是通不过borrow检查器:

rust 复制代码
error[E0373]: closure may outlive the current function, but it borrows `start_time`, which is owned by the current function
  --> src/main.rs:42:39
   |
42 |     let thread_1 = std::thread::spawn(||{
   |                                       ^^ may outlive borrowed value `start_time`
43 |         println!("Started {}, called thread 1 {}", &start_time, Utc::now());
   |                                                     ---------- `start_time` is borrowed here
   |
note: function requires argument type to outlive `'static`

这个错误并不十分明显。编译器告诉我们,新创建的线程的生命周期可能比 start_time 值更长,而 start_time 值存在于 main 函数的栈帧中。

从技术上讲,这是不可能的。线程是被join的,因此main线程不会在子线程完成之前退出。

但编译器不够智能,无法处理这种特殊情况。通常情况下,当创建一个新线程时,提供的闭包只能借用具有静态生命周期的元素。换句话说,借用的值必须在整个程序生命周期内都保持有效。

对于刚开始学习 Rust 的人来说,你可能会想直接使用全局变量。但至少有两种方法比这简单得多。最简单的方法是复制字符串值,然后将字符串的所有权转移到闭包中。当然,这需要额外的内存分配。但在这个例子中,它只是一个短字符串,(复制)对性能影响不大

但如果要共享的对象很大 呢?如果您不想克隆它,可以将其包装在引用计数智能指针之后 。Rc 是单线程引用计数类型。Arc 是原子版本,可以在线程间安全地共享值。

因此,为了满足编译器的要求,我们可以使用 Arc ,如下所示:

rust 复制代码
/* Final Solution */
pub fn main() {
    let start_time = Arc::new(Utc::now().to_string());
    // This clones the Arc pointer, not the String behind it
    let cloned_start_time = start_time.clone();
    let thread_1 = std::thread::spawn(move ||{
        println!("Started {}, called thread 1 {}", cloned_start_time, Utc::now());
    });
    let thread_2 = std::thread::spawn(move ||{
        println!("Started {}, called thread 2 {}", start_time, Utc::now());
    });

    // Join threads and panic on error to show what went wrong
    thread_1.join().unwrap();
    thread_2.join().unwrap();
}

可以去playground上试试!

以上简要介绍了如何在避免使用全局变量的情况下在线程间共享状态 。除了我目前展示的内容之外,您可能还需要内部可变性修改 共享状态。本文篇幅有限,无法全面介绍内部可变性。但在本例中,我会选择使用 Arc<Mutex<String>>start_time 添加线程安全的内部可变性。

当全局变量值在 [编译时] 已知时

根据我的经验,全局状态最常见的用途不是变量,而是常量。在 Rust 中,它们有两种类型:

  • 常量值用 const 定义。这些常量值会被编译器内联。内部变量的可变性是绝对不允许的。
  • 静态变量,用 static 定义,在数据段中占据固定的空间。它们内部可以修改

它们都可以使用编译时常量进行初始化。这些常量可以是简单的值,例如 42"hello world" 。也可以是包含多个其他编译时常量和标记为 const 函数的表达式。只要避免循环依赖即可。(您可以在 Rust 参考手册中找到有关常量表达式的更多详细信息。)

rust 复制代码
use std::sync::atomic::AtomicU64;
use std::sync::{Arc,Mutex};

static COUNTER: AtomicU64 = AtomicU64::new(TI_BYTE);

const GI_BYTE: u64 = 1024 * 1024 * 1024;
const TI_BYTE: u64 = 1024 * GI_BYTE;

通常情况下, const 是更好的选择------除非你需要内部可变性,或者你特别想避免内联。

如果您需要内部可变性,有多种选择。对于大多数基本类型, std::sync::atomic 中都有相应的原子变体。它们提供了一个简洁的 API,用于原子地加载、存储和更新值。

在无法使用原子操作的情况下,通常的选择是使用锁。Rust 的标准库提供了读写锁RwLock )和互斥锁Mutex )。

但是,如果您需要在运行时计算值 ,或者需要堆分配,那么 conststatic 就无济于事了。

Rust 中的 [单线程] 全局变量及其 [运行时] 初始化

我编写的大多数应用程序都只有单线程。在这种情况下,锁 机制是不必要的。

然而,仅仅因为只有一个线程,我们就不应该直接使用 static mut 并将访问操作包装在 unsafe 中。这样做可能会导致严重的内存损坏。

例如,不安全地从全局变量借用值可能会导致同时存在多个可变引用。然后,我们可以使用其中一个引用来遍历一个向量,再使用另一个引用从同一个向量中删除值。这样一来,迭代器就可能超出有效的内存边界,而安全的 Rust 代码本可以避免这种潜在的崩溃。

但标准库提供了一种"全局"存储值的方法,以便在单个线程内 安全访问。我指的是线程局部变量 。在多线程情况下,每个线程都会获得该变量的独立副本。但在我们的例子中,由于只有一个线程,所以只有一个副本。

线程局部变量使用 thread_local! 宏创建。访问它们需要使用闭包,如下例所示:

rust 复制代码
use chrono::Utc;

thread_local!(static GLOBAL_DATA: String = Utc::now().to_string());

fn main() {
    GLOBAL_DATA.with(|text| {
        println!("{}", *text);
    });
}

这并非最简单的解决方案。但它允许我们执行任意初始化代码,这些代码会在首次访问该值时及时运行。

线程局部变量在内部可变性方面表现出色。与其他所有解决方案不同,它不需要同步机制 。这使得可以使用 RefCell 来实现内部可变性,从而避免了互斥锁带来的额外开销。

线程局部变量的绝对性能高度依赖于平台。但我在自己的电脑上做了一些简单的测试, 将其与依赖锁的内部可变性进行比较,发现前者速度快了 10 倍。我预计结果在其他平台上不会相反,但如果您非常在意性能,请务必运行您自己的基准测试。

以下是如何使用 RefCell 实现内部可变性的示例:

rust 复制代码
thread_local!(static GLOBAL_DATA: RefCell<String> = RefCell::new(Utc::now().to_string()));

fn main() {
    GLOBAL_DATA.with(|text| {
        println!("Global string is {}", *text.borrow());
    });

    GLOBAL_DATA.with(|text| {
        *text.borrow_mut() = Utc::now().to_string();
    });

    GLOBAL_DATA.with(|text| {
        println!("Global string is {}", *text.borrow());
    });
}

你可以自己去playground上试试吧!

顺便一提,虽然 WebAssembly 中的线程与 x86_64 平台上的线程有所不同,但这种使用 thread_local! + RefCell 模式同样适用于编译 Rust 代码并在浏览器中运行。在这种情况下,使用针对多线程代码的安全方法就显得有些多余了。(如果您还不了解在浏览器中运行 Rust 的概念,可以阅读我之前写的文章《 Rust 教程:面向 JavaScript 开发者的 Rust 入门 》。)

线程局部变量的一个需要注意的地方是,它们的实现取决于平台。通常情况下,你不会注意到这一点,但需要注意的是,正因如此 ,丢弃语义也与平台相关

综上所述,多线程全局变量的解决方案显然也适用于单线程情况。而且,如果没有内部可变性,它们的速度似乎与线程局部变量一样快。

接下来我们就来看看这个问题。

具有 [运行时] 初始化的 [多线程] 全局变量

目前标准库对于运行时初始化的安全全局变量还没有很好的解决方案。但是,如果你清楚自己在做什么,可以使用 std::sync::Once构建一个能够安全地使用 unsafe 程序。

官方文档中的示例是一个很好的起点。如果您还需要内部可变性,则必须将该方法与读写锁或互斥锁结合使用。以下是具体示例:

rust 复制代码
static mut STD_ONCE_COUNTER: Option<Mutex<String>> = None;
static INIT: Once = Once::new();

fn global_string<'a>() -> &'a Mutex<String> {
    INIT.call_once(|| {
        // Since this access is inside a call_once, before any other accesses, it is safe
        unsafe {
            *STD_ONCE_COUNTER.borrow_mut() = Some(Mutex::new(Utc::now().to_string()));
        }
    });
    // As long as this function is the only place with access to the static variable,
    // giving out a read-only borrow here is safe because it is guaranteed no more mutable
    // references will exist at this point or in the future.
    unsafe { STD_ONCE_COUNTER.as_ref().unwrap() }
}
pub fn main() {
    println!("Global string is {}", *global_string().lock().unwrap());
    *global_string().lock().unwrap() = Utc::now().to_string();
    println!("Global string is {}", *global_string().lock().unwrap());
}

你自己去playground上试试吧!

如果你想要更简单的方式,我强烈推荐以下两个 crate,我将在下一节中讨论。

Rust 中用于管理全局变量的外部库

根据流行度和个人喜好,我想推荐两个我认为是 2021 年 Rust 中实现简单全局变量的最佳选择。

Once Cell 目前已被考虑纳入标准库。(参见此跟踪问题 。)如果您使用的是 nightly 编译器,您可以通过在项目的 main.rs 中添加 #![feature(once_cell)] 来使用它的不稳定 API。

以下是在稳定编译器上使用 once_cell 以及额外依赖项的示例:

rust 复制代码
use once_cell::sync::Lazy;

static GLOBAL_DATA: Lazy<String> = Lazy::new(||Utc::now().to_string());

fn main() {
    println!("{}", *GLOBAL_DATA);
}

你自己去playground上试试吧!

最后,还有 Lazy Static ,它是目前最流行的用于初始化全局变量的 crate。它使用一个带有少量语法扩展( static ref )的宏来定义全局变量。

以下是同样的例子,从 once_cell 翻译成 lazy_static

rust 复制代码
#[macro_use]
extern crate lazy_static;

lazy_static!(
    static ref GLOBAL_DATA: String = Utc::now().to_string();
);

fn main() {
    println!("{}", *GLOBAL_DATA);
}

你自己去操场上试试吧!

once_celllazy_static 之间的选择,本质上取决于你更喜欢哪种语法。

此外,两者都支持内部可变性。只需将 String 包装在 MutexRwLock 中即可。

结论

以上就是我所知的在 Rust 中实现全局变量的所有(合理的)方法。我希望它能更简单一些。但全局状态本身就很复杂。考虑到 Rust 的内存安全保证,似乎不可能找到一个简单通用的解决方案。但我希望这篇文章能帮助你从众多可用的选项中理清思路。

总的来说,Rust 社区倾向于赋予用户最大的权力------但这同时也使事情变得更加复杂。

关于如何在 Rust 中惯用全局变量的常见问题解答

What Is the Difference Between Static and Const in Rust?

Rust 中 static 和 const 有什么区别?

在 Rust 中,staticconst 都用于声明全局变量,但它们的特性有所不同。const 在 Rust 中表示常量值,这意味着它的值不会改变。它类似于变量,但它的值是固定的,不能更改。另一方面,static 变量是存储在程序二进制文件只读数据段中的全局变量。它在整个程序中都可用,而不仅仅是在其声明的作用域内 。与 const 不同(const 可能被拷贝多份),static 变量在内存中具有固定的地址

如何在 Rust 中声明全局变量?

在 Rust 中,可以使用 static 关键字声明全局变量。例如:

static GLOBAL: i32 = 10;

在这个例子中,GLOBAL 是一个 i32 类型的全局变量,其初始值为 10。请记住,静态变量是只读的,您不能修改它们。

我可以在 Rust 中修改全局变量吗?

不,你不能直接修改 Rust 中的全局变量,因为它们默认是不可变的。这是 Rust 的一个安全特性,用于防止编译时出现数据竞争。不过,你可以在 std::sync 模块中使用 Mutex 或 RwLock 来实现可变全局变量。

Rust 中 'lazy_static!' 宏的用途是什么?

Rust 中的 lazy_static! 宏允许你以惰性方式初始化静态变量。这意味着静态变量的初始化会被延迟到首次访问时才执行。当初始化操作开销较大,而你只想在必要时才执行时,这种方式非常有用。

如何在 Rust 中使用 'lazy_static!' 宏声明全局变量?

以下示例展示了如何在 Rust 中使用 'lazy_static!' 宏来声明全局变量:

#[macro_use]
extern crate lazy_static;
use std::sync::Mutex;
lazy_static! {static ref GLOBAL: Mutex<i32> = Mutex::new(0);
}

在这个例子中,GLOBAL 是一个 Mutex 类型的全局变量。<i32> 并初始化为值 0。'lazy_static!' 宏确保初始化以线程安全的方式完成,并且只执行一次。

Rust 中 'static' 和 'static mut' 有什么区别?

在 Rust 中,static 用于声明只读 的全局变量,而 static mut 用于声明可变的全局变量。但是,static mut不安全的,因为如果两个线程同时访问同一个变量,可能会导致未定义行为。

如何在 Rust 中安全地访问静态 mut 变量?

在 Rust 中,可以使用 unsafe 关键字安全地访问静态可变变量 (static mut)。例如:

static mut GLOBAL: i32 = 10;

fn main() {
unsafe {
GLOBAL = 20;
println!("{}", GLOBAL);
}
}

在这个例子中,"unsafe"关键字用于指示代码可能会执行在安全代码中未定义的操作。

Rust 中全局变量的生命周期是什么?

在 Rust 中,全局变量的生命周期与程序的运行时间相同。这意味着全局变量在程序启动时创建,并在程序结束时销毁

我可以在 Rust 函数中使用全局变量吗?

是的,你可以在 Rust 函数中使用全局变量。但是,使用时要格外小心,因为如果使用不当,它们可能会导致数据竞争。通常建议尽可能使用局部变量而不是全局变量。

Rust 中全局变量的替代方案有哪些?

如果你想在 Rust 程序的不同部分之间共享数据,可以使用一些替代全局变量的方法。这些方法包括将数据作为函数参数传递、从函数返回数据、使用结构体和枚举等数据结构,以及使用通道和锁等并发原语。


原文地址

相关推荐
爬山算法1 天前
Hibernate(26)什么是Hibernate的透明持久化?
java·后端·hibernate
彭于晏Yan1 天前
Springboot实现数据脱敏
java·spring boot·后端
alonewolf_991 天前
Spring IOC容器扩展点全景:深入探索与实践演练
java·后端·spring
super_lzb1 天前
springboot打war包时将外部配置文件打入到war包内
java·spring boot·后端·maven
天远云服1 天前
Go语言高并发实战:集成天远手机号码归属地核验API打造高性能风控中台
大数据·开发语言·后端·golang
钱多多_qdd1 天前
springboot注解(二)
java·spring boot·后端
神奇小汤圆1 天前
MyBatis批量插入从5分钟优化到3秒,我做了这3件事
后端
上去我就QWER1 天前
什么是反向代理?
后端·nginx
Charlo1 天前
手把手配置 Ralph -- 火爆 X 的全自动 AI 编程工具
前端·后端·github