Question

How to Create a Global Mutable Singleton in Rust

Question

I want to create and use a struct that has only one instance in the entire program. In this case, it represents the OpenGL subsystem, and having multiple copies or passing it through many layers of code would make the design more confusing rather than simpler.

The singleton should be as efficient as possible. A direct static value does not seem practical because the struct contains a Vec, which has a destructor and cannot be trivially stored as a regular static value in the way I expect.

Another idea is to store an unsafe pointer in static storage and make it point to a heap-allocated instance. What is the safest and most convenient way to implement this in Rust while keeping the syntax reasonably concise?

For example, the kind of type I am thinking about is something like:

struct OpenGlSubsystem {
    buffers: Vec<u32>,
}

How can I make a single global, mutable instance of a type like this in Rust?

Short Answer

By the end of this page, you will understand how global mutable state works in Rust, why plain mutable globals are restricted, and how to build a safe singleton using tools like OnceLock, Mutex, and LazyLock. You will also see when a singleton is appropriate, what trade-offs it introduces, and how real Rust codebases usually structure shared global state.

Concept

Rust is very careful about global mutable state because it is a common source of bugs in many languages.

A singleton is a value that exists only once in a program and is shared from a central place. In Rust, this is not usually implemented with a raw global mutable variable, because:

  • mutable globals can cause data races
  • initialization order can become hard to reason about
  • cleanup and ownership become unclear
  • unrestricted shared mutation breaks Rust's safety model

That is why Rust pushes you toward controlled global access.

Why a plain static mut is not ideal

You might think of writing something like this:

static mut OPENGL: OpenGlSubsystem = ...;

But this has several problems:

  • accessing static mut is unsafe
  • simultaneous access from multiple threads can be undefined behavior
  • initialization can be awkward for types like Vec
  • it bypasses Rust's usual ownership and borrowing checks

The safe Rust approach

The usual safe pattern is:

  1. store the singleton in a global container
  2. initialize it once
  3. guard mutable access with a synchronization type if needed

Common standard-library tools are:

  • std::sync::OnceLock<T>: initialize exactly once
  • std::sync::LazyLock<T>: lazily initialize on first use
  • std::sync::Mutex<T>: allow mutation through a lock
  • std::sync::RwLock<T>: many readers or one writer

A very common combination is:

static APP: LazyLock<Mutex<MyType>> = LazyLock::new(|| Mutex::new(MyType::new()));

This means:

  • the global value is created only when first used
  • there is only one instance
  • mutation is protected safely

What if the program is single-threaded?

If your subsystem truly runs on one thread only, you may also see patterns using:

  • RefCell<T> for interior mutability in single-threaded code
  • thread_local! for per-thread globals

However, for a shared process-wide singleton, Mutex or another sync primitive is usually the clearest and safest choice.

Why this matters in real programming

Global subsystems appear in real applications for things like:

  • logging
  • configuration
  • metrics registries
  • renderer state
  • plugin registries
  • caches

Rust does not forbid these patterns. It just requires that they be made explicit and safe.

Mental Model

Think of a singleton as a shared control room in a building.

  • There is only one control room.
  • Everyone who needs it goes to the same place.
  • If only one person can change settings at a time, the room needs a lock on the door.
  • The first person to arrive may also be responsible for setting it up.

In Rust terms:

  • the control room is the singleton value
  • the one-time setup is OnceLock or LazyLock
  • the door lock is Mutex
  • the rulebook that prevents chaos is Rust's ownership system

Without the lock, two people could change controls at once. In programming, that becomes corrupted state or a data race. Rust makes you use the lock instead of hoping everyone behaves.

Take Quiz

Syntax and Examples

Core syntax

A modern safe pattern for a global mutable singleton in Rust is:

use std::sync::{LazyLock, Mutex};

struct OpenGlSubsystem {
    buffers: Vec<u32>,
}

impl OpenGlSubsystem {
    fn new() -> Self {
        Self { buffers: Vec::new() }
    }

