Содержание


Rust - новый язык программирования: Часть 16. Многопоточность. Задачи и их взаимодействие

Comments

В предыдущих статьях цикла был сделан общий обзор языка программирования Rust, обсуждались основы синтаксиса: были описаны простейшие компоненты (переменные, простые типы данных, операторы и расширения), управляющие конструкции и составные структуры данных, функции и деструкторы. Особое внимание было уделено модели памяти в целом, концепции владения объектами, общим принципам управления памятью, использованию собственных и управляемых блоков общей памяти, а также заимствованных указателей. Далее описывались средства ввода/вывода, векторы и строки, контейнеры и итераторы, замыкания и do-выражения. Темой двух отдельных статей была объектная система Rust, трэйты, их методы, а также обобщённые функции. В предыдущей статье обсуждалась модульная система языка Rust, основанная на крэйтах; кроме того, был сделан краткий обзор стандартной библиотеки. В этой статье рассматривается организация многопоточности в Rust.

В наше время многопоточность, как средство организации одновременного выполнения нескольких задач в рамках одного приложения, стала уже привычным свойством программ. Различные языки и системы программирования используют разные подходы для поддержки многопоточности. Rust предлагает безопасную модель параллельного выполнения, основанную на сочетании упрощённых (lightweight; их иногда называют "облегчёнными" или "легковесными") задач (tasks), размещаемых в отдельных защищённых и изолированных друг от друга областях памяти, и подсистемы передачи сообщений (message passing). Кроме того, при рассмотрении многопоточности (многозадачности) в Rust невозможно обойти вниманием её связь с системой типов и с системой распределения памяти.

1. Задачи

В языке Rust задача (task) представляет собой несколько другую сущность нежели более привычный, хорошо знакомый многим программистам "поток" (thread; распространена также калька "тред"), скорее задачи следует классифицировать, как так называемые "зелёные потоки" (green threads) - легковесные потоки, создаваемые и управляемые в пространстве пользователя, способные в случае отстуствия поддержки встроенных потоков операционной системой (ОС) самостоятельно эмулировать многопоточную среду. Если ОС поддерживает многопоточность, то во время выполнения эти легковесные зелёные потоки при планировании процессов отображаются на небольшое количество встроенных потоков (или процессов) ОС, то есть, в одном процессе ОС могут совместно существовать и выполняться несколько задач Rust. На многопроцессорных или многоядерных системах задачи Rust по умолчанию планируются для действительного параллельного выполнения. Поскольку создание таких задач существенно менее затратно, чем создание обычных потоков (тредов), Rust может создавать сотни тысяч параллельно выполняющихся задач даже на 32-битных системах, не говоря уже о более мощных.

Вообще говоря, даже в самом простом случае весь код Rust выполняется в рамках одной задачи, включающей функцию main(). Поэтому более правильным подходом будет представление любой выполняемой Rust-программы в виде дерева задач. Типичная задача Rust состоит из функции входа (entry function), стека, набора исходящих каналов и входящих портов для обмена данными, а кроме того задаче передаётся во владение некоторая часть общей памяти (heap) соответствующего отдельного процесса ОС. Впрочем, авторы языка рекомендуют не обращаться к каналам и портам обмена данными напрямую, а вместо этого пользоваться абстракциями более высокого уровня, предоставляемых стандартной библиотекой и библиотеками расширений; например, соединениями (pipes).

1.1. Планирование задач

Как уже было сказано выше, планировщик времени выполнения отображает задачи на некоторое количество потоков ОС (если таковая имеется). По умолчанию планировщик выбирает количество потоков ОС в соответствии с количеством физических процессоров или ядер процессоров, обнаруженных при запуске. Количество планируемых потоков можно изменить во время выполнения. Если количество задач превышает количество потоков ОС - такое происходит весьма часто, - то планировщик объединяет несколько задач в одном потоке. Этот тип планировщика описывается соотношением "потоки:процессы" M:N и позволяет получить близкие к оптимальным результаты для задач, выполняемых параллельно на нескольких процессорах. Конечно, в таких случаях работа одинакового количества потоков ОС и задач (tasks) может давать лучшие результаты, тем не менее, Rust использует схему планирования M:N для поддержки очень большого количества задач в таких контекстах, в которых потоки ОС являются слишком ресурсоёмкими, когда используются в больших количествах. Накладные расходы на потоки существенно различаются для разных ОС, и именно поэтому подобная адаптируемость не всегда оправдывает затраты на её обеспечение.

