Свобода блокировки без сборки мусора

Перевод | Автор оригинала: Aaron Turon

TL;DR

Широко распространен фольклор, что одним из преимуществ сборки мусора является простота построения высокопроизводительных структур данных без блокировки. Ручное управление памятью для этих структур данных непросто, а сборщик мусора делает это тривиальным.

В этом посте показано, что с помощью Rust можно создать API управления памятью для параллельных структур данных, которые:

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

Я реализовал «восстановление памяти на основе эпох» в новой библиотеке Crossbeam, которая уже сегодня готова к использованию в ваших собственных структурах данных. В этом посте рассказывается о структурах данных без блокировок, алгоритме эпохи и обо всем API Rust.

Контрольные показатели

Прежде чем подробно изучить дизайн и использование API для восстановления эпох, давайте сразу перейдем к сути: производительности.

Чтобы протестировать накладные расходы моей реализации Crossbeam по сравнению с полным сборщиком мусора, я реализовал базовую свободную от блокировок очередь (обычную очередь Майкла-Скотта) поверх нее и построил такую же очередь в Scala. В общем, языки на основе JVM являются хорошим тестовым примером для «хорошего GC» пути к свободным от блокировок структурам данных.

В дополнение к этим реализациям я сравнивал с:

Я тестировал эти очереди двумя способами:

Подобные тесты довольно типичны для измерения масштабируемости структуры данных без блокировок в условиях «конкуренции» - несколько потоков конкурируют за одновременное выполнение одновременных обновлений. Есть много вариантов, которые следует протестировать при построении реализации производственной очереди; цель здесь - просто измерить приблизительные накладные расходы схемы управления памятью.

Для теста MPSC я также сравнил с алгоритмом, используемым во встроенных каналах Rust, который оптимизирован для этого сценария (и, следовательно, не поддерживает MPMC).

Машина представляет собой 4-ядерный Intel Core i7 с тактовой частотой 2,6 ГГц и 16 ГБ оперативной памяти.

Вот результаты в наносекундах на сообщение (чем меньше, тем лучше):

Анализ

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

Обратите внимание, что версии Java / Scala в тесте MPMC работают намного лучше, чем в тесте MPSC. Это почему?

Ответ прост: сборка мусора. В тесте MPSC производители, как правило, со временем опережают потребителя, а это означает, что объем данных в очереди медленно растет. Это, в свою очередь, увеличивает стоимость каждой сборки мусора, которая включает в себя просмотр живого набора данных.

В схеме эпох, напротив, стоимость управления мусором относительно фиксирована: она пропорциональна количеству потоков, а не количеству текущих данных. Это дает как лучшую, так и более стабильную / предсказуемую производительность.

Наконец, одно сравнение, которое я не включил в диаграмму (потому что оно затмило бы другие), было использование мьютекса вокруг двухсторонней очереди в Rust. Для теста MPMC производительность составила около 3040 нс / операцию, что более чем в 20 раз ниже, чем у реализации Crossbeam. Это наглядная демонстрация того, почему структура данных без блокировок важна, поэтому давайте начнем с погружения в то, что это такое.

Свободные от блокировок структуры данных

Если вы хотите использовать (и изменять) структуру данных из множества параллельных потоков, вам нужна синхронизация. Самое простое решение - глобальная блокировка - в Rust обернуть всю структуру данных в мьютекс и вызвать его.

Проблема в том, что такая «грубая» синхронизация означает, что нескольким потокам всегда необходимо координировать свои действия при доступе к структуре данных, даже если они обращались к ее непересекающимся частям. Это также означает, что даже когда поток пытается только читать, он должен писать, обновляя состояние блокировки - и поскольку блокировка является глобальной точкой связи, эти записи приводят к большому объему трафика аннулирования кеша. Даже если вы используете много точных блокировок, есть и другие опасности, такие как тупик и инверсия приоритета, и вы часто все равно оставляете производительность на столе.

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

Я не буду пытаться давать полное руководство по программированию без блокировок в этом посте, но ключевым моментом является то, что если у вас нет глобальной синхронизации, очень сложно определить, когда можно освободить память. Многие опубликованные алгоритмы в основном предполагают использование сборщика мусора или некоторых других средств освобождения памяти. Итак, прежде чем безблокирующий параллелизм действительно сможет стать популярным в Rust, нам нужна история для восстановления памяти - и именно об этом этот пост в блоге.

Стек Трейбера