    fn add_buffer(&mut self, id: u32) {
        self.buffers.push(id);
    }
}

static OPENGL: LazyLock<Mutex<OpenGlSubsystem>> =
    LazyLock::new(|| Mutex::new(OpenGlSubsystem::new()));

fn main() {
    let mut gl = OPENGL.lock().unwrap();
    gl.add_buffer(42);
    println!("{:?}", gl.buffers);
}

What this does

  • LazyLock creates the singleton the first time it is used
  • Mutex allows safe mutable access
  • lock() gives you a guard that acts like &mut OpenGlSubsystem
  • when the guard goes out of scope, the lock is released

Using OnceLock when initialization happens later

Sometimes you need to initialize a subsystem with runtime values.

use std::sync::{Mutex, OnceLock};

struct OpenGlSubsystem {
    buffers: Vec<u32>,
}

impl OpenGlSubsystem {
    fn new() -> Self {
        Self { buffers: vec![1, 2, 3] }
    }
}

static OPENGL: OnceLock<Mutex<OpenGlSubsystem>> = OnceLock::new();

fn init_opengl() {
    OPENGL.set(Mutex::new(OpenGlSubsystem::new())).unwrap();
}

fn use_opengl() {
    let gl = OPENGL.get().expect("OpenGL not initialized yet");
    let mut gl = gl.lock().unwrap();
    gl.buffers.push(99);
}

Use OnceLock when initialization depends on program startup logic.

Single-threaded interior mutability example

If you are certain the singleton is only used from one thread, this can work too:

use std::cell::RefCell;
use std::sync::OnceLock;

struct OpenGlSubsystem {
    buffers: Vec<u32>,
}

static OPENGL: OnceLock<RefCell<OpenGlSubsystem>> = OnceLock::new();

But this is less common for shared global process state because RefCell is not thread-safe. In multi-threaded programs, use Mutex or RwLock instead.

Step by Step Execution

Consider this example:

use std::sync::{LazyLock, Mutex};

struct Counter {
    value: i32,
}

static COUNTER: LazyLock<Mutex<Counter>> =
    LazyLock::new(|| Mutex::new(Counter { value: 0 }));

fn increment() {
    let mut counter = COUNTER.lock().unwrap();
    counter.value += 1;
}

fn main() {
    increment();
    increment();

    let counter = COUNTER.lock().unwrap();
    println!("{}", counter.value);
}

Step by step

1. Program starts

COUNTER exists as a global definition, but Counter { value: 0 } is not created yet.

2. First call to increment()

This line runs:

let mut counter = COUNTER.lock().unwrap();

Because COUNTER is a LazyLock, Rust initializes it now by calling:

|| Mutex::new(Counter { value: 0 })

So the singleton is created exactly once.

3. Lock is acquired

lock() returns a guard. While that guard exists:

  • no other code can mutably access the same Mutex
  • counter behaves like a mutable reference to the inner Counter

4. Value is updated

counter.value += 1;

Now value becomes 1.

5. Function ends

The lock guard goes out of scope, so the mutex is unlocked automatically.

6. Second call to increment()

The singleton already exists, so Rust does not create it again.

It simply locks the same Mutex, increments value to 2, and unlocks it.

7. Final print

main() locks the singleton again and prints:

2

This is the key singleton behavior: one global instance, many accesses, one initialization.

Real World Use Cases

Global mutable singletons are used carefully in real programs when there is truly one shared resource or registry.

Common use cases

Rendering or graphics subsystem

A renderer may hold:

  • GPU buffer IDs
  • shader cache
  • device state
  • texture registry

This is close to your OpenGL example.

Global configuration loaded at startup

A program may load configuration once and keep it globally accessible.

use std::sync::OnceLock;

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

Logger or metrics registry

Logging backends and metrics collectors are often initialized once and then reused throughout the app.

Shared cache

An application may keep a global in-memory cache of parsed templates, API tokens, or expensive computed values.

