Пользователь
0,0
рейтинг
30 августа 2013 в 11:13

Разработка → Ключевые возможности Rust recovery mode

Rust — новый язык программирования, разрабатываемый корпорацией Mozilla. Главная цель разработчиков — создание безопасного практичного языка для параллельных вычислений. Первая версия языка была написана Грэйдоном Хором в 2006 году, а в 2009 году к разработке подключилась Mozilla. С тех пор изменения претерпел и сам компилятор, изначально написанный на OCaml: он был успешно переписан на Rust с использованием LLVM в качестве back-end.

Основным продуктом, разрабатываемым на Rust, является новый веб-движок Servo, разработка которого также ведется Mozilla. В 2013 году к разработке Rust и Servo присоединилась корпорация Samsung Electronics, при активном участии которой код движка Servo был портирован на ARM архитектуру. Поддержка языка столь серьезными игроками IT индустрии не может не радовать и дает надежду на его дальнейшее активное развитие и совершенствование.

Язык Rust просто не может не понравится системным и сетевым разработчикам, тем, кому по работе приходится писать много кода, производительность которого критична, на C и C++, потому что:
  1. Rust ориентирован на разработку безопасных приложений. Сюда входит безопасная работа с памятью: отсутствие null-указателей, контроль за использованием не инициализированных и деинициализированных переменных; невозможность совместного использования разделяемых состояний несколькими задачами; статический анализ времени жизни указателей.
  2. Rust ориентирован на разработку параллельных приложений. В нем реализована поддержка легких (зеленых) потоков, асинхронного обмена сообщениями без копирования пересылаемых данных, возможность выбора размещения объектов на стеке, в локальной куче задачи или куче, разделяемой между задачами.
  3. Rust ориентирован на разработку эффективных по скорости и памяти приложений. Использование LLVM в качестве back-end позволяет производить компиляцию приложения в нативный код, а простой интерфейс взаимодействия с C кодом – легко использовать уже имеющиеся высокопроизводительные библиотеки.
  4. Rust ориентирован на разработку кросс-платформенных приложений. Компилятор официально поддерживается на платформах Windows, Linux и Mac OS X, при этом существуют порты на другие *NIX платформы, такие как FreeBSD. Также поддерживается и несколько архитектур процессоров: i386, x64 и ARM.
  5. Rust позволяет писать в разных стилях: объектно-ориентированном, функциональном, actor-based, императивном.
  6. Rust поддерживает уже существующие отладочные инструменты: GDB, Valgrind, Instruments.


Целевая аудитория


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

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

Терминология


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

Наибольшее количество проблем вызвали термины Box и Pointer. По своим свойствам что Box, что Pointer больше всего напоминают умные указатели из C++, поэтому я решил использовать термин «указатели». Таким образом, Owned boxes превратились в Уникальные указатели, а Borrowed pointers во Временные указатели.

Работа с памятью


Принципы работы с памятью – это первая из ключевых возможностей Rust, которая выгодно отличает этот язык как от языков с полным доступом к памяти (типа C++), так и от языков с полным контролем за памятью со стороны GC (типа Java). Дело в том, что, с одной стороны, Rust предоставляет разработчику возможность контролировать, где размещать данные, вводя разделение по типам указателей и обеспечивая контроль за их использованием на этапе компиляции. C другой стороны, механизм подсчета ссылок, который в окончательной версии языка будет заменен полноценным GC, обеспечивает автоматическое управление ресурсами.

В Rust существует несколько типов указателей, адресующих объекты, размещенные в разных типах памяти, и подчиняющихся разным правилам:
  • Разделяемые указатели (Managed boxes). Указывают на данные, размещенные в локальной куче (local heap) задачи; несколько разделяемых указателей могут адресовать один и тот же объект.
  • Уникальные указатели (Owned boxes). Указывают на данные, размещенные в куче обмена (exchange heap), общей для всех задач; в одну единицу времени доступ к объекту может адресовать только один указатель (см. исключения из правила в разделе «Модуль ARC»).
  • Временные указатели (Borrowed pointers). Универсальные указатели, имеющие возможность указывать на любой тип объекта: стековый, размещенный в локальной или обменной куче. В основном используются для написания универсального кода, работающего с данными в функциях, когда тип размещения объекта не важен.
  • Немного сбоку находятся объекты, размещенные на стеке. Для их адресации не существует какого-либо выделенного типа указателя.

Схематически модель памяти Rust можно представить следующим образом:


Использование стека


let x = Point {x: 1f, y: 1f};        // (1)
let y = x;                           // (2)

Так, код (1) разместит объект типа Point на стеке задачи, в которой будет вызван. При копировании подобного объекта (2) будет скопирован не указатель на объект x, а вся структура типа Point.

Для информации: переменные

Как можно увидеть из примера выше, ключевое слово let используется в Rust для создания переменных. По умолчанию все переменные константные и для создания изменяемой переменной необходимо добавлять ключевое слово mut. Таким образом, создание изменяемой переменной типа Point могло бы выглядеть следующим образом let mut x = Point {x: 1f, y: 1f};.

Крайне важно помнить при работе с переменными, что константными оказываются именно данные, и за попытками изменить их «обманом» пристально следит компилятор.
let x = Point {x:1, y:2};
let y = Point {x:2, y:3};
let mut px = &x;              // (1)
let py = &y;

px.x = 42;                    // (2)
px = py;                      // (3)