Чтобы сделать вещи более конкретными, давайте посмотрим на «Привет, мир» структур данных без блокировок: стек Трейбера. Стек представлен в виде односвязного списка со всеми изменениями, происходящими в указателе заголовка:

#![feature(box_raw)]

use std::ptr::{self, null_mut};
use std::sync::atomic::AtomicPtr;
use std::sync::atomic::Ordering::{Relaxed, Release, Acquire};

pub struct Stack<T> {
    head: AtomicPtr<Node<T>>,
}

struct Node<T> {
    data: T,
    next: *mut Node<T>,
}

impl<T> Stack<T> {
    pub fn new() -> Stack<T> {
        Stack {
            head: AtomicPtr::new(null_mut()),
        }
    }
}

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

 Обратите внимание, что compare_and_swap атомарно изменяет значение AtomicPtr со старого значения на новое значение, если старое значение совпадает. Кроме того, в этой публикации вы можете игнорировать ярлыки Acquire, Release и Relaxed, если вы с ними не знакомы. 
impl<T> Stack<T> {
    pub fn pop(&self) -> Option<T> {
        loop {
            // take a snapshot
            let head = self.head.load(Acquire);

            // we observed the stack empty
            if head == null_mut() {
                return None
            } else {
                let next = unsafe { (*head).next };

                // if snapshot is still good, update from `head` to `next`
                if self.head.compare_and_swap(head, next, Release) == head {

                    // extract out the data from the now-unlinked node
                    // **NOTE**: leaks the node!
                    return Some(unsafe { ptr::read(&(*head).data) })
                }
            }
        }
    }
}

Функция ptr::read - это способ Rust извлекать право собственности на данные без статического или динамического отслеживания. Здесь мы используем атомарность compare_and_swap, чтобы гарантировать, что только один поток вызовет ptr::read - и, как мы увидим, эта реализация никогда не освобождает узлы, поэтому деструктор данных никогда не вызывается. Эти два факта вместе делают использование ptr::read безопасным.

Нажатие аналогично:

impl<T> Stack<T> {
    pub fn push(&self, t: T) {
        // allocate the node, and immediately turn it into a *mut pointer
        let n = Box::into_raw(Box::new(Node {
            data: t,
            next: null_mut(),
        }));
        loop {
            // snapshot current head
            let head = self.head.load(Relaxed);

            // update `next` pointer with snapshot
            unsafe { (*n).next = head; }

            // if snapshot is still good, link in new node
            if self.head.compare_and_swap(head, n, Release) == head {
                break
            }
        }
    }
}

Проблема

Если бы мы закодировали вышеупомянутое на языке с GC, все было бы. Но, как написано на Rust, утечка памяти. В частности, реализация pop не пытается освободить указатель узла после удаления его из стека.

Что бы пошло не так, если бы мы сделали именно это?

// extract out the data from the now-unlinked node
let ret = Some(unsafe { ptr::read(&(*head).data) });

// free the node
mem::drop(Box::from_raw(head));

return ret

Проблема в том, что другие потоки также могут запускать pop одновременно. Эти потоки могут иметь снимок текущей головы; ничто не помешает им прочитать (* head) .next на этом снимке сразу после того, как мы освободим узел, на который они указывают - ошибка использования после освобождения в процессе создания!

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

Мелиорация по эпохам

Есть несколько способов управления памятью для незаблокированного кода, не основанных на GC, но все они сводятся к одним и тем же основным наблюдениям:

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

Основная идея состоит в том, чтобы спрятать узлы, которые были отключены от структуры данных (первый источник достижимости), до тех пор, пока их можно будет безопасно удалить. Прежде чем мы сможем удалить зашифрованный узел, нам нужно знать, что все потоки, которые обращались к структуре данных в то время, завершили операцию, которую они выполняли. Согласно наблюдению 2 выше, это будет означать, что больше не осталось никаких снимков (поскольку за это время не могло быть создано никаких новых снимков). Самое сложное - делать все это без особой синхронизации. В противном случае мы потеряем ту выгоду, которую изначально должна была приносить свобода от блокировок!

Схема эпох работает, имея:

  1. Глобальный счетчик эпох (принимающий значения 0, 1 и 2);
  2. Глобальный список мусора для каждой эпохи;
  3. «Активный» флаг для каждого потока;
  4. Счетчик эпох для каждого потока.