Plugin or command registry

A CLI or framework may store a global registry of commands, handlers, or extensions.

When not to use a singleton

Avoid a singleton if:

  • the value can be passed as a normal parameter
  • different tests need different instances
  • different parts of the app should use different configurations
  • global state will make dependencies hidden and hard to maintain

In many codebases, explicit dependency passing is better. A singleton is best reserved for truly global system-wide resources.

Take Quiz

Real Codebase Usage

In real Rust projects, developers usually avoid raw singleton patterns and instead use a few practical patterns.

1. One-time initialization at startup

A subsystem is created once and stored globally:

static STATE: OnceLock<Mutex<AppState>> = OnceLock::new();

Then startup code calls set(...).

This is useful when initialization depends on command-line arguments, files, or environment variables.

2. Lazy global state

If the subsystem can be created with default logic, developers often write:

static STATE: LazyLock<Mutex<AppState>> = LazyLock::new(|| {
    Mutex::new(AppState::new())
});

This avoids separate init code.

3. Guard clauses for initialization

Code often checks that global state exists before using it:

let state = STATE.get().expect("state not initialized");

That makes initialization errors fail early and clearly.

4. Narrow access functions

Instead of exposing the global directly everywhere, many projects wrap it in helper functions:

fn with_opengl<F, R>(f: F) -> R
where
    F: FnOnce(&mut OpenGlSubsystem) -> R,
{
    let mut gl = OPENGL.lock().unwrap();
    f(&mut gl)
}

This reduces repeated locking code and hides implementation details.

5. Error handling around poisoned locks

In real code, .unwrap() on lock() is common in small examples, but production code may handle poisoning explicitly:

let mut state = OPENGL.lock().unwrap_or_else(|poisoned| poisoned.into_inner());

6. Minimizing lock duration

Developers try to hold the mutex for as little time as possible:

  • lock
  • do the smallest necessary mutation
  • release quickly

This avoids contention and keeps the program responsive.

Common Mistakes

1. Using static mut directly

Broken example:

struct State {
    value: i32,
}

static mut STATE: State = State { value: 0 };

fn increment() {
    unsafe {
        STATE.value += 1;
    }
}

Why this is a problem:

  • every access is unsafe
  • concurrent access can cause undefined behavior
  • Rust cannot protect you properly

Prefer:

use std::sync::{LazyLock, Mutex};

2. Forgetting synchronization for mutation

If multiple threads may access the singleton, mutation must be protected.

Broken idea:

use std::sync::OnceLock;

static STATE: OnceLock<MyType> = OnceLock::new();

This is fine for shared immutable access, but not for direct mutation from many places unless MyType itself handles safe interior mutability.

3. Holding the lock too long

Problematic pattern:

let mut gl = OPENGL.lock().unwrap();
// lots of unrelated work here
// maybe I/O here
// maybe long computation here

Why it is bad:

  • blocks other code from accessing the singleton
  • increases contention
  • can lead to deadlock patterns in larger systems

4. Re-initializing a OnceLock

Broken example:

OPENGL.set(Mutex::new(OpenGlSubsystem::new())).unwrap();
OPENGL.set(Mutex::new(OpenGlSubsystem::new())).unwrap();

The second set fails because OnceLock can only be initialized once.

5. Assuming a singleton is always the best design

Beginners sometimes use globals to avoid passing references. That can hide dependencies and make testing harder.

A better question is:

  • is this truly one shared system resource?
  • or should it be owned by an application struct and passed where needed?

6. Using RefCell in multi-threaded code

RefCell<T> checks borrow rules at runtime, but it is not thread-safe.

Use:

  • RefCell<T> for single-threaded interior mutability
  • Mutex<T> or RwLock<T> for multi-threaded shared state

Comparisons