Так, вполне можно (1) создать изменяемую переменную, указывающую на константные данные, но вот попытка (2) изменить сами данные закончится ошибкой на этапе компиляции. А вот изменение значения переменной, хранящей адрес константного объекта Point и созданной ранее, является допустимым (3).
error: assigning to immutable field
px.x = 42;
^~~~~


Разделяемые указатели

Разделяемые указатели используются в качестве указателей на объекты, располагающиеся в локальной куче задачи. У каждой задачи есть собственная локальная куча, и указатели на расположенные в ней объекты никогда не могут быть переданы за ее пределы. Для создания разделяемых указателей используется унарный оператор @
let x = @Point {x: 1f, y: 1f};

В отличие от стековых объектов, при копировании копируется исключительно указатель, а не данные. Именно из этого свойства и пошло название данного типа указателей, так как поведение их очень похоже на shared_ptr из языка C++.
let y = x; // теперь x и y указывают на один и 
           // тот же объект типа Point

Также необходимо отметить тот факт, что невозможно создать структуру, содержащую указатель на собственный тип (классический пример – односвязный список). Для того чтобы компилятор разрешил подобную конструкцию, необходимо обернуть указатель в тип Option (1).
struct LinkedList<T> {
    data: T,
    nextNode: Option<@LinkedList<T>>    // (1)
}


Уникальные указатели

Уникальные указатели, как и разделяемые указатели, представляют собой указатели на объекты в куче, на чем их сходство и заканчивается. Данные, адресуемые уникальными указателями, располагаются в куче обмена, которая является общей для всех задач. Для создания уникальных указателей используется унарный оператор ~
let p = ~Point {x: 1f, y: 1f};

Уникальные указатели реализуют семантику владения, благодаря чему объект может адресовать только один уникальный указатель. C++ разработчики наверняка найдут общие черты между уникальными указателями Rust и классом unique_ptr из STL.
let new_p = p;            // (1)
let val_x = p.x;          // (2)

Присвоение (1) указателю new_p указателя p приводит к тому, что new_p начинает указывать на созданный ранее объект типа Point, а указатель p деинициализируется. В случае попытки работы с деинициализированными переменными (2) компилятор генерирует ошибку use of moved value и предлагает сделать копию переменной вместо присвоения указателя с последующей деинициализацией исходного.
let p = ~Point {x: 1f, y: 1f};
let new_p = p.clone();       // (1)

Благодаря явному созданию копии (1), new_p указывает на копию созданного ранее объекта типа Point, а указатель p не изменяется. Для того, что бы к структуре Point можно было применить метод clone, структура должна быть объявлена с использованием атрибута #[deriving(Clone)].
#[deriving(Clone)]
struct Point {x: float, y: float}


Временные указатели

Временные указатели – указатели которые могут указывать на объект, размещенный в любом из возможных типов памяти: стеке, локальном или хипе обмена, а также на внутренний член любой структуры данных. На физическом уровне временные указатели представляют собой типичные Си указатели и, как следствие, не отслеживаются сборщиком мусора и не привносят никаких дополнительных накладных расходов. В то же время, их основным отличием от Си указателей являются дополнительные проверки, проводимые на этапе компиляции для гарантии возможности безопасного использования. Для создания временных указателей используется унарный оператор &
let on_the_stack  = &Point {x: 3.0, y: 4.0};     // (1)

Объект типа Point был создан (1) на стеке и временный указатель был сохранен в on_the_stack. Данный код аналогичен следующему:
let on_the_stack  =  Point {x: 3.0, y: 4.0};
let on_the_stack_pointer = &on_the_stack;

Типы, отличные от стековых, приводятся к временным указателям автоматически, без использования оператора взятия адреса, что позволяет упростить написание функций (1), если тип указателя не имеет значения.
let on_the_stack : Point  =  Point {x: 3.0, y: 4.0};
let managed_box  : @Point = @Point {x: 5.0, y: 1.0};
let owned_box    : ~Point = ~Point {x: 7.0, y: 9.0};

fn compute_distance(p1: &Point, p2: &Point) -> float {    // (1)
    let x_d = p1.x - p2.x;
    let y_d = p1.y - p2.y;
    sqrt(x_d * x_d + y_d * y_d)
}

compute_distance(&on_the_stack, managed_box);
compute_distance(managed_box, owned_box);

А теперь небольшая иллюстрация того, как можно получить временный указатель на внутренний элемент структуры данных.
let y = &point.y;

Контроль времени жизни временных указателей довольно объемная и не совсем устоявшаяся тема. При желании с ней можно подробно ознакомится в статье Rust Borrowed Pointers Tutorial и Lifetime Notation.

Разыменование указателей

Для доступа к значениям, адресованным при помощи указателей, необходимо проводить операцию разыменования (Dereferencing pointers). При доступе к полям структурированных объектов разыменование производится автоматически.
let managed = @10;
let owned = ~20;
let borrowed = &30;

let sum = *managed + *owned + *borrowed;


Преобразование между указателями

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

Задачи


Вторая ключевая возможность Rust – написание параллельных приложений. В плане возможностей для написания параллельных приложений Rust напоминает Erlang с его моделью акторов и обменом сообщениями между ними и Limbo с его каналами. При этом разработчику предоставляется возможность выбирать: хочет ли он копировать память при отправке сообщения или просто передать владение объектом. А при совместной работе нескольких задач с одним и тем же объектом можно легко организовать доступ один-писатель-много-читателей. Для создаваемых задач есть возможность выбрать наиболее подходящий планировщик или написать собственный.

