- Как обеспечить безопасность (работы с памятью) в системном программировании?
- Как сделать многопоточное программирование безболезненным?
Изначально эти проблемы казались не связанными друг с другом, но к нашему удивлению, их решение оказалось одинаковым — проблемы с многопоточностью решают те же самые инструменты, которые обеспечивают безопасность.
Ошибки работы с памятью и ошибки при работе с несколькими потоками частно сводятся к тому, что код обращается к некоторым данным вопреки тому, что он не должен этого делать. Секретное оружие 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 заключается в следующем:
- Многопоточность с разделяемым состоянием так или иначе является фундаментальным стилем программирования, необходимым для системного кода, максимальной производительности и для реализации других стилей многпоточного программирования.
- На самом деле, проблема заключается в случайно разделяемом состоянии.
Цель 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, — это только первые шаги, и мы собираемся уделить особое внимание этой области в несколько следующих месяцев. Оставайтесь с нами!
Комментарии (55)
OasisInDesert
21.04.2015 06:40+5Неплохо, подобные статьи весьма полезны для обучения и популяризации. Спасибо.
NeoCode
21.04.2015 11:30+1Штуки типа 'a и 'static это «именованные области жизни» (lifetimes) — концепция, насколько я понимаю, уникальная и существующая только в Rust; по ним бы неплохо отдельную статью…
Googolplex Автор
21.04.2015 11:38+2Вполне возможно, что авторы языка сделают статью и по ним тоже.
Сейчас там уже есть следующая статья, про паттерн-матчинг в контексте владения данными, я её тоже скоро переведу.Halt
21.04.2015 17:19+4Это единственная тема из статьи которая освещена мутно. Про 'а вообще ничего не сказано. Почему именно а, а не б?
С удовольствием почитал бы еще, спасибо за перевод.Googolplex Автор
21.04.2015 17:38+1Почему именно а, а не б?
Можно написать и'b
, и вообще произвольный идентификатор. Лайфтаймовые параметры очень похожи на типовые параметры, недаром они находятся рядом, в одних и тех же угловых скобках.
В принципе, в статье есть ссылки на другие статьи про концепции владения и заимствования, в частности, на официальную книгу по Rust, где про это всё неплохо разъяснено.
biziwalker
21.04.2015 17:41+1Как я понимаю, lifetime не зависит от того, что напишешь, будь это 'a или 'b, главное тут это краткость в записи. Для строкового слайса используют например 's… Тут важно, что если их несколько lifetime, то если укажешь 'a и 'a, то возьмется минимальный из них, а если 'a и 'b то они будут считается независимыми между друг другом. Есть ещё один lifetime: 'static — он указывает что время жизни равно жизни программы.
Gorthauer87
21.04.2015 12:17+2То есть тут пошли дальше в идеи ссылок и довели до ума продление их времени жизни? А что будет в случае, если нужно написать биндинг к сишной или плюсовой либе?
Googolplex Автор
21.04.2015 13:00+1В Rust есть и «сырые» указатели, к которым статического анализа не применяется. Такие указатели можно разыменовывать только в unsafe-блоках, потому что это небезопасно в общем случае. Сырые указатели как раз используются при написании байндингов к другим библиотекам.
Exabiche
21.04.2015 13:29+1Жаль только, что из ядра убрали работу с зелеными тредами. Если я правильно понял, с ними была какая-то проблема с мультиплатформенностью.
Googolplex Автор
21.04.2015 17:36+5Грин-треды в той модели, которая применялась в Rust, были неудобны по многим причинам.
Напомню, раньше (примерно до начала-середины 2014) Rust предоставлял универсальное API для многопоточности и ввода-вывода, которое работало либо поверх нативных тредов и сишного API ввода-вывода, либо поверх грин-тредов в рантаймовом шедулере на основе libuv. При этом желаемый рантайм можно было выбрать при старте программы и даже комбинировать — часть потоков запускать «зелёными», часть — нативными.
Идея сама по себе очень интересная и мощная, но, как оказалось, её очень сложно реализовать правильно, и она накладывает очень серьёзные ограничения на развитие API ввода/вывода и многопоточности в целом. Из-за такой гибридности было сложно пользоваться наиболее продвинутыми возможностями с обоих концов «спектра», плюс необходимость динамической диспетчеризации системных вызовов накладывала отпечаток на производительность любой программы. Также те программы, которые так или иначе использовали рантайм (т.е. практически все), было проблематично использовать через C-интерфейс из других сред. В общем и целом, все проблемы гибридного рантайма и причины, по которым его удалили, описаны в соответствующем RFC.
В итоге поддержку гринтредов из стандартной библиотеки убрали, что оказалось очень правильным решением — программы на Rust стали гораздо более легковесными, с меньшим количеством магии. При этом никто в принципе не запрещает сделать стороннюю библиотеку для зелёных потоков или чего-то подобного. Не так давно, например, выложили библиотеку для поддержки сопроцедур, с явной передачей управления (не могу найти ссылку сейчас).Exabiche
21.04.2015 17:41Понятно, спасибо. Как мне кажется, идея зеленых тредов в ядре — очень вкусная фича для веба. Вырезав ее и отдав «на сторону», мэйнтенеры несколько охладили пыл готовых пересесть с go на rust веб-девелоперов.
poxu
21.04.2015 18:14+3Это кто это из веб разработчиков был готов перейти на язык, который позиционирует себя как заменитель си?
stepik777
21.04.2015 22:48Ну библиотеки под это дело активно пилят: arewewebyet.com (информация на сайте уже несколько устарела).
Бэкэнд для сrates.io, например, написан на Rust.
То, что язык позиционирует себя именно как заменитель си, подразумевает, что он должен быть всё-таки удобнее, чем си.poxu
22.04.2015 10:56-1> То, что язык позиционирует себя именно как заменитель си, подразумевает, что он должен быть всё-таки удобнее, чем си.
Это также подразумевает, что область применения у него та же, что и у си и он удобнее, чем си именно в этой области. У языков, на которых пишут веб-программисты нет проблем с контролем памяти. Там либо есть gc, либо каждая программа, как в php, работает несколько секунд, а потом умирает и отдаёт всю память обратно.
Вообще у меня создалось впечатление, что единственная черта Rust, которая могла привлечь веб разработчиков — это похожий на javascript синтаксис.MuLLtiQ
23.04.2015 12:36На javascript синтаксис там вообще не похож :)
Я бы сказал что в Rust синтаксис немного переусложнен, но это связано с большим количеством фич, управлением памятью и т.д.
leventov
21.04.2015 17:42+2Имхо они изменили позиционирование языка, и правильно сделали. Грин-тредовые штуки и акторы по моим ощущениям дружат с медленными, gc-рантаймами. В том смысле что:
— акторы, грин-треды — это по-любому оверхед
— зато, типа, очень простой поддерживаемый код и архитектура
— в принципе любое акторное или грин-тредовое приложение можно задизайнить по-другому, на обычных потоках, что будет системным (Раст-) путемExabiche
21.04.2015 17:47Интересная мысль, а можно какой-нибудь пример такого дизайна?
leventov
21.04.2015 18:19-1Ну все ведь тьюринг-полное… я просто исхожу из того, что раньше люди без грин-тредов, акторов (и функциональщины) как-то ведь жили. Видимо, Раст надо брать, либо когда вам это не нужно в принципе, либо когда вы готовы «жить», ради скорости, которой не будет ни с какими акторами. А если хотите акторы — берите медленную Скалу, или Эрланг, или что там еще.
stepik777
21.04.2015 22:55+1Не совсем грин-треды, но async/await в планах: internals.rust-lang.org/t/priorities-after-1-0/1901
fogone
21.04.2015 15:15А что у rust-а с ide? Есть какие-то наработки уже у кого-нибудь?
danslapman
21.04.2015 16:23
Googolplex Автор
21.04.2015 17:27+2Помимо названных danslapman, есть ещё SolidOak (первая ссылка в гугле по запросу «rust ide», кстати). Эта среда написана на Rust, с использованием embedded NeoVim в качестве редактора.
Лично я пользуюсь обычным вимом, даже без специальных плагинов для Rust.
leventov
21.04.2015 17:15мутабельная ссылка, которая возвращается функцией access, не может действовать дольше, чем MutexGuard, из которого она получена;
error: `guard` does not live long enough
Не понимаю, как это работает, если в компилятор не знает о мьютексах ничего. Или знает?
Если все компоненты типа являются Send, то и он сам является Send, что покрывает большинство типов.
Что-то тут не то, из нескольких по отдельности атомарных счетчиков можно элементарно сконструировать потоконебезопасный тип.
alekspak
21.04.2015 17:231) Тут как раз и работает lifetime. Он у них одинаковый и поэтому данные внутри не могут жить дольше чем MutexGuard
2) Send это немного другое. Потокобезопасный это trait Sync, а Send просто позволяет отправлять объект в другой потокleventov
21.04.2015 17:36Это все не объяснено в статье (в лучшем случае; а скорее статья вводит в заблуждение) и из вашего краткого комментария тоже не понятно. Объясните обстоятельнее или дайте ссылку на спеку.
1) Что значит одинаковый лайфтайм? Их можно особым образом связывать, для разных данных? Можно определять «родительские» (в данном случае guard) и «дочерние» (vec), чтобы обусловить понятие «что-то живет не дольше чего-то»?
2) Тогда что не есть Send? Почему Rc это не Send, если это по сути «newtype» (кстати, как newtype из Хаскеля называются в Расте?) для какого-нибудь int32?Googolplex Автор
21.04.2015 17:40С чего вы взяли, что Rc — это newtype? Это вполне себе отдельный тип, оборачивающий указатель.
leventov
21.04.2015 17:55Ок, не важно.
Ограничение Send на T означает, что T можно безопасно пересылать между потоками.
Send это немного другое. Потокобезопасный это trait Sync, а Send просто позволяет отправлять объект в другой поток
Естественно, Arc является Send, а Rc — нет.
Для меня это ни сколько не естественно, судя по тому что есть еще и Sync, по моему ощущению Send должно быть вообще все (что-то я не могу придумать не-Send).Googolplex Автор
21.04.2015 17:59Нет, это не так. Смотрите, что было бы, если бы Rc был бы Send:
let r0: Rc<i32> = Rc::new(1); let r1 = r0.clone(); // клонируем указатель, счётчик ссылок увеличивается thread::spawn(move || { let r2 = r1.clone(); let r3 = r2.clone(); let r4 = r1.clone(); }); let r2 = r0.clone(); let r3 = r0.clone();
clone() на Rc увеличивает счётчик на 1, выход из области действия уменьшает на 1. Как по-вашему, как будет работать эта программа, если операции изменения счётчика неатомарные?leventov
21.04.2015 18:02Я так понимаю, это потому, что Rc внутри фигачит unsafe-код и clone() и «уничтожение» (выход из области видимости) делает что-то далеко не стандартное? И для этого Rc явно помечен как !Send, хотя его поля по отдельности — Send?
Googolplex Автор
21.04.2015 18:05Да, именно так. Уничтожение/клонирование изменяет счётчик ссылок, общий для всех ссылок на этот участок памяти — это происходит через сырой указатель. Поскольку в случае Rc это делается неатомарными операциями, Rc не может быть Send.
Googolplex Автор
21.04.2015 17:46Можно определять «родительские» (в данном случае guard) и «дочерние» (vec), чтобы обусловить понятие «что-то живет не дольше чего-то»?
Непосредственно этого сделать нельзя, компилятор сам выведет лайфтаймы для всех объектов, которые вы используете, на основе того, какие операции вы осуществляете с ними. Вот в данном примере:
let mut guard = lock(mutex); let vec = access(&mut guard);
guard
имеет тип MutexGuard. access принимает мутабельную ссылку на MutexGuard и возвращает ссылку на охраняемый вектор (которая присваивается vec). При этом лайфтайм, ассоциированный со ссылкой, связывается с лайфтаймом MutexGuard, потому что access определена вот так:
fn access<'a, T>(guard: &'a mut MutexGuard<T>) -> &'a mut T
Обратите внимание на то, как применяется лайфтайм-параметр.leventov
21.04.2015 17:49Ну вот теперь примерно ясно, а в статье просто постулируется, что &mut Vec не переживет MutexGuard, почему — не объяснено.
Googolplex Автор
21.04.2015 17:56Сигнатура access приведена в самом начале соответствующего раздела. Вероятно, авторам действительно стоило написать её в развёрнутом виде. Здесь используется механизм удаления лайфтайм-параметров (lifetime elision) — когда компилятор сам, по довольно простым правилам, выводит то, как связаны лайфтаймы входных и выходных ссылок.
leventov
21.04.2015 17:59А почему лайфтайм результата функции должен быть связан с лайфтаймом параметра? Мне кажется, это не обязательно. Или, если не связаны, надо явно указать это как-то в сигнатуре?
Googolplex Автор
21.04.2015 18:02Да, если не связаны — это нужно указать явно. Просто после анализа кода выяснилось, что около 90% лайфтайм-параметров использовались примерно в таком контексте.
Учтите, правда, что в общем случае вы не сможете вернуть ссылку с лайфтаймом, не связанным с параметрами (ну или не статическим).leventov
21.04.2015 18:05(ну или не статическим).
Не понял.
Какой-нибудь
fn vecOfSize(&int size) { return Vec::new(size); }
(Извините за неровный почерк)
С чего бы лайфтайму возвращенного вектора зависеть от параметра?Googolplex Автор
21.04.2015 18:09Здесь вы возвращаете не ссылку, а значение. Я говорил про ссылки. Вот такое, например, вы сделать не сможете:
fn wrong() -> &i32 { let x = 10; &x }
leventov
21.04.2015 18:13Кажется, начинаю понимать — ссылка должна от чего-то зависеть, может от области, но если мы возвращаем ссылку, область функции уже закончилась по определению — значит остается зависеть только от какого-нибудь параметра.
Только как при этом должно выглядеть «чисто языковое» тело функции access(), чтобы компилятор понял, что &mut Vec зависит от &mut MutexGuard? Или компилятор привязывает к первому аргументу, не глядя? Или там на самом деле опять unsafe?Googolplex Автор
21.04.2015 18:23Кажется, начинаю понимать — ссылка должна от чего-то зависеть, может от области, но если мы возвращаем ссылку, область функции уже закончилась по определению — значит остается зависеть только от какого-нибудь параметра.
Да, всё именно так.
Только как при этом должно выглядеть «чисто языковое» тело функции access(), чтобы компилятор понял, что &mut Vec зависит от &mut MutexGuard? Или компилятор привязывает к первому аргументу, не глядя? Или там на самом деле опять unsafe?
Ну в общем случае функция действительно может возвращать указатель на какое-то поле объекта. Вот, например:
struct X { x: i32 } fn access(x: &mut X) -> &mut i32 { &mut x.x }
Здесь компилятор видит доступ к полю и на основании этого делает вывод о связи между входной и выходной ссылкой.
В общем-то с MutexGuard ситуация аналогичная — он содержит поле типа &UnsafeCell — ссылку на UnsafeCell, а у UnsafeCell есть метод типа
unsafe fn get(&self) -> &mut T
который возвращает мутабельную ссылку на свои внутренности. Он unsafe потому что он «обходит» гарантию отсутствия алиасинга мутабельных ссылок. Собственно, метод получения мутабельной ссылки из MutexGuard вызывает get() на UnsafeCell непосредственно. Соблюдение же вышеописанных гарантий в контексте мьютекса обеспечивается реализацией этого мьютекса, в частности, тем, что получить одновременно больше одного MutexGuard'а невозможно.stepik777
21.04.2015 23:03+1Здесь компилятор видит доступ к полю и на основании этого делает вывод о связи между входной и выходной ссылкой.
Нет, вывод о связи между входной и выходной ссылкой компилятор делает только на основании сигнатуры функции, игнорируя её тело. Если у функции всего один параметр ссылка, то компилятор предполагает, что у всех возвращаемых ссылок такой же лайфтайм. Если вы к своей функции access добавите второй параметр, то код уже не скомпилируется:
src\main.rs:5:36: 5:40 error: missing lifetime specifier [E0106] src\main.rs:5 fn access(x: &mut X, y: &mut X) -> &mut i32 { ^~~~ src\main.rs:5:36: 5:40 help: this function's return type contains a borrowed value, but the signature does not say whether it is borrowed from `x` or `y`
В этом случае придётся вручную указывать лайфтаймы.Googolplex Автор
22.04.2015 00:25Нет, вывод о связи между входной и выходной ссылкой компилятор делает только на основании сигнатуры функции, игнорируя её тело
Да, я, наверное. неправильно выразился. Правила lifetime elision действительно механические — фактически, синтаксический сахар, и они однозначно определяются только сигнатурой. Я имел в виду то, что компилятор проверит, что код внутри функции действительно вернёт ссылку с корректным лайфтаймом — на основании того, что возвращается ссылка на поле входной структуры.
Googolplex Автор
21.04.2015 17:41Не понимаю, как это работает, если в компилятор не знает о мьютексах ничего. Или знает?
Нет, компилятор не знает о мьютексах ничего. В этом как раз и главная идея.
Работает это за счёт общей системы заимствования. Ссылка на объект не может действовать дольше, чем живёт сам объект, и это гарантируется статически. В начале статьи недаром даются объяснения про ownership и borrowing — именно за счёт них вы не сможете получить ссылку на внутренность мьютекса, если он не захвачен.leventov
21.04.2015 17:47Кажется, я понял (точнее придумал сам как это могло бы быть) — MutexGuard хранит &Vec, то есть при его уничтожении должен уничтожиться &mut Vec, который из него вышел. Только из текста статьи это вовсе не очевидно
Googolplex Автор
21.04.2015 17:49MutexGuard ничего не хранит (вернее, хранит, но точно не &mut T и не &T) — всё дело в том, какими операциями вы с ним работаете.
evnuh
21.04.2015 17:38Непонятно про lock-free. Вот нужно мне по рингбуферу бегать несколькими потоками (читатели и писатели), как передавать им всем мутабельные заимствования на этот буфер?
Googolplex Автор
21.04.2015 17:52Одновременно — никак. В Rust в один и тот же момент времени может существовать только одна мутабельная ссылка (т.е. мутабельное заимствование) на один и тот же объект. За счёт механизмов синхронизации вы можете статически обеспечить, что никакие два потока не имеют мутабельную ссылку на один объект в одно и то же время. В вашем случае вам, вероятно, понадобится
RWLock
, похожий, например, на ReadWriteLock из джавы.
Но это, конечно, не lock-free-структуры. Насколько я в курсе, на данный момент в стандартной библиотеке локфри-структур данных нет, но их ничего принципиально не мешает сделать.
leventov
21.04.2015 17:43Будет Раст 2.0 или они начнут есть Java-кактус?
Googolplex Автор
21.04.2015 17:47Однозначно будет. Если вам интересно, почитайте, пожалуйста, предыдущие статьи из того блога (они есть и в переводе на хабре) — там рассказывается про то, как Rust будет развиваться в дальнейшем и как именно будет обеспечиваться стабильность без стагнации.
stepik777
21.04.2015 23:15После 1.0 будут версии 1.x, которые будут обратно совместимы с 1.0, там ещё много всего запланировано.
2.0 — это что-то более отдалённое и туманное.
Googolplex Автор
Как всегда, предложения и замечания по переводу прошу слать в личку.