ApproachSafe?Mutable?Thread-safe?Typical use
static mut TNo, requires unsafeYesNo, unless manually synchronizedRare, low-level code only
static TYesNo, unless T has interior mutabilityDepends on TConstants and immutable globals
OnceLock<T>YesOnly through T's own APIDepends on TOne-time initialization
LazyLock<T>YesOnly through T's own APIDepends on TLazy global initialization
Mutex<T>YesYesYesShared mutable global state
RwLock<T>YesYesYesMany readers, occasional writers
RefCell<T>YesYesNoSingle-threaded interior mutability

OnceLock vs LazyLock

FeatureOnceLockLazyLock
Initialization styleExplicit set() or get_or_init()Automatic on first use
Good for runtime setupYesSometimes
Good for default global singletonYesYes, often simplest
Requires separate init functionOftenUsually no

Mutex vs RwLock

FeatureMutexRwLock
Multiple readers at onceNoYes
One writer at a timeYesYes
Simpler choiceYesSlightly more complex
Best for frequent writesUsuallySometimes less ideal

For beginners, LazyLock<Mutex<T>> is often the clearest safe default for a mutable singleton.

Take Quiz

Cheat Sheet

Quick patterns

Lazy global mutable singleton

use std::sync::{LazyLock, Mutex};

static STATE: LazyLock<Mutex<MyType>> =
    LazyLock::new(|| Mutex::new(MyType::new()));

One-time explicit initialization

use std::sync::{Mutex, OnceLock};

static STATE: OnceLock<Mutex<MyType>> = OnceLock::new();

STATE.set(Mutex::new(MyType::new())).unwrap();

Accessing the singleton

let mut state = STATE.lock().unwrap();

or with OnceLock:

let mut state = STATE.get().unwrap().lock().unwrap();

Rules

  • prefer safe abstractions over static mut
  • use Mutex<T> for shared mutable access
  • use RwLock<T> if reads are much more common than writes
  • use OnceLock<T> for one-time setup
  • use LazyLock<T> for lazy initialization
  • keep lock scopes short

Edge cases

  • OnceLock can only be initialized once
  • lock() can fail if the mutex is poisoned
  • RefCell<T> is not thread-safe
  • globals can make testing and design harder

Good default choice

static GLOBAL: LazyLock<Mutex<MyType>> =
    LazyLock::new(|| Mutex::new(MyType::new()));

FAQ

Is static mut a good way to make a singleton in Rust?

Usually no. It requires unsafe access and can easily lead to undefined behavior if used incorrectly.

What is the safest way to make a mutable global in Rust?

A common safe approach is LazyLock<Mutex<T>> or OnceLock<Mutex<T>>.

Why can't I just put a Vec in a simple global static and mutate it freely?

Because mutation of shared global state must be synchronized, and Rust prevents unsafely shared mutable access.

Should I use Mutex even if my type is only initialized once?

If the value is never mutated after initialization, you may not need Mutex. If it will be changed later, Mutex is a safe choice.

When should I use OnceLock instead of LazyLock?

Use OnceLock when initialization depends on runtime values or explicit startup logic. Use LazyLock when initialization can happen automatically on first access.

Is a singleton always bad design in Rust?

No, but it should be used only when there is truly one shared resource. Otherwise, passing dependencies explicitly is often clearer.

Can I use RefCell for a global singleton?

Only in single-threaded scenarios. For multi-threaded shared state, use Mutex or RwLock.

Take Quiz

Mini Project

Description

Build a tiny global resource manager that simulates part of a rendering subsystem. It will store a list of loaded texture names in one shared global instance. This demonstrates safe global mutable state, lazy initialization, and controlled mutation through a mutex.

Goal

Create a single global texture registry that can be updated and read from different functions safely.

Requirements

  1. Create a TextureRegistry struct that stores texture names in a Vec<String>.
  2. Store exactly one global instance using a safe Rust pattern.
  3. Add a function to register a texture name.
  4. Add a function to list all registered textures.
  5. Demonstrate usage from main by adding multiple textures and printing them.
Take Quiz

Keep learning

Related questions

View more