Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Глобальные данные

Теперь, когда мы разобрались с многопоточностью и понимаем проблемы синхронизации доступа к данным, мы готовы рассмотреть работу с глобальными данными.

Как мы уже знаем, в Rust есть два вида глобальных данных: константы и статические данные.

Константы крайне просты в использовании, потому что они:

  • доступны с самого первого момента работы программы (имеют 'static лайфтайм)
  • неизменяемы, а значит никакие ссылочные ограничения на них не распространяются

Однако константы имеют существенные ограничения:

  • значение константы должно быть вычислено на этапе компиляции, а значит константой не может быть тип данных, который использует кучу (например, HashMap)
  • иногда необходимо менять значение глобальных данных во время работы программы, но константы этого не позволяют

Глобальные статические переменные

Если в глобальной переменной необходимо хранить данные, которые нельзя инициализировать на этапе компиляции, или необходимо, чтобы глобальная переменная была мутабельной, то придётся использовать глобальную статическую переменную.

Мы уже видели статические переменные внутри функций. Глобальные статические переменные отличаются лишь тем, что они доступны не только в пределах одной функции, а из всей программы. Всё остальное — одинаково.

Статическая глобальная переменная, как и локальная, объявляется при помощи ключевого слова static.

static VAR1: i32 = 1;
static mut VAR2: i32 = 2;

Напомним, что:

  • Имена статических переменных записываются заглавными буквами.
  • Статические переменные могут быть как мутабельными, так и немутабельными.
  • Тип переменной обязателен, даже если компилятор может однозначно вывести его из значения.

Чтение и запись в статическую мутабельную переменную считается потенциально опасной операцией, так как эта переменная может быть одновременно использована из разных потоков. Именно поэтому читать или изменять значение статической мутабельной переменной можно только внутри unsafe блока.

static mut VAR1: i32 = 0;

fn inc_var() {
    unsafe {
        VAR1 += 1;
    }
}

fn get_var() -> i32 {
    unsafe {
        VAR1
    }
}

fn main() {
    println!("{}", get_var()); // 0
    inc_var();
    println!("{}", get_var()); // 1
}

На этом этапе уже становится понятным, что использовать мутабельные статические переменные без некоего механизма синхронизации — плохая затея.

Синхронизация глобальных переменных

Из главы про многопоточность мы уже знаем, что разделяемую переменную можно синхронизировать при помощи мьютекса или RwLock. Это работает не только для локальных переменных, захватываемых замыканием, но и для глобальных.

Рассмотрим пример синхронизации доступа к глобальной статической переменной при помощи мьютекса.

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

static COUNTER: Mutex<u64> = Mutex::new(0);

fn main() {
    thread::scope(|s| {
        s.spawn(|| {
            for i in 0..100 {
                let mut guard = COUNTER.lock().unwrap();
                let curr_val = *guard;
                thread::sleep(Duration::from_millis(10));
                *guard = curr_val + 1;
            }
        });
        s.spawn(|| {
            for i in 0..100 {
                let mut guard = COUNTER.lock().unwrap();
                let curr_val = *guard;
                thread::sleep(Duration::from_millis(10));
                *guard = curr_val + 1;
            }
        });
    });

    println!("{}", *COUNTER.lock().unwrap()); // 200
}

Как видим, потоки работают с глобально объявленным мьютексом COUNTER точно так же, как если бы он был создан в теле функции main.

LazyLock

В примере выше мы синхронизировали доступ к глобальной переменной такого типа, который не использует кучу — i32. Однако если мы попытаемся таким же образом создать, например, глобальную хеш-таблицу, то получим ошибку компиляции:

use std::{collections::HashMap, sync::Mutex};

static m: Mutex<HashMap<String, i32>> = Mutex::new(HashMap::new());

fn main() {}

ошибка:

error[E0015]: cannot call non-const associated function `HashMap::<String, i32>::new` in statics
 --> src/main.rs:3:52
  |
3 | static m: Mutex<HashMap<String, i32>> = Mutex::new(HashMap::new());
  |                                                    ^^^^^^^^^^^^^^
  |
  = note: calls in statics are limited to constant functions, tuple structs and tuple variants
  = note: consider wrapping this expression in `std::sync::LazyLock::new(|| ...)`

Сообщение от компилятора поясняет, что конструировать начальное значение статической переменной можно только при помощи константной функции, коей является Mutex::new, но не является HashMap::new.

Компилятор также любезно предлагает нам решить проблему путём использования LazyLock.

LazyLock — обёртка, которая позволяет отложить инициализацию значения до момента, когда значение будет затребовано впервые. То есть перенести фактическую инициализацию из времени компиляции во время исполнения.

Значение LazyLock создаётся константной функцией LazyLock::new, что позволяет использовать её для инициализации статической глобальной переменной.

pub const fn new(f: F) -> LazyLock<T, F>

В качестве аргумента она принимает замыкание, которое должно сконструировать нужный нам объект. Это замыкание будет вызвано при первом обращении к значению LazyLock.

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

// Создаём хеш-таблицу, синхронизированную при помощи мьютекса,
// и инициализированную при помощи LazyLock
static M: LazyLock<Mutex<HashMap<String, i32>>> = LazyLock::new(
    || Mutex::new(HashMap::new())
);

fn main() {
    { // добавляем элемент в хеш таблицу
        let mut guard = M.lock().unwrap();
        guard.insert("one".to_string(), 1);
    }
    { // считываем значения хеш-таблицы
        let guard = M.lock().unwrap();
        println!("{:?}", *guard); // {"one": 1}
    }
}

LazyLock реализует трэйт Deref, что позволяет работать с ним прозрачно: словно мы работаем с его содержимым напрямую.

Note

Если с непривычки трёхэтажный генерик LazyLock<Mutex<HashMap<String, i32>>> выглядит для вас устрашающе, не переживайте: вы быстро начнёте ценить явность таких объявлений.

Для синхронизации LazyLock используется double-checked locking, что позволяет безопасно использовать его в многопоточной среде.

OnceLock

LazyLock решает проблему инициализации глобальной переменной “сложного” типа, однако он не подходит для случаев, если глобальная переменная может быть инициализирована из нескольких разных мест в коде. Например, в зависимости от каких-то дополнительных условий должны вызываться разные функции, которые инициализируют глобальную переменную по-разному. В такой ситуации нам поможет OnceLock.

OnceLock служит той же цели, что и LazyLock, однако инициализирует своё значение не при первом обращении, а путём явного вызова метода set. Метод set устанавливает значение OnceLock только если это самый первый вызов set. В противном случае вызов set игнорируется.

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

static O: OnceLock<Mutex<String>> = OnceLock::new();

fn main() {
    { // Инициализируем значение OnceLock
        let r = O.set(Mutex::new("1".to_string()));
        if let Err(e) = r {
            eprintln!("Cannot init with: {e:?}");
        }
    }
    { // Попытка повторной инициализации OnceLock
        let r = O.set(Mutex::new("2".to_string()));
        if let Err(e) = r {
            eprintln!("Cannot init with: {e:?}");
        }
    }
    {
        let mutex = O.get().unwrap();
        let guard = mutex.lock().unwrap();
        println!("OnceLock = {:?}", *guard);
    }
}

Эта программа напечатает:

Cannot init with: Mutex { data: "2", poisoned: false, .. }
OnceLock = "1"

Важно заметить, что вызов get на неинициализированном объекте OnceLock приведёт к панике.

Также для OnceLock есть метод get_or_init, который принимает замыкание для генерации значения по умолчанию. Если OnceLock был проинициализирован, то get_or_init вернёт ссылку на хранящееся в нём значение, иначе использует замыкание для инициализации, а затем вернёт ссылку на только что созданное значение.

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

static O: OnceLock<Mutex<String>> = OnceLock::new();

fn main() {
    {
        let mutex = O.get_or_init(|| Mutex::new("default".to_string()));
        let guard = mutex.lock().unwrap();
        println!("OnceLock = {:?}", *guard);
    }
}

Пример хранения сессий пользователей

Теперь давайте рассмотрим пример, который часто возникает при написании бекенд приложений: хранение сессий пользователей.

Допустим, данные пользователя будут представлены некой структурой UserSession (конкретная реализация для нас не важна), и каждая сессия будет идентифицирована уникальным строковым ключом, например UUID.

В качестве основы для такого хранилища сессий удобно взять HashMap<String, UserSession>. Например, эта хеш-таблица может принадлежать некому объекту синглтону (например, структуре SessionManager), или быть глобальной статической переменной. Для большего раскрытия темы давайте использовать статическую переменную.

Мы уже знаем, что нам потребуется синхронизация доступа к этой хеш-таблице. Новые сессии создаются гораздо реже, чем читаются существующие, поэтому в нашем случае RwLock будет эффективнее, чем Mutex. Также мы знаем, что нам понадобится LazyLock.

static SESSIONS: LazyLock<RwLock<HashMap<String, UserSession>>> =
    LazyLock::new(|| RwLock::new(HashMap::new()));

Бекенд приложения могут одновременно обрабатывать тысячи запросов от пользователей, и при обработке каждого запроса данные сессии могут требоваться многократно, поэтому такое хранилище сессий может стать “узким местом”. Надо сделать так, чтобы в самом начале обработки запроса обработчик получал объект сессии пользователя и дальше работал с ним, не захватывая (пусть и на чтение) весь RwLock. Мы можем достичь этого путём заворачивания объекта сессии в Arc.

static SESSIONS: LazyLock<RwLock<HashMap<String, Arc<UserSession>>>> =
    LazyLock::new(|| RwLock::new(HashMap::new()));

Теперь обработчик может получить умный указатель на нужный ему объект сессии и работать с ним.

fn serve_request(r: Request) -> Response {
    let session: Arc<UserSession> = {
        // Захватываем хеш-таблицу для чтения
        let hash_table = SESSIONS.read().unwrap();
        if let Some(session_ref) = hash_table.get(r.get_session_id()) {
            session_ref.clone(); // клонируем Arc
        } else {
            // Сессия с таким ID не найдена в хеш-таблице
            return Response::error_301();
        }
    };
    // Здесь уже спокойно работаем с сессией не блокируя RwLock
    Response::ok_200()
}

Теперь мы захватываем RwLock на небольшой промежуток времени, чтобы достать объект сессии из хеш-таблицы, после чего сразу отпускаем его. Однако у нас появилась другая проблема: Arc не является синхронизированным, поэтому не позволяет менять своё содержимое, а мы хотим, чтобы обработчик запроса имел возможность менять соответствующий ему объект сессии.

Это ограничение со стороны Arc более чем справедливо: теоретически мы одновременно можем обрабатывать несколько запросов от одного и того же пользователя, и мы не хотим, чтобы эти обработчики повредили данные в сессии из-за несинхронизированного доступа.

К счастью, мы уже знаем решение этой проблемы: нужно поместить Mutex или RwLock внутрь Arc. Допустим, что обработчикам запросов лучше иметь эксклюзивный доступ к данным сессии, поэтому будем использовать Mutex.

static SESSIONS: LazyLock<RwLock<HashMap<String, Arc<Mutex<UserSession>>>>> =
    LazyLock::new(|| RwLock::new(HashMap::new()));

Теперь наш обработчик запроса от пользователя может иметь вид:

fn serve_request(r: Request) -> Response {
    let session_mutex: Arc<Mutex<UserSession>> = {
        let hash_table = SESSIONS.read().unwrap();
        if let Some(session_ref) = hash_table.get(r.get_session_id()) {
            session_ref.clone();
        } else {
            return Response::error_301();
        }
    };
    // Извлекаем некое значение из сессии
    let mut some_value = {
        let guard = session_mutex.lock().unwrap();
        (*guard).some_field.clone();
    };
    // Какие-то вычисления с участием some_value
    {
        // обновляем данные сессии
        let guard = session_mutex.lock().unwrap();
        // Тут хорошо бы проверить, изменилось ли поле some_field
        // другим параллельным обработчиком, который возможно существует
        (*guard).some_field = some_value;
    }
    Response::ok_200()
}

Разумеется, мы используем unwrap на объектах RwLock и Mutex исключительно для простоты примера. В реальном приложении необходимо проверять возможную ошибку.