Ключевые возможности Rust

в 7:13, , рубрики: Rust, многопоточное программирование, параллельное программирование, Программирование, системное программирование, функциональное программирование, метки: , , ,

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 — новый язык программирования, разрабатываемый корпорацией Mozilla. Главная цель разработчиков — создание безопасного практичного языка для параллельных вычислений. Первая версия языка была написана Грэйдоном Хором в 2006 году, а в 2009 году к разработке подключилась Mozilla. С тех пор изменения претерпел и сам компилятор, изначально написанный на OCaml: он был успешно переписан на Rust с использованием LLVM в качестве back-end.
    Основным продуктом, разрабатываемым на Rust, является новый веб-движок Servo, разработка которого также ведется Mozilla. В 2013 году к разработке Rust и Servo присоединилась корпорация Samsung Electronics, при активном участии которой код движка Servo был портирован на ARM архитектуру. Поддержка языка столь серьезными игроками IT индустрии не может не радовать и дает надежду на его дальнейшее активное развитие и совершенствование.
    Язык Rust просто не может не понравится системным и сетевым разработчикам, тем, кому по работе приходится писать много кода, производительность которого критична, на C и C++, потому что:
  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 можно представить следующим образом:
Ключевые возможности 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)].

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

Временные указатели – указатели которые могут указывать на объект, размещенный в любом из возможных типов памяти: стеке, локальном или хипе обмена, а также на внутренний член любой структуры данных. На физическом уровне временные указатели представляют собой типичные Си указатели и, как следствие, не отслеживаются сборщиком мусора и не привносят никаких дополнительных накладных расходов. В то же время, их основным отличием от Си указателей являются дополнительные проверки, проводимые на этапе компиляции для гарантии возможности безопасного использования. Для создания временных указателей используется унарный оператор &

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) на каждый из элементов массива в функцию fn.

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).

<source lang="cpp">
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

Источник

Поделиться

* - обязательные к заполнению поля