Во время выполнения текущая планируемая задача получает конечный интервал времени (time slice) для выполнения, по истечении которого она перестаёт быть планируемой (descheduled) и перемещается в конец циклической очереди ожидания или другую подобную позицию для вытесненных задач, после чего начинается планирование и выполнение другой задачи из очереди, выбранной по псевдо-случайному принципу.

Любая выполняющаяся задача может в любой момент передать управление с помощью метода std::task::yield, который немедленно завершает её планирование и выполнение. Подобным же образом завершается планирование и выполнение любой задачи, которая входит в состояние, отличающееся от номинального выполнения (блокировка, останов и т.п.).

1.2. Цикл существования задачи

Замечание о состояниях задачи, которым завершился предыдущий раздел, вводит в рассмотрение следующую актуальную тему - цикл существования (lifecycle) задачи, который состоит из конечного набора состояний и событий, вызывающих переходы между этими состояниями. На протяжении цикла существования задача может иметь следующие состояния:

  • выполняется (running)
  • блокирована (blocked)
  • сбой, ошибка (failing)
  • останов (dead)

После того, как задача создана (в терминах Rust - порождена - spawned), цикл её существования начинается с состояния "выполняется" (running). В этом состоянии задача выполняет все команды из своей функции входа (entry function) и все вызываемые из неё функции.

Задача может перейти из состояния "выполняется" в состояние "блокирована" (blocked) в любой момент, когда она выполняет блокирующий вызов для обмена данными. Если такой вызов может быть выполнен успешно, то есть, запрашиваемое сообщение приходит к отправителю или открывается буфер для приёма сообщения, то заблокированная задача разблокируется и переходит обратно в состояние "выполняется".

В любой момент времени задача может перейти в состояние "ошибка" (failing) либо вследствие уничтожения её каким-либо внешним событием, либо по причине внутреннего характера - выполнение макрокоманды fail!(). После перехода в состояние "ошибка" задача проходит по своему стеку в обратном направлении (часто это действие обозначается, как "обратная раскрутка стека") и переходит в состояние "останов" (dead). Обратный проход по своему стеку задача выполняет сама. Если во время такого обратного прохода требуется освобождение значения с деструктором, то запускается код этого деструктора - также в стеке, управляемом данной задачей. Инициализация кода деструктора вызывает временный переход в состояние "выполняется", что позволяет такому коду также последовательно переходить в различные состояния. Поэтому исходная задача, пребывающая в состоянии ошибки и обратной раскрутки стека, может быть временно приостановлена для того, чтобы начать рекурсивную обратную раскрутку стека ошибочного деструктора. Тем не менее, в любом случае самый внешний обратный проход по стеку будет продолжаться до тех пор, пока стек не будет обработан полностью, после чего задача перейдёт в состояние "останов" (dead). После перехода задачи в состояние "ошибка" нет никакого способа восстановить её и продолжить выполнение. Если задача в состоянии "ошибка" временно приостановлена в процессе обратной раскрутки стека, то ошибка при выполнении текущего деструктора может привести к критической ошибке (hard failure). В этом случае процедура обработки критической ошибки освобождает ресурсы, но не выполняет код деструкторов. Обработка первоначальной (некритической) ошибки возобновляется с той точки, где задача была временно приостановлена.

Задача, находящаяся в состоянии "останов" (dead), не может переходить в какие-либо другие состояния, она существует только для того, чтобы прочие задачи, отслеживающие её статус, получили информацию о её завершении, и/или для того, чтобы ожидать оповещения о том, что самая последняя ссылка на неё уничтожена.

Таким образом обеспечивается полная изолированность задач друг от друга, следовательно, повышается уровень безопасности. Какая бы критическая ошибка ни возникла в Rust-коде, будь то результат явного вызова fail!(), внешние факторы или любая некорректная операция, система времени выполнения уничтожит только одну ошибочную задачу, не затрагивая прочие. В отличие от таких языков программирования, как C++ и Java, здесь нет команды перехвата исключений catch. Вместо этого задачи имеют возможность отслеживать состояние друг друга, чтобы обнаружить "ошибку" и "останов".

1.3. Управление памятью и типы данных

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

Задачи применяют систему типов Rust для строгого обеспечения безопасности используемой памяти. В частности, система типов гарантирует, что задачи никоим образом не могут совместно использовать изменяемые объекты. Обмен данными между задачами осуществляется посредством передачи только собственных (owned) данных через глобальную область памяти, предназначенную для обмена (global exchange heap).

1.4. Библиотеки поддержки многопоточности