Эпохи используются, чтобы обнаружить, когда мусор можно безопасно освободить, потому что ни один поток не может до него добраться. В отличие от традиционного GC, это не требует просмотра данных в реальном времени; это просто вопрос проверки отсчета эпох.

Когда поток хочет выполнить операцию над структурой данных, он сначала устанавливает свой «активный» флаг, а затем обновляет свою локальную эпоху, чтобы она соответствовала глобальной. Если поток удаляет узел из структуры данных, он добавляет этот узел в список мусора для текущей глобальной эпохи. (Примечание: очень важно, чтобы мусор попадал в текущую глобальную эпоху, а не в предыдущий локальный снимок.) Когда он завершает свою операцию, он сбрасывает «активный» флаг.

Чтобы попытаться собрать мусор (что можно сделать в любой момент), поток проходит по флагам для всех участвующих потоков и проверяет, все ли активные потоки находятся в текущей эпохе. Если это так, он может попытаться увеличить глобальную эпоху (по модулю 3). Если приращение прошло успешно, мусор двух эпох назад может быть удален.

Зачем нам три эпохи? Поскольку «сборка мусора» выполняется одновременно, потоки могут находиться в одной из двух эпох в любое время («старая» и «новая»). Но поскольку мы проверяем, что все активные потоки находятся в старой эпохе, прежде чем увеличивать ее, мы гарантируем, что в третьей эпохе нет активных потоков.

Эта схема тщательно спроектирована таким образом, чтобы в большинстве случаев потоки касались данных, которые уже находятся в кеше или (обычно) являются локальными для потока. Только выполнение «GC» включает в себя изменение глобальной эпохи или чтение эпох других потоков. Эпохальный подход также не зависит от алгоритмов, прост в использовании и по своим характеристикам не уступает другим подходам.

Он также отлично подходит для системы владения Rust.

API Rust

Мы хотим, чтобы Rust API отражал основные принципы рекультивации на основе эпох:

Мы будем использовать систему владения Rust, в частности, управление ресурсами на основе владения (также известное как RAII), чтобы зафиксировать эти ограничения непосредственно в сигнатурах типов API эпохи. Это, в свою очередь, поможет нам правильно использовать управление эпохами.

Сторожить

Чтобы работать с структурой данных без блокировок, вы сначала приобретаете охранник, который представляет собой принадлежащее значение, которое представляет ваш активный поток:

pub struct Guard { ... }
pub fn pin() -> Guard;

Функция pin помечает поток как активный, загружает глобальную эпоху и может попытаться выполнить сборку мусора (подробнее подробнее в этой статье). С другой стороны, деструктор Guard завершает управление эпохой, помечая поток как неактивный.

Поскольку Guard представляет «активность», заимствование &'Guard гарантирует, что поток активен в течение всего времени жизни' a - именно то, что нам нужно, чтобы ограничить время жизни моментальных снимков, сделанных в алгоритме без блокировки.

Чтобы использовать Guard, Crossbeam предоставляет набор из трех типов указателей, предназначенных для совместной работы:

Мы рассмотрим каждый из них по очереди.

Собственные и общие указатели

Указатель Owned имеет интерфейс, почти идентичный Box:

pub struct Owned<T> { ... }

impl<T> Owned<T> {
    pub fn new(t: T) -> Owned<T>;
}

impl<T> Deref for Owned<T> {
    type Target = T;
    ...
}
impl<T> DerefMut for Owned<T> { ... }

Общий указатель <'a, T> похож на &' a T - это копия, но он разыменован на &'a T. Это несколько хакерский способ передать, что время жизни указателя, которое он предоставляет, на самом деле 'а.

pub struct Shared<'a, T: 'a> { ... }

impl<'a, T> Copy for Shared<'a, T> { ... }
impl<'a, T> Clone for Shared<'a, T> { ... }

impl<'a, T> Deref for Shared<'a, T> {
    type Target = &'a T;
    ...
}

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

Атомный

Ядром библиотеки является Atomic, который обеспечивает атомарный доступ к указателю (допускающему значение NULL) и соединяет вместе все другие типы библиотеки:

pub struct Atomic<T> { ... }

impl<T> Atomic<T> {
    /// Create a new, null atomic pointer.
    pub fn null() -> Atomic<T>;
}

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

Загрузка

Во-первых, загрузка из Atomic:

impl<T> Atomic<T> {
    pub fn load<'a>(&self, ord: Ordering, _: &'a Guard) -> Option<Shared<'a, T>>;
}