Для информации: do-синтаксис

Перед тем как перейти к описанию работы с задачами, желательно ознакомиться с do-синтаксисом, который используется в Rust для упрощения работы с функциями высшего порядка. В качестве примера можно взять функцию each, передающую указатель (1) на каждый из элементов массива в функцию op.
fn each(v: &[int], op: &fn(v: &int)) {
   let mut n = 0;
   while n < v.len() {
       op(&v[n]);			// (1)
       n += 1;
   }
}

При помощи функции each, используя do-синтаксис (1), можно вывести на экран каждый из элементов массива, не забывая о том, что в лямбду будет передано не значение, а указатель, который необходимо разыменовать (2) для доступа к данным:
do each([1, 2, 3]) |n| {              // (1)
    io::println(n.to_str());          // (2)
}	

Так как do-синтаксис является синтаксическим сахаром, то запись ниже эквивалентна записи с использованием do-синтаксиса.
each([1, 2, 3], |n| {
    io::println(n.to_str());
});


Запуск задачи на выполнение

Создать и выполнить задачу в Rust очень просто. Код, относящийся к работе с задачами, сосредоточен в модуле std::task, а простейшим способом создания и старта задачи является вызов функции spawn из этого модуля.
use std::task;
fn print_message() { println("Message form task 1"); }
fn main() {
    spawn(print_message);                                      // (1)
    spawn( || println("Message form task 2") );               // (2)

    do spawn {                                                 // (3)
        println("Message form task 3");
    }
}

Функция spawn принимает замыкание в качестве аргумента и запускает его на выполнение в виде задачи (не стоит забывать о том, что задачи в Rust реализованы поверх зеленых потоков). Для того чтобы получить текущую задачу, в рамках которой выполняется код, можно воспользоваться методом get_task() из модуля task. С учетом того, что в рамках задачи выполняются замыкания, не сложно предположить 3 способа запустить задачу на выполнение: передав адрес функции (1), создав замыкание «на месте» (2) или, что более верно с точки зрения идеологии языка, воспользовавшись do-синтаксисом (3).

Взаимодействие между задачами

Модель памяти Rust, в общем случае, не допускает совместного обращения к одной и той же памяти из разных задач (shared memory model), предлагая вместо этого обмен сообщениями между задачами (mailbox model). При этом для нескольких задач существует возможность работать с общей памятью в режимах «только для чтения» и «один писатель много читателей». Для организации взаимодействия между задачами Rust предлагает следующие способы:
  • Низкоуровневые каналы и порты из модуля std::comm;
  • Высокоуровневая абстракция над каналами и портами extra::comm;
  • Каналы, предназначенные для передачи бинарных данных из extra::flatpipes;


Обмен сообщениями на низком уровне

Самым широко используемым на данный момент способом взаимодействия между задачами является модуль std::comm. Код из std::comm хорошо отлажен, неплохо задокументирован и довольно прост в использовании. Основой механизма обмена сообщениями std::comm являются потоки, манипуляция с которыми происходит посредством каналов и портов. Поток представляет собой однонаправленный механизм связи, в котором порт используется для отправки сообщения, а канал – для приема отправленной информации. Простейший пример использования потока выглядит следующим образом:
let (chan, port) = stream();     // (1)

port.send("data");               // (2)
// port.send(1);                 // (3)
println(chan.recv());            // (4)

