Как стать автором
Обновить

Многопоточность в Rust

Время на прочтение 14 мин
Количество просмотров 37K
Автор оригинала: Aaron Turon
Rust начинался как проект, решающий две трудные проблемы:

  • Как обеспечить безопасность (работы с памятью) в системном программировании?
  • Как сделать многопоточное программирование безболезненным?

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

Ошибки работы с памятью и ошибки при работе с несколькими потоками частно сводятся к тому, что код обращается к некоторым данным вопреки тому, что он не должен этого делать. Секретное оружие Rust против этого — концепция владения данными, способ управления доступом к данным, которого системные программисты стараются придерживаться самостоятельно, но который Rust проверяет статически.

С точки зрения безопасности работы с памятью это означает, что вы можете не использовать сборщик мусора и в то же время не опасаться сегфолтов, потому что Rust не даст вам совершить ошибку.

С точки зрения многопоточности это означает, что вы можете пользоваться различными парадигмами (передача сообщений, разделяемое состояние, lock-free-структуры данных, чистое функциональное программирование), и Rust позволит избежать наиболее распространённых подводных камней.

Вот какие особенности у многопоточного программирования в Rust:

  • Каналы (channels) передают право владения данными, которые пересылаются через них, поэтому вы можете отправить через канал указатель из одного потока в другой и не бояться, что между этими потоками возникнет гонка за доступ через этот указатель. Каналы Rust обеспечивают изоляцию потоков.
  • Блокировки (lock'и) владеют защищаемыми ими данными, и Rust гарантирует, что доступ к этим данным можно получить только тогда, когда блокировка захвачена. Состояние никогда не разделяется между потоками случайно. Концепция "синхронизируйте данные, а не код" в Rust обязательна.
  • Для каждого типа данных известно, можно ли его пересылать между потоками или можно ли к нему обращаться из нескольких потоков одновременно, и Rust обеспечивает безопасность этих действий; поэтому гонки данных исключаются, даже для lock-free-структур данных. Потокобезопасность не просто отражается в документации — она является законом.
  • Более того, вы можете использовать стек одного потока из другого, и Rust статически обеспечит его существование до тех пор, пока другие потоки используют его. Даже самые рискованные формы разделения данных гарантированно безопасны в Rust.

Все эти преимущества вытекают из модели владения данными, и все вышеописанные блокировки, каналы, lock-free-структуры данных и прочее определены в библиотеках, а не в самом языке. Это значит, что подход Rust к многопоточности весьма расширяем — новые библиотеки могут реализовывать другие парадигмы и помогать в предотвращении новых классов ошибок, просто предоставляя новый API, основанный на фичах Rust, связанных с владением данными.

Цель этого поста — показать, как это делается.

Основы: владение данными


Мы начнём с обзора систем владения и заимствования данных в Rust. Если вы уже знакомы с ними, то вы можете пропустить обе части "основ" и перейти непосредственно к многопоточности. Если же вы захотите поглубже разобраться в этих концепциях, я очень рекомендую вот эту статью, написанную Yehuda Katz. В официальной книге Rust вы найдёте ещё более подробные объяснения.

В Rust у каждого значения есть "область владения", и передача или возврат значения означает передачу права владения ("перемещение") в новую область. Когда область заканчивается, то все значения, которыми она владеет к этому моменту, уничтожаются.

Рассмотрим несколько простых примеров. Предположим, мы создаём вектор и помещаем в него несколько элементов:

fn make_vec() {
    let mut vec = Vec::new();   // принадлежит области видимости make_vec
    vec.push(0);
    vec.push(1);
    // область видимости заканчивается, `vec` уничтожается
}

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

Становится интереснее, если вектор передаётся в другую функцию или возвращается из функции:

fn make_vec() -> Vec<i32> {
    let mut vec = Vec::new();
    vec.push(0);
    vec.push(1);
    vec // передаём право владения вызывающей функции
}

fn print_vec(vec: Vec<i32>) {
    // параметр `vec` является частью этой области видимости,
    // поэтому он принадлежит `print_vec`

    for i in vec.iter() {
        println!("{}", i)
    }

    // теперь `vec` уничтожается
}

fn use_vec() {
    let vec = make_vec(); // получаем право владения вектором
    print_vec(vec);       // передаём его в `print_vec`
}

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

С другой стороны, функция print_vec принимает параметр vec, и право владения передаётся в неё вызывающей функцией. Поскольку print_vec никуда дальше не передаёт право владения vec, при выходе из этой области видимости вектор уничтожается.

Как только право владения значением передано куда-то ещё, его нельзя больше использовать. Например, рассмотрим такой вариант функции use_vec:

fn use_vec() {
    let vec = make_vec();  // получаем право владения вектором
    print_vec(vec);        // передаём его в `print_vec`

    for i in vec.iter() {  // продолжаем использовать `vec`
        println!("{}", i * 2)
    }
}

Если вы попробуете скомпилировать этот вариант, компилятор выдаст ошибку:

error: use of moved value: `vec`

for i in vec.iter() {
         ^~~

Компилятор сообщает, что vec больше недоступен — право владения передано куда-то ещё. И это очень хорошо, потому что к этому моменту вектор уже уничтожен.

Катастрофа предотвращена.

Основы: заимствование


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

Здесь нам и понадобится заимствование. В Rust если у вас есть значение, вы можете дать временный доступ к нему функциям, которые вы вызываете. Rust автоматически проверит, что эти "займы" не будут действовать дольше, чем "живёт" объект, который заимствуется.

Чтобы позаимствовать значение, нужно создать ссылку на него (ссылка — один из видов указателей) при помощи оператора &:

fn print_vec(vec: &Vec<i32>) {
    // параметр `vec` заимствуется на протяжении
    // этой области видимости

    for i in vec.iter() {
        println!("{}", i)
    }

    // здесь срок заимствования заканчивается
}

fn use_vec() {
    let vec = make_vec();  // получаем право владения вектором
    print_vec(&vec);       // предоставляем к нему доступ из `print_vec`
    for i in vec.iter() {  // продолжаем использовать `vec`
        println!("{}", i * 2)
    }
    // здесь vec уничтожается
}

Теперь print_vec принимает ссылку на вектор, и use_vec отдаёт вектор "взаймы": &vec. Поскольку заимствования временные, use_vec сохраняет право владения вектором и может продолжить его использовать после того, как print_vec вернёт управление (и срок заимствования vec истёк).

Каждая ссылка действует только в определённой области видимости, которую компилятор определяет автоматически. Ссылки бывают двух видов.

  • Иммутабельная ссылка &T, которая допускает совместное использование, но запрещает изменения. На одно и то же значение может быть несколько &T-ссылок, но само значение изменять нельзя до тех пор, пока эти ссылки существуют.
  • Мутабельная ссылка &mut T, которая допускает изменение, но не совместное использование. Если на значение существует &mut T-ссылка, других ссылок в это время на это же самое значение быть не может, но зато значение можно изменять.

Rust проверяет, что эти правила выполняются, во время компиляции — у заимствования нет накладных расходов во время выполнения программы.

Зачем нужны два вида ссылок? Рассмотрим функцию следующего вида:

fn push_all(from: &Vec<i32>, to: &mut Vec<i32>) {
    for i in from.iter() {
        to.push(*i);
    }
}

Эта функция проходит по каждому элементу вектора, помещая их все в другой вектор. В итераторе (созданном методом iter()) содержатся ссылки на вектор в текущей и конечной позициях, и текущая позиция "перемещается" в направлении конечной.

Что произойдёт, если мы вызовем эту функцию с одним и тем же вектором в обоих аргументах?

push_all(&vec, &mut vec)

Это приведёт к катастрофе! Когда мы помещаем новые элементы в вектор, иногда ему потребуется изменить размер, для чего выделяется новый участок памяти, в который копируются все элементы. В итераторе останется "висящая" ссылка в старую память, что приведёт к небезопасной работе с памятью, т.е. к segfault'ам или к чему-нибудь ещё похуже.

К счастью, Rust гарантирует, что пока существует мутабельное заимствование, других ссылок на объект быть не может, и поэтому код выше приведёт к ошибке компиляции:

error: cannot borrow `vec` as mutable because it is also borrowed as immutable
push_all(&vec, &mut vec);
                    ^~~

Катастрофа предотвращена.

Передача сообщений


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

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

Не общайтесь через совместный доступ к памяти; наоборот, обеспечивайте совместный доступ через общение.
Effective Go

Владение данными в Rust позволяет очень легко преобразовать этот совет в правило, проверяемое компилятором. Рассмотрим такой API для работы с каналами (хотя каналы в стандартной библиотеке Rust немного отличаются):

fn send<T: Send>(chan: &Channel<T>, t: T);
fn recv<T: Send>(chan: &Channel<T>) -> T;

Каналы — это обобщённые типы, параметризованные типом данных, которые они передают через себя (об этом говорит <T: Send>). Ограничение Send на T означает, что T можно безопасно пересылать между потоками. Мы вернёмся к этому позднее, но пока что нам достаточно знать, что Vec<i32> является Send.

Как всегда, передача T в функцию send означает также и передачу права владения T. Отсюда следует, что вот такой код не скомпилируется:

// Предположим, что chan: Channel<Vec<i32>>

let mut vec = Vec::new();
// произведём какие-нибудь вычисления
send(&chan, vec);
print_vec(&vec);

Здесь поток создаёт вектор, отправляет его в другой поток и затем продолжает его использовать. Поток, получивший вектор, мог бы его изменить в то время, когда первый поток ещё работает, поэтому вызов print_vec мог бы привести к гонке или, например, ошибке типа use-after-free.

Вместо этого компилятор Rust выдаст ошибку на вызове print_vec:

Error: use of moved value `vec`

Катастрофа предотвращена.

Блокировки


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

У многопоточности с разделяемым состоянием дурная слава. Очень легко забыть захватить блокировку или как-то ещё изменить не те данные не в то время, с катастрофичным результатом — настолько легко, что многие программисты отказываются от такого способа многопоточного программирования полностью.

Подход Rust заключается в следующем:

  1. Многопоточность с разделяемым состоянием так или иначе является фундаментальным стилем программирования, необходимым для системного кода, максимальной производительности и для реализации других стилей многпоточного программирования.
  2. На самом деле, проблема заключается в случайно разделяемом состоянии.

Цель Rust — предоставить вам инструменты, помогающие в использовании разделяемого состояния, и в тех случаях, когда вы используете блокировки, и в тех, когда вы используете lock-free-структуры данных.

Потоки в Rust "изолированы" друг от друга автоматически благодаря концепции владения данными. Запись может происходить только тогда, когда у потока есть мутабельный доступ к данным: либо за счёт того, что поток ими владеет, либо за счёт наличия мутабельной ссылки. Так или иначе, гарантируется, что поток будет единственным, кто в данный момент времени может получить доступ к данным. Рассмотрим реализацию блокировок в Rust, чтобы понять, как это работает.

Вот упрощённая версия их API (вариант в стандартной библиотеке более эргономичен):

// создать новый мьютекс
fn mutex<T: Send>(t: T) -> Mutex<T>;

// захватить блокировку
fn lock<T: Send>(mutex: &Mutex<T>) -> MutexGuard<T>;

// получить доступ к данным, защищённым блокировкой
fn access<T: Send>(guard: &mut MutexGuard<T>) -> &mut T;

Этот интерфейс достаточно необычен в нескольких аспектах.

Во-первых, у типа Mutex есть типовый параметр T, означающий данные, защищаемые этой блокировкой. Когда вы создаёте мьютекс, вы передаёте ему право владения данными, немедленно теряя к ним доступ. (После создания блокировки остаются в незахваченном состоянии)

Далее, вы можете использовать функцию lock, чтобы заблокировать поток до тех пор, пока он не захватит блокировку. Особенность этой функции в том, что она возвращает специальное значение-предохранитель, MutexGuard<T>. Этот объект автоматически отпускает блокировку после своего уничтожения — отдельной функции unlock здесь нет.

Единственным способом получить доступ к данными является функция access, которая превращает мутабельную ссылку на предохранитель в мутабельную ссылку на данные (с меньшим временем жизни):

fn use_lock(mutex: &Mutex<Vec<i32>>) {
    // захватить блокировку и получить право владения предохранителем;
    // блокировка захвачена на протяжении всей области видимости
    let mut guard = lock(mutex);

    // получить доступ к данными с помощью мутабельного
    // заимствования предохранителя
    let vec = access(&mut guard);

    // vec имеет тип `&mut Vec<i32>`
    vec.push(3);

    // здесь блокировка автоматически отпускается (когда `guard` уничтожается)
}

Здесь мы можем отметить два ключевых момента:

  • мутабельная ссылка, которая возвращается функцией access, не может действовать дольше, чем MutexGuard, из которого она получена;
  • блокировка отпускается, только когда MutexGuard уничтожается.

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

fn use_lock(mutex: &Mutex<Vec<i32>>) {
    let vec = {
        // захватываем блокировку
        let mut guard = lock(mutex);

        // пытаемся вернуть ссылку на данные
        access(&mut guard)

        // здесь предохранитель разрушается, отпуская блокировку
    };

    // пытаемся изменить данные, не захватив блокировку
    vec.push(3);
}

Компилятор Rust сгенерирует ошибку, в точности указывающую на проблему:

error: `guard` does not live long enough
access(&mut guard)
            ^~~~~

Катастрофа предотвращена.

Потокобезопасность и трейт Send


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

Например, вместе с Rust поставляется два типа "умных указателей", использующих подсчёт ссылок:

  • Rc<T>, который реализует подсчёт ссылок с помощью простых операций чтения/записи. Он не является потокобезопасным.
  • Arc<T>, который релизует подсчёт ссылок с помощью атомарных операций. Он является потокобезопасным.

Аппаратные атомарные операции, используемые в Arc, вычислительно более дорогие, чем простые операции, применяемые в Rc, поэтому в обычной ситуации использовать Rc предпочтительнее. С другой стороны, очень важно обеспечить, чтобы Rc<T> никогда не передавался бы между потоками, потому что это может привести к гонкам, ломающим счётчик ссылок.

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

В Rust всё множество типов делится на два вида — те, которые реализуют трейт Send, что означает, что эти типы можно безопасно перемещать между потоками, и те, которые его не реализуют (!Send), что, соответственно, значит противоположное. Если все компоненты типа являются Send, то и он сам является Send, что покрывает большинство типов. Некоторые базовые типы не являются потокобезопасными по своей сути, поэтому такие типы, как Arc, можно явно пометить как Send, что означает подсказку компилятору: "Верь мне, я обеспечил здесь всю необходимую синхронизацию".

Естественно, Arc является Send, а Rc — нет.

Мы уже видели, что Channel и Mutex работают только с Send-данными. Поскольку они являются тем самым мостиком, по которому данные перемещаются между потоками, с их помощью также и обеспечиваются гарантии, связанные с Send.

Таким образом, программисты на Rust могут пользоваться преимуществами Rc и других типов данных, небезопасных для использования в многопоточной среде, будучи уверенными, что если они попытаются случайно передать такие типы в другой поток, компилятор Rust сообщит:

`Rc<Vec<i32>>` cannot be sent between threads safely

Катастрофа предотвращена.

Совместный доступ к стеку: scoped


До сих пор все структуры данных создавались на куче, которая затем использовалась из нескольких потоков. Но что если нам нужно запустить поток, который использует данные, "живущие" в стеке текущего потока? Это может быть опасно:

fn parent() {
    let mut vec = Vec::new();
    // fill the vector
    thread::spawn(|| {
        print_vec(&vec)
    })
}

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

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

fn spawn<F>(f: F) where F: 'static, ...

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

error: `vec` does not live long enough

По сути, это исключает возможность того, что стек parent может быть очищен, когда его ещё используют другие потоки. Катастрофа предотвращена.

Но есть и другой способ гарантировать безопасность: удостовериться, что родительский стек остаётся в порядке до тех пор, пока дочерний поток не завершится. Такой паттерн называется fork-join-программированием и часто применяется при разработке параллельных алгоритмов типа "разделяй и властвуй". Rust поддерживает этот подход с помощью специальной функции для запуска дочернего потока:

fn scoped<'a, F>(f: F) -> JoinGuard<'a> where F: 'a, ...

У этого API два ключевых отличия от spawn, описанного выше.

  • Использование параметра 'a вместо 'static. Этот параметр обозначает область видимости, которая является верхней границей всех заимствований внутри замыкания f.
  • Наличие возвращаемого значения, JoinGuard. Как подсказывает его название, JoinGuard гарантирует, что родительский поток присоединяется к дочернему потоку (ждёт его), неявно выполняя операцию присоединения в деструкторе (если она ещё не была выполнена явно).

Благодаря использованию параметра 'a объект JoinGuard не может выйти из области видимости, покрывающей все те данные, которые позаимствованы замыканием f. Другими словами, Rust гарантирует, что родительский поток дождётся завершения дочернего потока перед тем, как очистить свой стек (к которому дочерний поток может обращаться).

Поэтому вышеприведённый пример мы можем исправить следующим образом:

fn parent() {
    let mut vec = Vec::new();
    // заполняем вектор
    let guard = thread::scoped(|| {
        print_vec(&vec)
    });
    // предохранитель здесь уничтожается, неявно 
    // запуская ожидание дочернего потока
}

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

Примечание переводчика. Буквально в тот же день, когда вышла эта статья, была обнаружена возможность нарушить гарантии, предоставляемые scoped, в безопасном коде. Из-за этого функция thread::scoped была экстренно дестабилизирована, поэтому её нельзя использовать с бета-версией компилятора, а только с nightly. Эту проблему планируется так или иначе починить к релизу 1.0.

Гонки данных


Теперь мы рассмотрели достаточно примеров, чтобы привести, наконец, довольно строгое утверждение о подходе Rust к многопоточности: компилятор предотвращает все гонки данных.

Гонка данных (data race) возникает при несинхронизированном обращении к данным из нескольких потоков, при условии, что как минимум одно из этих обращений является записью.

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

Гонки данных — это только один (хоть очень важный) пример состояния гонки, но, предотвращая их, Rust помогает избежать других, скрытых форм гонок. Например, бывает важно обеспечить атомарность обновления одновременно нескольких участков памяти: другие потоки "увидят" либо все обновления сразу, либо ни одно из них. В Rust наличие ссылки типа &mut на все соответствующие области памяти в одно и то же время гарантирует атомарность их изменений, потому что ни один другой поток не сможет получить к ним доступ на чтение.

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

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

  • безопасность работы с памятью без сборки мусора;
  • многопоточность без гонок данных.

Будущее


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

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

И это очень здорово, потому что это значит, что способы работы с потоками в Rust могут всё время развиваться, предоставляя новые парадигмы и помогая в отлове новых классов ошибок. Такие библиотеки, как syncbox и simple_parallel, — это только первые шаги, и мы собираемся уделить особое внимание этой области в несколько следующих месяцев. Оставайтесь с нами!
Теги:
Хабы:
+61
Комментарии 55
Комментарии Комментарии 55

Публикации

Истории

Работа

Ближайшие события

Московский туристический хакатон
Дата 23 марта – 7 апреля
Место
Москва Онлайн
Геймтон «DatsEdenSpace» от DatsTeam
Дата 5 – 6 апреля
Время 17:00 – 20:00
Место
Онлайн
PG Bootcamp 2024
Дата 16 апреля
Время 09:30 – 21:00
Место
Минск Онлайн
EvaConf 2024
Дата 16 апреля
Время 11:00 – 16:00
Место
Москва Онлайн