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

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

977

Rust начинался как проект , решающий две трудные трудности:

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

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

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

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

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

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

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

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

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

Базы: владение данными
Мы начнём с обзора систем владения и заимствования данных в 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 истёк).

Любая ссылка действует лишь в определённой области видимости, которую компилятор описывает автоматом. Ссылки бывают 2-ух видов.

На одно и то же значение может быть несколько &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

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

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

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

// Представим, что chan: Channel<Vec<i32>>

let mut vec = Vec::new();
// произведём какие-нибудь вычисления
send(&chan, vec);
print_vec(&vec);
Тут поток делает вектор, посылает его в иной поток и потом продолжает его применять. Поток, получивший вектор, мог бы его поменять в то время, когда 1-ый поток ещё работает, потому вызов print_vec мог бы привести к гонке либо, к примеру, ошибке типа use-after-free.

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

Error: use of moved value `vec`
Трагедия предотвращена.

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

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

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

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

Цель 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, значащий данные, защищаемые сиим замком. (Опосля сотворения замки остаются в незахваченном состоянии) Когда вы создаёте мьютекс, вы передаёте ему право владения данными, немедля теряя к ним доступ.

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

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

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

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

Так как они являются тем самым мостиком, по которому данные передвигаются меж потоками, с их помощью также и обеспечиваются гарантии, связанные с Send. Мы уже лицезрели, что Channel и Mutex работают лишь с 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 может быть очищен, когда его ещё употребляют остальные потоки.

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

fn scoped<‘a, F>(f: F) -> JoinGuard<‘a> where F: ‘a, …
У этого API два главных отличия от spawn, описанного выше.

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

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

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

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

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

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

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

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

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

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

Заместо этого Rust употребляет владение данными и заимствования для реализации собственных 2-ух главных положений:

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

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

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

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