Skip to content

Rust 中全局可变单例模式的权衡与选择

Posted on:2024年9月8日 at 21:56

在编程中,我们经常会遇到需要创建全局状态的情况。这种做法通常被认为是不好的编程实践,因为它可能会导致代码难以维护和理解。然而,在某些情况下,全局状态是必要的,例如OpenGL子系统。在这种情况下,我们需要找到一种安全有效的方法来实现它。

Rust 语言为我们提供了几种方法来创建全局可变单例模式。在本文中,我们将探讨这些方法,并讨论它们各自的优缺点。

使用 std::sync::Mutex 创建全局单例

最基本的方法是使用 std::sync::Mutex 来保护我们的全局状态。这种方法如下所示:

use std::sync::Mutex;

static GLOBAL_DATA: Mutex<Vec<i32>> = Mutex::new(vec![]);

fn main() {
    GLOBAL_DATA.lock().unwrap().push(42);
    println!("{:?}", GLOBAL_DATA.lock().unwrap());
}

这种方法的优点是简单直接,但也存在一些缺点:

  1. 每次访问全局状态时都需要获取锁,这可能会影响性能。
  2. 如果忘记释放锁,可能会导致死锁问题。
  3. 初始化全局状态的方式不够灵活。

使用 std::sync::OnceLock 创建全局单例

Rust 1.17 版本引入了 std::sync::OnceLock,它可以帮助我们更好地管理全局单例。使用 OnceLock 的例子如下:

use std::sync::OnceLock;

static GLOBAL_DATA: OnceLock<Mutex<Vec<i32>>> = OnceLock::new();

fn array() -> &'static Mutex<Vec<i32>> {
    GLOBAL_DATA.get_or_init(|| Mutex::new(vec![]))
}

fn main() {
    array().lock().unwrap().push(42);
    println!("{:?}", array().lock().unwrap());
}

这种方法的优点是:

  1. 初始化全局状态的方式更加灵活。
  2. 只有在首次访问时才会进行初始化,减少了不必要的开销。

但缺点是:

  1. 代码相对更加复杂。
  2. 如果初始化过程非常昂贵,可能会影响性能。

使用 lazy_static 创建全局单例

lazy_static 是一个第三方库,它提供了一种更简单的方式来创建全局单例。使用 lazy_static 的例子如下:

#[macro_use]
extern crate lazy_static;

use std::sync::Mutex;

lazy_static! {
    static ref GLOBAL_DATA: Mutex<Vec<i32>> = Mutex::new(vec![]);
}

fn main() {
    GLOBAL_DATA.lock().unwrap().push(42);
    println!("{:?}", GLOBAL_DATA.lock().unwrap());
}

这种方法的优点是:

  1. 代码更加简洁和易读。
  2. 初始化过程隐藏在宏中,降低了使用者的复杂度。

缺点是:

  1. 需要依赖第三方库,增加了项目的复杂度。
  2. 如果初始化过程非常昂贵,可能会影响性能。

使用 once_cell 创建全局单例

类似于 lazy_staticonce_cell 也是一个第三方库,提供了一种创建全局单例的简单方法。使用 once_cell 的例子如下:

use once_cell::sync::Lazy;
use std::sync::Mutex;

static GLOBAL_DATA: Lazy<Mutex<Vec<i32>>> = Lazy::new(|| Mutex::new(vec![]));

fn main() {
    GLOBAL_DATA.lock().unwrap().push(42);
    println!("{:?}", GLOBAL_DATA.lock().unwrap());
}

这种方法的优点和缺点与使用 lazy_static 类似。

特殊情况: 使用 std::sync::atomic::AtomicUsize 创建全局单例

如果你只需要跟踪一个整数值,可以直接使用 std::sync::atomic::AtomicUsize。这种方法更加简单和高效,示例如下:

use std::sync::atomic::{AtomicUsize, Ordering};

static CALL_COUNT: AtomicUsize = AtomicUsize::new(0);

fn do_a_call() {
    CALL_COUNT.fetch_add(1, Ordering::SeqCst);
}

fn main() {
    do_a_call();
    do_a_call();
    do_a_call();
    println!("called {}", CALL_COUNT.load(Ordering::SeqCst));
}

这种方法的优点是:

  1. 代码更加简单和高效。
  2. 适用于只需要跟踪整数值的情况。

缺点是:

  1. 只适用于整数值,不能用于更复杂的数据结构。

手动实现全局单例

除了使用现有的工具,我们也可以手动实现全局单例。这种方法可以更好地控制初始化过程,示例如下:

use std::sync::{Mutex, Once};
use std::time::Duration;
use std::{mem::MaybeUninit, thread};

struct SingletonReader {
    inner: Mutex<Vec<u8>>,
}

fn singleton() -> &'static SingletonReader {
    static mut SINGLETON: MaybeUninit<SingletonReader> = MaybeUninit::uninit();
    static ONCE: Once = Once::new();

    ONCE.call_once(|| {
        let singleton = SingletonReader {
            inner: Mutex::new(vec![]),
        };
        unsafe { SINGLETON.write(singleton); }
    });

    unsafe { SINGLETON.assume_init_ref() }
}

fn main() {
    let threads: Vec<_> = (0..10)
        .map(|i| {
            thread::spawn(move || {
                thread::sleep(Duration::from_millis(i * 10));
                let s = singleton();
                let mut data = s.inner.lock().unwrap();
                *data = i as u8;
            })
        })
        .collect();

    for _ in 0..20 {
        thread::sleep(Duration::from_millis(5));
        let s = singleton();
        let data = s.inner.lock().unwrap();
        println!("It is: {}", *data);
    }

    for thread in threads {
        thread.join().unwrap();
    }
}

这种方法的优点是:

  1. 可以更好地控制初始化过程。
  2. 不需要依赖任何第三方库。

缺点是:

  1. 代码相对更加复杂。
  2. 需要更多的手动管理,增加了出错的风险。

关于”全局”的含义

需要注意的是,你仍然可以使用普通的 Rust 作用域和模块级别的隐私来控制对全局状态的访问。这意味着你可以在模块或函数内部声明它,这样它就不会被外部访问。这有助于控制访问:

lazy_static! {
    static ref NAME: String = String::from("hello, world!");
}

fn only_here() {
    println!("{}", &*NAME);
}

fn not_here() {
    // println!("{}", &*NAME); // Error: `NAME` not found in this scope
}

尽管变量是全局的,但它仍然是在整个程序中唯一存在的。

总结

在 Rust 中,创建全局可变单例模式有多种方法。每种方法都有自己的优缺点,开发者需要根据具体需求进行权衡和选择。总的来说,避免使用全局状态是最好的做法,但在某些情况下,它可能是必要的。无论选择哪种方法,开发者都需要谨慎地考虑它的影响,并确保代码的安全性和可维护性。