В данном примере создается пара (1), состоящая из канала и порта, которые используются для отправки (2) строкового типа данных. Отдельное внимание стоит уделить прототипу функции stream(), который выглядит следующим образом: fn stream<T: Send>() -> (Port, Chan). Как видно из прототипа, канал и порт являются шаблонными типами, что, на первый взгляд, неочевидно из кода, приведенного выше. В данном случае тип передаваемых данных выводится автоматически, основываясь на первом использовании. Так, если раскомментировать строку, отправляющую в поток единицу (3), компилятор выдаст сообщение об ошибке:
error: mismatched types: expected `&'static str` but found `<VI0>` 
                (expected &'static str but found integral variable

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

Для получения данных из потока можно воспользоваться функцией recv(), которая либо вернет данные, либо заблокирует задачу до их появления. Глядя на пример, приведенный выше, закрадывается подозрение, что он совершенно бесполезен, так как какого-то практического смысла в отправке сообщений при помощи потоков в рамках одной задачи нет. Так что стоит перейти к более практичным вещам, таким как использование потоков для передачи информации между задачами.
let value = vec::from_fn(5, |x| x + 1);      // (1)
let (server_chan, server_port) = stream();   // (2)
let (client_chan, client_port) = stream();   // (3)

do task::spawn {
    let val: ~[uint] = server_chan.recv();   // (4)
    let res = val.map(|v| {v+1});
    client_port.send(res)                    // (5)
}

server_port.send(value);                     // (6)
io::println(fmt!("Result: %?", 
client_chan.recv()));                        // (7)

Первое, на что стоит обратить внимание при работе с потоками, это необходимость передавать значения, адресуемые уникальными указателями, а функция from_fn() (1) как раз создает такой массив. Так как поток является однонаправленным, то для передачи запроса (2) и получения ответа (3) понадобятся два потока. При помощи функции recv() данные считываются из потока (4), а при отсутствии таковых поток заблокирует задачу до их появления. Для отправки результата клиенту используется функция send() (5), принадлежащая не серверному, а клиентскому потоку; аналогичным образом необходимо поступить с данными для отправки серверной задаче: они записываются (6) при помощи функции send(), относящейся к серверному порту. В самом конце результат, переданный серверной задачей, считывается (7) из клиентского потока.

Таким образом, для отправки сообщений серверу и приема сообщений на стороне сервера используется поток server_chan, server_port. В силу однонаправленности потока, для получения результата вычислений сервера был создан клиентский поток, состоящий из пары client_chan, client_port.

Совместное использование потока

Хотя поток является однонаправленным механизмом передачи данных, это не приводит к необходимости создавать новый поток для каждого из желающих отправить данные, так как существует механизм, обеспечивающий работу в режиме «один-получатель-много-отправителей».
enum command {                                     // (1)
    print_hello(int),
    stop
}

...

let (server_chan, server_port) = stream();         // (2)
let (client_chan, client_port) = stream();         // (3)

do spawn {                                         // (4)
    let mut hello_count = 0;
    let mut done = false;
    while !done {
        let req: command = server_chan.recv();     // (5)
        match req {
            print_hello(client_id) => {
                    println(
fmt!("Hello from client #%d", client_id));
                    hello_count += 1;
                }
            stop => { 
                println("Stop command received");
                done = true; 
                }
        }
    }
    client_port.send(hello_count);                 // (6)
}

let server_port = SharedChan::new(server_port);    // (7)
for i in range(0, 5) {
    let server_port = server_port.clone();         // (8)
    do spawn {
        server_port.send(print_hello(i));          // (9)
    }
}
server_port.send(stop);
println(fmt!("Result: %?", client_chan.recv()));

Для этого, как и для схемы «один-читатель-один-писатель», необходимо создать серверный (2) и клиентский (3) потоки и запустить серверную задачу (3). Логика серверной задачи предельно проста: считать (5) данные из серверного канала, переданные клиентом (9), вывести сообщение о получении запроса на экран и отправить результирующее количество полученных запросов print_hello (5) в клиентский поток. Так как писателей несколько, то необходимо внести изменения в тип серверного порта, преобразовав (7) его к SharedChan вместо Chan, и для каждого из писателей создать уникальную копию порта (8) посредствам метода clone(). Дальнейшая работа с портом ничем не отличается от предыдущего примера: метод send() используется для отправки данных серверу (9) с той лишь разницей, что теперь данные отправляются из нескольких задач одновременно.

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

Пересылка объектов

В тех случаях, когда необходимость пересылать значения, адресуемые исключительно уникальными указателями, становится проблемой, на помощь приходит модуль flatpipes. Данный модуль позволяет отправлять и принимать любые бинарные данные в виде массива или объекты, поддерживающие сериализацию.
#[deriving(Decodable)]                           // (1)
#[deriving(Encodable)]                           // (2)
struct EncTest { val1: uint, val2: @str, val3: ~str }
...
let (server_chan, server_port) = 
         flatpipes::serial::pipe_stream();       // (3)

do task::spawn {
    let value = @EncTest{val1: 1u, val2: @"test string 1",
                         val3: ~"test string 2"};
    server_port.send(value);                     // (4)
}
let val = server_chan.recv();
server_port.send(value);                         // (5)

Как видно из примера, работать с flatpipes предельно просто. Структура, объекты которой будут передаваться посредством flatpipes, должна быть объявлена сериализуемой (1) и десериализуемой (2). Создание flatpipes (3) технически ничем не отличается от создания обычных потоков, так же как прием (4) и отправка (5) сообщений при помощи канала и порта. Главным же отличием flatpipes от потока является создание глубокой копии объекта на отправляющей стороне и построение нового объекта на принимающей стороне. Благодаря такому подходу, накладные расходы при работе с flatpipes, по сравнению с обычными потоками, возрастают, но возможности по пересылке данных между задачами увеличиваются.

Высокоуровневая абстракция обмена сообщениями

В большинстве приведенных выше примеров создаются два потока: один для отправки данных на сервер, второй для получения данных с сервера. Подобный подход не привносит какой-то ощутимой пользы да и просто замусоривает код. В связи с этим был создан модуль extra::comm, являющийся высокоуровневой абстракцией над std::comm и содержащий в себе DuplexStream, позволяющий организовать двунаправленное общение в рамках одного потока. Само собой, если заглянуть в исходный код DuplexStream, станет ясно, что это не более чем удобная надстройка над парой стандартных потоков.
let value = ~[1, 2, 3, 4, 5];
let (server, client) = DuplexStream();           // (1)

do task::spawn {
    let val: ~[uint] = server.recv();            // (2)
    io::println(fmt!("Value: %?", val));
    let res = val.map(|v| {v+1});
    server.send(res)                             // (3)
}

client.send(value);                              // (4)
io::println(fmt!("Result: %?", client.recv()));  // (5)

При работе с DuplexStream создается (1) единственная пара из двух двунаправленных потоков, оба из которых могут использоваться как для отправки, так и для получения сообщений. Объект server захватывается контекстом задачи и используется для получения (2) и отправки (3) сообщений в задаче сервера, а объект client – в задаче клиента (4,5). Принцип работы с DuplexStream ничем не отличается от работы с обычными потоками, но позволяет сократить количество вспомогательных объектов.

Модуль Arc

Несмотря на все прелести отправки сообщений, рано или поздно возникает вопрос: «А что делать с большой структурой данных, доступ к которой нужен из нескольких задач одновременно?» Конечно, ее можно пересылать в виде уникального указателя между потоками, но такой подход сильно затруднит разработку приложения, а его сопровождение превратится в настоящий кошмар. Именно для таких случаев и был создан модуль Arc, позволяющий организовать совместный доступ из нескольких задач к одному и тому же объекту.
Совместное использование уникальных указателей с доступом только на чтение

Сначала стоит разобраться с самым простым случаем – совместным доступом к неизменяемым данным из нескольких задач. Для решения подобной задачи необходимо воспользоваться модулем Arc, который реализует механизм автоматического подсчета ссылок (Atomically Reference-Counter) на разделяемый объект. В прототипе функции создания ARC-объекта pub fn new(data: T) -> Arc стоит обратить внимание на налагаемые на тип T ограничения.
impl<T:Freeze+Send> Arc<T> {
    pub fn new(data: T) -> Arc<T> {
...
    }
...
}

Теперь объект должен относиться не только к классу Send, как это было в случае с потоком, но еще и к классу Freeze, что гарантирует отсутствие каких бы то ни было изменяемых полей или указателей на изменяемые поля внутри объекта T (такие объекты в Rust носят название deeply immutable objects).
let data = arc::Arc::new(~[1, 2, 3, 4, 5]);              // (1)

let shared_data = data.clone();                          // (2)
do spawn {
    let val = shared_data.get();                         // (3)
    println(fmt!("Shared array: %?", val));
}

println(fmt!("Original array: %?", data.get()));         // (4)

Пусть в данном примере нет работы с потоками, но он вполне достаточен для иллюстрации работы с Arc, так как наглядно демонстрирует основной функционал этого модуля – возможность одновременно обращаться к одним и тем же данным из разных задач. Так, для совместного использования одного и того же массива, обернутого в Arc (1), надо создать клон Arc обертки (2), что сделает возможным обращение к данным как из новой (3), так и из основной (4) задач.

R/W доступ к уникальным указателям

Модуль RWArc вызывает у меня двоякие эмоции. С одной стороны, благодаря RWArc можно реализовать широко распространенную и хорошо известную большинству разработчиков концепцию “много читателей один писатель”, что, наверное, хорошо, так как концепция широко известна. С другой стороны, совместный доступ к памяти, причем не RO доступ, который был описан чуть ранее, а RW доступ, чреват проблемами с взаимоблокировками, от которых Rust как раз и должен защитить разработчиков. Лично для себя я пришел к следующему выводу: о модуле знать надо, но использовать его без крайней необходимости не стоит.
let data = arc::RWArc::new(~[1, 2, 3, 4, 5]);              // (1)

do 5.times {
    let reader = data.clone();                               // (2)
    do spawn {
        do reader.read() |data| {                            // (3)
            io::println(fmt!("Value: %?", data));            // (4)
        }
    }
}

do spawn {
    do data.write() |data| {                                 // (5)
        for x in data.mut_iter() { *x = *x * 2 }             // (6)
    }
}

В приведенном выше примере создается (1) массив, обернутый в RWArc, благодаря чему к нему можно обращаться как на чтение (4), так и на запись (6). Кардинальное отличие примера работы с RWArc от всех предыдущих примеров – использование замыканий в функциях read() (3) и write() (5) в качестве аргумента. Чтение и запись данных, обернутых в RWArc, можно производить только в этих функциях. И, как обычно, необходимо создать копию (2) объекта для доступа к нему из замыкания, так как в противном случае оригинал станет недоступным.

Как такое вообще возможно?

Да, именно такой вопрос возникает после того, как узнаешь о том, что модули Arc и RWArc присутствуют в Rust. На первый взгляд они противоречат концепции работы с памятью в Rust в целом, и принципам работы уникальных указателей в частности. Не являясь создателем или разработчиком данного языка, я могу только лишь рассказать о том, благодаря чему подобное поведение возможно. В составе языка Rust имеется ключевое слово unsafe, позволяющее писать код, работающий с памятью напрямую, вызывать такие небезопасные с точки зрения управления памятью функции, как malloc, free, и использовать адресную арифметику. Именно эта возможность используется для обхода встроенной в Rust защиты памяти и обеспечения совместного доступа к одному и тому же объекту. Весь код, относящийся к данной функциональности, помечен как «COMPLETELY UNSAFE» и не должен использоваться конечными пользователями напрямую.

Вместо заключения


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

Что касается статьи, то считать ее законченной, скорее всего, нельзя: во-первых, синтаксис языка наверняка претерпит еще ряд изменений, а, во-вторых, должна завершиться работа над третей из ключевых возможностей языка – поддержкой сетевых взаимодействий. Как только эта функциональность придет в более или менее завершенное состояние, я обязательно о ней напишу.
@kaapython
карма
20,0
рейтинг 0,0
Реклама помогает поддерживать и развивать наши сервисы

Подробнее
Реклама

Самое читаемое Разработка

Комментарии (52)

  • –22
    Так написано, что не стал дочитывать. Проверяйте тексты перед публикацией.
    • +22
      Так — как? «Так написано» — это ну просто очень-очень информативно!
      • –10
        Зря минусуете. Первый абзац со списком и правда были очень удручающими. Спасибо, что поправили.
        • +12
          О чем можно просто написать в личку, так как не велика трагедия, верно? Как, впрочем, несколько человек и сделало.
    • 0
      Написано отлично и развернуто. Пользуясь случаем благодарю за популяризацию.
  • 0
    А есть ли в языке поддержка интринсиков для работы с SIMD операциями или возможность написания инлайн ассемблерного кода?
    • +1
      Есть начальная поддержка: github.com/mozilla/rust/pull/5317
      • 0
        Синтаксис не очень, конечно…
        • +2
          Это почему? Там простой ассемблерный код в AT&T стиле и пока нет возможности использования памяти «из программы», вроде этого:
          float a[4] = {1.2f, 2.0f, 3.0f, 4.0f}, b[4];
          
          __asm__ (
          	"movaps %[a], %%xmm0\n\t"
          	"mulps %xmm0, %xmm0\n\t"
          	"movaps %xmm0, %[b]\n\t"
          	:
          	: [a]"m"(*a), [b]"m"(*b)
          	: "%xmm0"
          );
          
          • 0
            AT&T синтаксис — не самое лучшее, что я видел, на самом деле. Пишут что есть возможность использовать интеловский, и то хорошо.
            • +2
              Чем вам AT&T не угодил? Лично мне, AT&T больше нравится.
              А вообще, я тоже сначала в NASM/YASM на Intel-синтаксисе писал. Когда на GAS переходил, изучил AT&T, где единственная проблема была, это операнды в другом порядке (что быстро ушло, когда я «набил руку»). Плюс, AT&T более «явный» (это я к суффиксам b/w и т.д.).
              Да и потом, лучше AT&T изучать и применять, т.к. он еще и на других ассемблерах используется (arm, sparc, ppc и т.д.).
              P.S. А если некто на ассемблере вообще никогда не писал, так ему совершенно все равно будет.
              • +1
                Лучше, строго говоря, применять интринсики.
                AT&T слишком «другой» по сравнению с MSVC-шным синтаксисом, который был у меня первым, и который я до сих пор считаю лучшим, плюс как уже сказали, непохожесть на nasm/yasm, только и всего. Плюc AT&T на первый взгляд содержит немного больше мусора, чем остальные варианты.
                Не знаю, почему они решили использовать его дефолтным, разве что для совместимости с gcc/clang.
    • +2
      Делать ассемблерные вставки однозначно можно. Но, вряд ли его использование в рамках Rust можно считать хорошим решением, т.к. компилятор не сможет провести анализа работы с памятью и одна из ключевых возможностей — гарантия безопасности операций, на данный код распространяться не будет.
      • 0
        Только если там не будет возможности использования SSE/AVX, то придется только так. Например в Go пакета с SSE/AVX нет, поэтому приходится import «C» использовать и расширения gcc (или тот-же asm()).
  • –4
    > Основным продуктом, разрабатываемым на Rust, является новый веб-движок Servo
    Как, опять новый веб-движок?
    Ооооох.
  • 0
    Да, еще один. Но, если бы не этот «опять новый веб-движек», то не появился бы Rust.
    Немного о движке:
    * Architecting Servo: Pipelines and Parallelism
    * Layout in Servo: Parallel and Rustic Tree Traversals
    • 0
      Скоро ждать аналога от гугла и очередного витка войны браузеров?
      • +2
        Да они и так конкурируют. КМК, Мозилла решила, что имея в своем распоряжении движок написанный на языке типа Rust, у них будет конкурентное преимущество над разработчиками с C++ движками. Подобное ожидание выглядит довольно разумно, т.к. многопоточный код на Rust писать быстрее и проще чем на C++. А если еще и производительность будет не больше чем 1,5-2 раза ниже чем у плюсового кода, то это вообще победа :)
        • 0
          Только вот они бы для начала спецификацию продумали до конца и что туда включать. А то это добавят, это добавят, это поломают, там починят. Это все-таки не программа, а язык программирования, если делать с ним что ни попадя, то результат будет плачевным (костыль на костыле и т.п.).
          • 0
            Для версии 0.7 — это нормальная ситуация. Ближе к 1.0 язык стабилизируют.
          • +1
            В этом состоит прекрасная часть Rust: этот язык community driven. Пока не заморожена 1.0 вы можете написать свой фидбек на синтаксис или фичи и если этот фидбек конструктивен — к нему прислушаются.
            • +1
              Не доверяю я этим вашим «комьюнити», там много людей разной компетенции, много идей и пихать все это в кучу плохая мысль. Я больше доверяю лицам вроде создателей Go, которые, как выразился Бобок на Radio T, на языках собаку съели (а еще на OS).
              • +2
                Ну, Rust тоже не первоклассники пишут, я ж не говорю что каждый может туда всё что хочешь впихнуть. Но способ разработки, когда с одной стороны не заморожен API, а с другой язык щупают на практике и получают фидбек, согласитесь, имеет смысл. И вот тут помощь коммьюнити бесценна.
  • +7
    Весьма хорошо, что разработчики постепенно понимают важность удобного написания и исполнения параллельного кода и корутин, и выходят новые удобные инструменты для этого. Конкуренция в таких вещах положительно влияет на удобство и возможности (<joke>и демонстрирует фанатам node.js его ущербность</joke>)

    Судя по статье и документации, Rust — это чуть более человечный Erlang. Те же immutable структуры, попытки минимизировать использование shared state посредством мучений разработчиков при их использовании, и всякие приятные вещи типа pattern matching'а.

    С другой стороны, есть Go. Помимо его очевидного недостатка (Not invented here), и перекладывания на разработчика ответственности за целостность shared state, он даёт гораздо больше возможностей для работы, чтобы называть его языком более высокого уровня, чем Rust, при сохранении удобства и эффективности параллельного исполнения кода и использования памяти (ну и, само собой, не мучает разработчика immutable переменными, отсутствием необходимых структур типа ассоциативных массивов, и прочими ограничениями).

    Посмотрим, что из этого выйдет.
  • +2
    Наконец то нашел подробный материал по Rust, спасибо вам. После прочтения сложилось впечатление, что идеологии Rust и D очень схожи: Erlang-like многопоточность, строгий следящий за shared состояниями компилятор, разделение кода на unsafe и safe.
    • +2
      Да не за что. В принципе, я свои ощущения и небольшие заметки еще сюда складываю, можно и там глянуть, если интересно.
      • 0
        Вот бы RSS ещё)
  • +2
    Самый интересный нативный язык на данный момент. Спасибо за статью.
  • 0
    static.rust-lang.org/doc/tutorial.html#generics
    >Inside a generic function, the names of the type parameters (capitalized by convention) stand for opaque types. All you can do with instances of these types is pass them around: you can't apply any operations to them or pattern-match on them.
    Ололо, type-erasure
    • 0
      Ну, на самом деле именно из этого куска наличие стирания типов не следует (где-нибудь в скале без дополнительных рефлективных действий тоже ничего не сделаешь с параметром типа T), но по факту да, в Rust присутствует стирание типов. Они ближе к шаблонам, чем к дженерикам.
  • 0
    do spawn { // (3)
    println((«Message form task 3»);
    }

    2 открывающие и одна закрывающая. Опечатка или синтаксис?
    • 0
      Опечатка.
  • +1
    Вообще раст — очень годный язык. Я следил за ним почти с самого его появления, и с тех пор он очень сильно поменялся, причём в лучшую сторону. Многие лишние фичи выкинули, новые более удобные добавили, что-то привели в гораздо более приятную форму.

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

    Но, как правильно было сказано, он сейчас довольно трудно юзабельный на нетривиальных вещах. И дело тут даже не в «немного» непривычной семантике (из наличия unique-типов следует очень много весёлых вещей, вроде явных lifetime'ов у borrowed pointer'ов и средств работы с ними), а в общей сырости компилятора. При использовании каких-нибудь сложных штук есть очень большой риск получить ICE, Internal Compiler Error. Но над этим работают очень активно, и баги на багтрекере принимаются от всех желающих.
    • +1
      Работал с растом с версии 0.5. Для маленьких проектов, вроде, всё стабильно, а, когда объём кода становится большим, то начинают отовсюду лезть трудноотлавливаемые ошибки.

      Язык действительно стремительно улучшается и становится стабильней, но в том состоянии, в котором он сейчас на нём писать проекты, кроме как для того, чтобы оценить язык не стоит. Синтаксис от версии к версии меняется очень сильно. То, что собиралось старой версией не будет собираться новой(кроме тривиальных программ). Сама модель выполнения, когда потоки «не настоящие» требует особой аккуратности от компилятора, ошибка доступа к памяти приведёт к смерти всего процесса со всеми такими потоками.

      Приведу пример: следующий код(чуть сложнее чем сишный switch-case) вызывает segmentation fault даже в последней стабильной версии раста:

      код
      enum Command {
          Create{qname:~str},
          Help,
          Unknown,
      }
      
      fn main() {
          let val = Unknown;
      
          match val{
              Create{qname: qname} => {
                  print(fmt!("Create %?", qname));
              }
              Help => {
                  print(~"Help");
              }
              _ => {
                  print(~"Other");
              }
          }
      }
      


      При этом не важно, сколько растовских тасков было запущено все они умрут!

      Вот ещё несколько ссылок на баги, встреченных и зарепорченных мной: 5999, 5998, 5906.

      В противоположность к этому, компилятор языка go, даже с ранних версий, генерировал очень стабильный код(или мне везло). Хоть и писал на нём больше чем на rust'е.
      • +1
        Как вам Rust против Go в плане синтаксиса?
        • +1
          Их сложно сравнивать, потому что они разные:
          1) Мне, как питонисту, нравится в go возможность не писать ";" в конце каждой строки. В rust'е правила использования ";" довольно непривычные, от пропущенного ";" часто может измениться семантика, т.е. код будет компилироваться, но делать не то, что от него ожидали.
          2) От символов "::" в rust'е у меня немного рябит в глазах, точка нравится больше.
          3) В rust'е много внимания уделено менеджменту памяти и потокобезопасности, поэтому поначалу путаешься какой тип указателей использовать(а их четыре: @, ~, & и *).
          4) В rust'овский match влюбился сразу же! На мой взгляд это самая вкусная особенность языка.

          Но, на самом деле, это все дело вкуса и привычки.
        • +1
          У Go меньше возможностей в языке заложено, он проще. Про стабильность уже говорили, в Go код генерируется достаточно качественный. С другой стороны, код на Rust поддерживает за счёт системы типов и указателей больше инвариантов => больше безопасность, особенно в случае многопоточности. Плюс в Rust очень удобные ADT и паттерн-матчинг на них.

          Inherent mutability — тоже очень интересная фича, я ни в одном другом языке такого не видел. Сначала может показаться, что с этим непонятно как жить, но гибкий механизм impl'ов здесь спасает.

          Но вот что мне больше всего не нравится в Go — это отсутствие любого рода дженериков. Это очень серьёзное упущение для современного языка. В Rust дженерики есть, и достаточно мощные.
          • 0
            В Go работа с интерфейсами весьма близка к generic'ам, и их мощность хорошо видна даже по стандартной библиотеке, где, например, везде где можно используются io.Reader'ы и io.Writter'ы
            • 0
              Нет. Дженерики — это параметрический полиморфизм, а интерфейсы Go — это ближе к subtyping-полиморфизму. Они решают разные задачи.
              • 0
                Безусловно, Go не функциональный язык, и всего богатства параметрического полиморфизма там нет. Однако, если интерфейсы Go сравнивать с generic'ами в каком-нибудь C# или Java, то значительное количество кейсов использования generic'ов последних легко реализуется в рамках Go, что хорошо видно, например, по реализации кучи в стандартной библиотеке Go.
                • +2
                  Посмотрел на пакет container/heap. Очень интересный подход, практически не свойственный другим языкам, однако дженерики там не причём. Он вполне реализуется на интерфейсах той же джавы, пусть и с некоторыми костылями, связанными с отсутствием в Java решения expression problem.

                  А вот то, что методы Push() и Pop() тамошнего интерфейса Interface используют interface{} в качестве типа для данных — это очень печально и как раз есть то, с чем бы дженерики очень помогли. В некотором роде, их отсутствие даже странно, потому что Go позиционируется как язык с довольно строгой системой типов (отсутствие неявных преобразований тому подтверждение). И то, что для написания достаточно обобщённого кода приходится обращаться к динамическим кастам, сильно разочаровывает.

                  А параметрический полиморфизм — это и не есть фича функциональных языков)
  • +1
    «Разделяемые указатели (Managed boxes)»

    Все же в рускоязычной литературе managed обычно переводится как «Управляемый». На примере того же .Net — managed language — управляемый язык, managed code — управляемый код. По аналогии unmanaged — неуправляемый.
    • 0
      Да, в рускоязычной литературе managed обычно переводится как «Управляемый». В то же время, в Rust есть несколько типов управляемых указателей, поэтому переводить какой-то один из них как «управляемы» было бы не совсем корректно. К тому же, при подборе русского названия я еще исходил из поведения указателя.
  • 0
    А SMP для Rust уже имеется?
    • +1
      SMP — архитектура компьютеров. Как следствие, вопрос не совсем понятен.
      • 0
        Испрользовал терминологию из Erlang… Имею в виду, умеют ли зеленые процессы расходиться по ядрам процессора
  • –1
    отсутствие null-указателей

    Реально? Но ведь они же постоянно нужны. Например, можно было бы намекать сборщику мусора, что объект нужно удалить, путём присвоения указателю нулевого указателя.

    Для создания разделяемых указателей используется унарный оператор @

    Как я понял, код
    let x = @Point {x: 1f, y: 1f};

    не просто создаёт разделяемый указатель, как вы написали, он создаёт сам объект Point в куче и кладёт указатель на него в x. Т. е. не просто берётся адрес от объекта типа Point, а для него выделяется память. Т. е. этот код ближе к такому коду на C++:
    Point *x = new Point(1.0, 1.0);

    чем к такому (сломанному):
    Point *x = &Point(1.0, 1.0);

    Т. е., как я понял, @ ведёт себя как new. Тогда про это нужно написать в статье. То же с ~.

    Ещё у меня такой вопрос: @ (и ~) всегда ведёт себя как new? Или всё-таки есть ситуация, когда @ просто берёт адрес? Например, что будет в следующем коде?
    let x = @0;
    let y = @*x;

    @ просто возмёт адрес и таким образом в y будет лежать тот же указатель, что и в x? Или @ всё-таки выделит новую память и объект скопируется?
    • +1
      Реально? Но ведь они же постоянно нужны. Например, можно было бы намекать сборщику мусора, что объект нужно удалить, путём присвоения указателю нулевого указателя.


      Нулевой указатель вполне заменяется использованием более безопасных конструкций: Выражениями, возвращающими значение, вместо процедурного стиля (см. if, match, etc.), Option/Maybe для возможно отсутствующего значения.

      С GC же все проще: зачем занулять ссылку, если скоро функция кончится и ссылка сама уйдет со стека? Если же это бесконечный цикл или поле долгоживущего объекта, то Option/Maybe тут самое место.
  • 0
    А как там обстоят дела с разноязыковым взаимодействием? На планировщике Rust можно запускать кусочки кода на другом языке, хотя бы теоретически, чтоб они вперемешку с блоками Rust работали?
    • 0
      Планировщик Rust «выполняет» обычный машинный код. По умолчанию Rust поддерживает вызовы Си кода. Ну а через Си ты уже можешь вызвать что угодно.
      Хотя возникает вопрос — зачем? Ты же понимаешь, что такой код должен быть помечен как unsafe и проверка работы с памятью времени компиляции к нему применяться не будет.
      • 0
        Будет, но другим компилятором.

        Я про другое. Вот этот код на другом языке, на Erlang, Limbo или вообще каким–нибудь Intel Cilk должен иметь возможность сделать yield, создавать зелёные потоки, делать обмен сообщений между этими потоками.

        До тех пор, пока нет общей платформы, каждый тянет в свою сторону, как в басне Крылова. Intel Cilk, Limbo, Go, Erlang, каждый по отдельности с зелёными потоками, а вместе никак.

Только зарегистрированные пользователи могут оставлять комментарии. Войдите, пожалуйста.