В настоящее время многопоточность в Rust обеспечивается следующими стандартными и расширенными модулями:

  • std::task - здесь собран весь код, относящийся к задачам и их планированию
  • std::comm - интерфейс передачи сообщений
  • extra::comm - дополнительные типы сообщений, основанные на std::comm
  • extra::sync - относительно реже применяемые средства синхронизации, включая блокировки (locks)
  • extra::arc - тип ARC (Atomically Reference Counted) для безопасного совместного использования неизменяемых данных
  • extra::future - тип, представляющий значения, которые могут быть вычислены параллельно и извлекаться по запросам в более позднее время

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

1.5. Создание параллельной задачи на практике

Как уже было сказано выше, программный интерфейс для создания задач и управления ими расположен в модуле task библиотеки std, и этот интерфейс по умолчанию доступен в любом Rust-коде (напоминание об автоматическом включении компилятором некоторых модулей стандартной библиотеки). В самой простой форме создание задачи осуществляется через вызов функции spawn() с аргументом, представляющим собой замыкание. Функция spawn() выполняет это замыкание в новой порождённой задаче. Варианты вызова функции spawn() продемонстрированы в листинге 1.

Листинг 1. Различные формы вызова функции spawn()
Различные формы вызова функции spawn()

// Обычная именованная функция, выводящая текст, передаётся в порождаемую задачу
fn print_text() {
  println( "Создана новая задача..." );
}
spawn( print_text );

// Аргументом spawn() является замыкание с использованием лямбда-выражения
spawn( || println( "Создана ещё одна новая задача..." ) );

// "Канонический" способ вызова spawn() с использованием do-синтаксиса
// рекомендуется в большинстве случаев
do spawn {
  println( "Создана третья новая задача..." );
}

В Rust нет ничего специфического, касающегося создания задач: сама по себе задача - это не реализация концепции в семантике языка. Вместо этого система типов Rust обеспечивает всё необходимое для реализации безопасного параллелизма, в частности, это собственные типы (owned types). Ядро языка оставляет все детали реализации на откуп стандартной библиотеке.

Сигнатура функции spawn очень проста: fn spawn( f: ~fn() ). Поскольку она принимает исключительно собственные замыкания (owned closures), а собственные замыкания содержат только собственные данные (owned data), функция spawn() может вполне безопасно перемещать замыкание в целом и всё связанное с ним состояние в абсолютно другую задачу для выполнения. Как и любое замыкание, функция, передаваемая в spawn(), может захватывать среду (контекст) и переносить его между задачами, как показано в листинге 2.

Листинг 2. Захват контекста и перенос его в новую задачу
// Генерация определённого состояния в локальной среде
let child_task_number = generate_task_number();

do spawn {
  // захват локального контекста и использование его в порождённой задаче
  println( fmt!( "Это задача номер %d", child_task_number ) );
}

2. Обмен данными между задачами

В очередной раз следует подчеркнуть, ввиду особой важности этого факта, что в Rust задачи полностью изолированы друг от друга и лишены возможности как-либо взаимодействовать с чужой памятью напрямую (исключение составляют лишь блоки unsafe-кода, но это тема для отдельного обсуждения). Виды связи между задачами ограничены только безопасными формами передачи владения объектами, при этом совместный доступ к данным в памяти запрещён в какой бы то ни было форме - за это отвечает система типов.

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

  • синхронные и асинхронные каналы обмена данными с различными коммуникационными топологиями;
  • защищённые от записи и доступные для чтения-записи разделяемые переменные с разнообразными шаблонами безопасных взаимных исключений (mutual exclusion), которые часто называют мьютексами;
  • простые средства блокировки и семафоры.