Для того, чтобы выполнить нагрузку, мы должны заимствовать Стража. Как объяснялось выше, это способ гарантировать, что поток будет активен в течение всего времени жизни 'a. Взамен вы получаете необязательный общий указатель обратно (None, если Atomic в настоящее время равен нулю), время жизни которого привязано к охраннику.

Интересно сравнить это с интерфейсом AtomicPtr стандартной библиотеки, где load возвращает * mut T. Благодаря использованию эпох мы можем гарантировать безопасное разыменование указателя внутри 'a, тогда как с AtomicPtr все ставки отключены.

Хранение

Хранение немного сложнее из-за наличия в игре нескольких типов указателей.

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

impl<T> Atomic<T> {
    pub fn store(&self, val: Option<Owned<T>>, ord: Ordering);
}

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

impl<T> Atomic<T> {
    pub fn store_and_ref<'a>(&self,
                             val: Owned<T>,
                             ord: Ordering,
                             _: &'a Guard)
                             -> Shared<'a, T>;
}

Обратите внимание, что представление val во время выполнения и возвращаемое значение совершенно одинаковы - мы передаем указатель и получаем тот же указатель. Но на этом этапе ситуация с собственностью с точки зрения Rust радикально меняется.

Наконец, мы можем сохранить общий указатель обратно в структуру данных:

impl<T> Atomic<T> {
    pub fn store_shared(&self, val: Option<Shared<T>>, ord: Ordering);
}

Эта операция не требует защиты, потому что мы не получаем никакой новой информации о времени жизни указателя.

CAS

Далее у нас есть аналогичное семейство операций сравнения и установки. Самый простой случай - это замена общего указателя на новый собственный:

impl<T> Atomic<T> {
    pub fn cas(&self,
               old: Option<Shared<T>>,
               new: Option<Owned<T>>,
               ord: Ordering)
               -> Result<(), Option<Owned<T>>>;
}

Как и в случае с магазином, для этой операции не требуется охрана; он не дает новой информации о сроке службы. Результат указывает, удалось ли CAS выполнить; в противном случае право собственности на новый указатель возвращается вызывающей стороне.

Затем у нас есть аналог store_and_ref:

impl<T> Atomic<T> {
    pub fn cas_and_ref<'a>(&self,
                           old: Option<Shared<T>>,
                           new: Owned<T>,
                           ord: Ordering,
                           _: &'a Guard)
                           -> Result<Shared<'a, T>, Owned<T>>;

В этом случае при успешном CAS мы получаем общий указатель на данные, которые мы вставили.

Наконец, мы можем заменить один общий указатель на другой:

impl<T> Atomic<T> {
    pub fn cas_shared(&self,
                             old: Option<Shared<T>>,
                             new: Option<Shared<T>>,
                             ord: Ordering)
                             -> bool;
}

Логическое возвращаемое значение истинно, когда CAS успешно.

Освобождение памяти

Конечно, все вышеперечисленные механизмы служат конечной цели: фактически освобождают память, которая больше недоступна. Когда узел был отключен от структуры данных, поток, который отключил его, может сообщить своему Guard, что память должна быть освобождена:

impl Guard {
    pub unsafe fn unlinked<T>(&self, val: Shared<T>);
}

Эта операция добавляет общий указатель к соответствующему списку мусора, позволяя освободить его двумя эпохами позже.

Операция небезопасна, поскольку утверждает, что:

Однако важно то, что другие потоки могут продолжать ссылаться на этот общий указатель; система эпохи гарантирует, что ни один поток не сделает этого к моменту фактического освобождения указателя.

Здесь нет особой связи между временем жизни Shared указателя и Guard; если у нас есть достижимый общий указатель, мы знаем, что охранник, от которого он исходит, активен.

Стек Трейбера по эпохам

Без лишних слов, вот код для стека Трейбера с использованием API эпохи Crossbeam:

use std::sync::atomic::Ordering::{Acquire, Release, Relaxed};
use std::ptr;

use crossbeam::mem::epoch::{self, Atomic, Owned};

pub struct TreiberStack<T> {
    head: Atomic<Node<T>>,
}

struct Node<T> {
    data: T,
    next: Atomic<Node<T>>,
}

impl<T> TreiberStack<T> {
    pub fn new() -> TreiberStack<T> {
        TreiberStack {
            head: Atomic::new()
        }
    }