Все значения, используемые в коммуникационных операциях, ограничены специализированным типом Send (подробнее см. Send type-kind. Это ограничение гарантирует, что между задачами не будут передаваться такие "небезопасные" объекты, как заимствованные и управляемые указатели. Таким образом, доступ к составной структуре данных может быть осуществлён только через её собственное "корневое" значение, следовательно, никаких дополнительных блокировок или копирований не требуется для того, чтобы избежать состояния гонки за ресурсом (data races) при использовании структур такого типа.

2.1. Соединения

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

Самым простым способом создания соединения является использование функции comm::stream() для создания пары (Port, Chan). В терминологии Rust канал (channel, Chan) - это конечная точка соединения для отправки данных, а порт (Port) - это конечная точка для приёма данных. В листинге 3 представлен пример создания соединения и передачи по нему результатов паралельных вычислений.

Листинг 3. Создание и использование соединения при распараллеливании вычислений
let (port, chan): (Port<int>, Chan<int>) = stream();

do spawn || {
  let result = some_expensive_computation();
  chan.send( result );
}

some_other_expensive_computation();
let result = port.recv();

Поскольку здесь впервые используется соединение между задачами, этот пример заслуживает несколько более подробного описания. В первой строке команда let создаёт соединение (поток - stream) для отправки и приёма целочисленных значений (int). Здесь следует вспомнить одно из замечательных свойств языка Rust, описанных в одной из первых статей цикла, - левая часть команды let (port, chan) представляет собой хороший пример let с деструктуризацией (destructuring let): эта конструкция разделяет кортеж на отдельные компоненты и выполняет их инициализацию.

Далее следует типичная конструкция do, создающая (порождающая) новую задачу, которая будет использовать канал chan для передачи данных в родительскую задачу, ожидающую приёма данных из порта port.

Следует обратить особое внимание на то, что при создании новой задачи замыкание передаёт канал chan в неявной форме: замыкание захватывает chan в своём контексте (среде). Chan и Port являются передаваемыми (sendable) типами, поэтому могут захватываться в порождаемые задачи или какм-либо другим способом передаваться между ними. В рассматриваемом примере предполагается, что порождённая задача выполняет некоторое сложное и длинное вычисление, результат которого отправляется через захваченный канал chan.

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

Пара (Port, Chan), создаваемая функцией stream(), обеспечивает эффективный обмен данными между одним отправителем и одним получателем, но несколько отправителей не могут пользоваться одним и тем же каналом и несколько получателей не могут воспользоваться одним и тем же портом. Попыка нарушить это правило приводит к ошибке, как показано в листинге 4.

Листинг 4. Некорректная попытка подключить несколько отправителей к одному каналу
let (port, chan) = stream();

do spawn {
  chan.send( some_expensive_computation() );
}

// Ошибка: задача, созданная предыдущим вызовом spawn, уже захватила канал,
// то есть владение каналом передано в предыдущую задачу;
// поэтому компилятор запрещает повторный захват данного ресурса
do spawn {
  chan.send( another_expensive_computation() );
}

Данная проблема решается посредством применения SharedChan - типа, позволяющего совместно использовать один и тот же канал нескольким отправителям (см. листинг 5).

Листинг 5. Использование типа SharedChan для обеспечения доступа нескольким отправителям к одному каналу
let (port, chan) = stream();
let chan = SharedChan::new( chan );

for init_val in range( 0u, 3 ) {
  let child_chan = chan.clone();
  do spawn {
    child_chan.send( some_expensive_computation(init_val) );
  }
}

let result = port.recv() + port.recv() + port.recv();

В данном примере владение каналом сразу после его создания передаётся новому значению типа SharedChan. Как и Chan, SharedChan является некопируемым (non-copyable), собственным типом (который часто называют аффинным (affine) или линейным (linear) типом). Но в отличие от Chan программист может дублировать SharedChan с помощью метода clone(). Клонированный таким образом SharedChan предоставляет новый пункт обработки на том же самом канале, позволяя нескольким задачам одновременно пользоваться этим каналом для отправки данных в один порт. И это самый простой случай, потому что помимо spawn, stream и SharedChan модули srd::task и std::comm предлагают достаточно обширный набор инструментов для реализации многих полезных вариантов применения задач и соединений для обмена данными между ними.

Но справедливости ради следует всё-таки сказать, что пример из листинга 5 является надуманным и служит скорее для иллюстрации, нежели для реального применения, поскольку программист может просто использовать несколько пар, созданных методом stream(). Этот более близкий к практике вариант показан в листинге 6.

Листинг 6. Создание нескольких соединений для отдельных задач
// создание вектора портов - по одному для каждой порождённой задачи
let ports = do vec::from_fn(3) |init_val| {
  let (port, chan) = stream();
  do spawn {
    chan.send( some_expensive_computation(init_val) );
  }
  port
};

// ожидание на каждом порту с целью сбора отдельных результатов 
//и формирования общего результата
let result = ports.iter().fold( 0, |accum, port| accum + port.recv() );

Заключение

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


Ресурсы для скачивания


Похожие темы


Комментарии

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

static.content.url=http://www.ibm.com/developerworks/js/artrating/
SITE_ID=40
Zone=Linux, Open source
ArticleID=953962
ArticleTitle=Rust - новый язык программирования: Часть 16. Многопоточность. Задачи и их взаимодействие
publish-date=11212013