    pub fn push(&self, t: T) {
        // allocate the node via Owned
        let mut n = Owned::new(Node {
            data: t,
            next: Atomic::new(),
        });

        // become active
        let guard = epoch::pin();

        loop {
            // snapshot current head
            let head = self.head.load(Relaxed, &guard);

            // update `next` pointer with snapshot
            n.next.store_shared(head, Relaxed);

            // if snapshot is still good, link in the new node
            match self.head.cas_and_ref(head, n, Release, &guard) {
                Ok(_) => return,
                Err(owned) => n = owned,
            }
        }
    }

    pub fn pop(&self) -> Option<T> {
        // become active
        let guard = epoch::pin();

        loop {
            // take a snapshot
            match self.head.load(Acquire, &guard) {
                // the stack is non-empty
                Some(head) => {
                    // read through the snapshot, *safely*!
                    let next = head.next.load(Relaxed, &guard);

                    // if snapshot is still good, update from `head` to `next`
                    if self.head.cas_shared(Some(head), next, Release) {
                        unsafe {
                            // mark the node as unlinked
                            guard.unlinked(head);

                            // extract out the data from the now-unlinked node
                            return Some(ptr::read(&(*head).data))
                        }
                    }
                }

                // we observed the stack empty
                None => return None
            }
        }
    }
}

Некоторые замечания:

Последний пункт о высвобождении ресурсов заслуживает более подробного комментария, поэтому давайте завершим описание API, поговорив о мусоре.

Управление мусором

Дизайн в Crossbeam рассматривает управление эпохой как услугу, совместно используемую всеми структурами данных: существует одна статическая модель для глобального состояния эпохи и один локальный поток для состояния каждого потока. Это делает epoch API очень простым в использовании, поскольку не требует настройки структуры данных. Это также означает, что (довольно тривиальное) использование пространства связано с количеством потоков, использующих эпохи, а не с количеством структур данных.

Одним из отличий реализации Crossbeam от существующей литературы по эпохам является то, что каждый поток хранит локальные списки мусора. То есть, когда поток отмечает узел как «несвязанный», этот узел добавляется к некоторым локальным данным потока, а не сразу к глобальному списку мусора (что потребует дополнительной синхронизации).

Каждый раз, когда вы вызываете epoch::pin(), текущий поток проверяет, не превысил ли его локальный мусор пороговое значение для сбора, и если да, он попытается выполнить сборку. Аналогично, всякий раз, когда вы вызываете epoch::pin(), если глобальная эпоха продвинулась дальше предыдущего снимка, текущий поток может собрать часть своего мусора. Помимо предотвращения глобальной синхронизации списков мусора, эта новая схема распределяет работу по фактическому освобождению памяти между всеми потоками, обращающимися к структуре данных.

Поскольку сборщик мусора может происходить только в том случае, если все активные потоки относятся к текущей эпохе, сбор не всегда возможен. Но на практике количество мусора в данном потоке редко превышает пороговое значение.

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

Наконец, что значит «собирать» мусор? Как упоминалось выше, библиотека только освобождает память; он не запускает деструкторы.

Концептуально фреймворк разделяет разрушение объекта на две части: уничтожение / удаление внутренних данных и освобождение объекта, содержащего их. Первое должно происходить одновременно с вызовом несвязанного - это момент, когда существует уникальный поток, который владеет объектом во всех смыслах, кроме возможности фактически освободить его. Последнее происходит в какой-то неизвестный более поздний момент, когда известно, что на объект больше нет ссылок. Это налагает на пользователя обязательство: доступ через моментальный снимок должен только читать данные, которые будут действительны до освобождения. Но это в основном всегда относится к структурам данных без блокировки, которые, как правило, имеют четкое разделение между данными, относящимися к контейнеру (то есть атомными полями), и фактическими содержащимися данными (например, полем данных в Node).

Разделение разрыва объекта таким образом означает, что деструкторы работают синхронно, в предсказуемое время, облегчая одну из болевых точек GC и позволяя использовать фреймворк с нестатическими (и не отправляемыми) данными.

Дорога впереди

Crossbeam все еще находится в зачаточном состоянии. Работа здесь закладывает основу для изучения широкого спектра структур данных без блокировок в Rust, и я надеюсь, что Crossbeam в конечном итоге сыграет роль, аналогичную java.util.concurrent для Rust, включая хэш-карту без блокировок, work- воровство дек и легкий движок задач. Если вам интересна эта работа, я буду рад получить помощь!