Футуры с нулевой стоимостью в Rust

Перевод | Автор оригинала: Aaron Turon

Одним из ключевых пробелов в экосистеме Rust была сильная история быстрого и производительного асинхронного ввода-вывода. У нас есть прочная основа, такая как библиотека mio, но она очень низкого уровня: вы должны напрямую подключать конечные автоматы и манипулировать обратными вызовами.

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

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

За последние пару месяцев мы с Алексом Крайтоном разработали бесплатную фьючерсную библиотеку для Rust, которая, как мы полагаем, позволяет достичь этих целей. (Спасибо Карлу Лерче, Иегуде Кац и Николасу Мацакису за идеи, сделанные на этом пути.)

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

Почему асинхронный ввод-вывод?

Прежде чем углубляться в будущее, будет полезно немного поговорить о прошлом.

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

// reads 256 bytes into `my_vec`
socket.read_exact(&mut my_vec[..256]);

Быстрая викторина: что произойдет, если мы еще не получили достаточно байтов от сокета?

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

Вначале в Rust была модель «зеленых нитей», мало чем отличавшаяся от Go. Вы могли развернуть большое количество легких задач, которые затем были запланированы на реальные потоки ОС (иногда называемые «потоками M: N»). В зеленой модели потоков такая функция, как read_exact, блокирует текущую задачу, но не базовый поток ОС; вместо этого планировщик задач переключается на другую задачу. Это замечательно, потому что вы можете масштабироваться до очень большого количества задач, большинство из которых заблокированы, при использовании лишь небольшого количества потоков ОС.

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

Итак, если мы хотим обрабатывать большое количество одновременных подключений, многие из которых ждут ввода-вывода, но мы хотим свести количество потоков ОС к минимуму, что еще мы можем сделать?

Асинхронный ввод-вывод - это ответ - и, по сути, он также используется для реализации зеленой потоковой передачи.

Вкратце, с помощью асинхронного ввода-вывода вы можете попытаться выполнить операцию ввода-вывода без блокировки. Если это не может быть выполнено немедленно, вы можете повторить попытку позже. Чтобы это работало, ОС предоставляет такие инструменты, как epoll, позволяющие запрашивать, какие из большого набора объектов ввода-вывода готовы к чтению или записи - по сути, это API, предоставляемый mio.

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

Футуры

Так что же будущее?

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

И так далее. Дело в том, что футуры применимы к асинхронным событиям всех форм и размеров. Асинхронность отражается в том факте, что вы получаете будущее сразу, без блокировок, даже если значение, которое представляет будущее, станет готовым только в какое-то неизвестное время в… будущем.

В Rust мы представляем футуры как трэйту (то есть интерфейс), примерно:

trait Future {
    type Item;
    // ... lots more elided ...
}

Тип «Предмет» говорит о том, какую ценность принесет будущее, когда оно будет завершено.

Возвращаясь к нашему предыдущему списку примеров, мы можем написать несколько функций, производящих разные футуры (используя синтаксис impl):

// Lookup a row in a table by the given id, yielding the row when finished
fn get_row(id: i32) -> impl Future<Item = Row>;

// Makes an RPC call that will yield an i32
fn id_rpc(server: &RpcServer) -> impl Future<Item = i32>;

// Writes an entire string to a TcpStream, yielding back the stream when finished
fn write_string(socket: TcpStream, data: String) -> impl Future<Item = TcpStream>;

Все эти функции будут немедленно возвращать свое будущее, независимо от того, завершено ли событие, которое представляет будущее; функции не блокируются.

Когда вы комбинируете футуры, все становится действительно интереснее. Есть бесконечное количество способов сделать это, например:

В качестве простого примера, используя приведенные выше футуры, мы могли бы написать что-то вроде:

id_rpc(&my_server).and_then(|id| {
    get_row(id)
}).map(|row| {
    json::encode(row)
}).and_then(|encoded| {
    write_string(my_socket, encoded)
})

См. Этот код для более подробного примера.

Это неблокирующий код, который проходит через несколько состояний: сначала мы выполняем вызов RPC для получения идентификатора; затем ищем соответствующую строку; затем кодируем его в json; затем записываем его в сокет. Под капотом этот код будет компилироваться до фактического конечного автомата, который выполняется с помощью обратных вызовов (без накладных расходов), но мы можем написать его в стиле, который не далек от простого кода блокировки. (Rustaceans заметят, что эта история очень похожа на Iterator в стандартной библиотеке.) Эргономичный высокоуровневый код, который компилируется в конечный автомат и обратные вызовы: вот что мы искали!

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

Streams

Но подождите - это еще не все! Продолжая продвигать будущие «комбинаторы», вы сможете не только достичь паритета с помощью простого блокирующего кода, но и делать вещи, которые в противном случае могут быть сложными или болезненными. Чтобы увидеть пример, нам понадобится еще одно понятие: потоки.

Футуры - это одно значение, которое в конечном итоге будет произведено, но многие источники событий, естественно, создают поток значений с течением времени. Например, входящие TCP-соединения или входящие запросы к сокету являются естественными потоками.

Библиотека футур также включает в себя трейт Stream, который очень похож на футуры, но настроен для создания последовательности значений с течением времени. В нем есть набор комбинаторов, некоторые из которых работают с футурами. Например, если s - это поток, вы можете написать:

s.and_then(|val| some_future(val))

Этот код предоставит вам новый поток, который работает, сначала извлекая значение val из s, затем вычисляя из него some_future (val), затем выполняя это future и возвращая его значение, а затем делая это снова и снова, чтобы получить следующее значение в ручей.

Давайте посмотрим на реальный пример:

// Given an `input` I/O object create a stream of requests
let requests = ParseStream::new(input);

// For each request, run our service's `process` function to handle the request
// and generate a response
let responses = requests.and_then(|req| service.process(req));

// Create a new future that'll write out each response to an `output` I/O object
StreamWriter::new(responses, output)

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

Давайте сделаем все поинтереснее. Предположим, что протокол конвейерный, т. Е. Что клиент может отправлять дополнительные запросы в сокет до получения ответа от обрабатываемых. Мы хотим фактически обрабатывать запросы последовательно, но здесь есть возможность некоторого параллелизма: мы могли бы читать и анализировать несколько запросов вперед, пока текущий запрос обрабатывается. Сделать это так же просто, как вставить еще один комбинатор в нужное место:

let requests = ParseStream::new(input);
let responses = requests.map(|req| service.process(req)).buffered(32); // <--
StreamWriter::new(responsesm, output)

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

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

Нулевая стоимость?

Я несколько раз утверждал, что наша библиотека Futures предоставляет абстракцию с нулевой стоимостью, поскольку она компилируется в нечто очень близкое к коду конечного автомата, который вы пишете вручную. Чтобы сделать это более конкретным:

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

Но доказательство в пудинге. Мы написали простую структуру HTTP-сервера, minihttp, которая поддерживает конвейерную обработку и TLS. Этот сервер использует футуры на каждом уровне своей реализации, от чтения байтов из сокета до обработки потоков запросов. Помимо того, что это приятный способ написания сервера, это обеспечивает довольно сильный стресс-тест на накладные расходы абстракции футур.

Чтобы получить базовую оценку этих накладных расходов, мы затем реализовали тест TechEmpower с открытым текстом. Этот микробенчмарк тестирует HTTP-сервер «привет, мир», отправляя ему огромное количество одновременных и конвейерных запросов. Поскольку «работа», которую сервер выполняет для обработки запросов, тривиальна, производительность в значительной степени является отражением основных накладных расходов серверной инфраструктуры (и в нашем случае фреймворка Futures).

TechEmpower используется для сравнения очень большого количества веб-фреймворков на разных языках. Мы сравнили minihttp с несколькими главными претендентами:

Вот результаты в количестве «Hello world!», Обслуживаемых в секунду на 8-ядерной машине Linux:

Диаграмма

Можно с уверенностью сказать, что футуры не предполагают значительных накладных расходов.

Обновление: чтобы предоставить дополнительные доказательства, мы добавили сравнение minihttp с версией конечного автомата с прямым кодированием в Rust (см. «Raw mio» в ссылке). Эти два значения находятся в пределах 0,3% друг от друга.

Будущее

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

На данный момент библиотека вполне пригодна для использования и довольно тщательно документирована; он поставляется с учебным пособием и множеством примеров, в том числе:

а также различные интеграции, например основанный на футурых интерфейс для curl. Мы активно работаем с несколькими людьми из сообщества Rust, чтобы интегрировать их в их работу; если вам интересно, свяжитесь с Алексом или со мной!

Если вы хотите выполнять низкоуровневое программирование ввода-вывода с помощью futures, вы можете использовать futures-mio для этого поверх mio. Мы думаем, что это захватывающее направление для развития программирования асинхронного ввода-вывода в Rust в целом, и в последующих публикациях будет более подробно рассказано о механике.

В качестве альтернативы, если вы просто хотите говорить по HTTP, вы можете работать поверх minihttp, предоставив службу: функцию, которая принимает HTTP-запрос и возвращает будущий HTTP-ответ. Такая абстракция RPC / сервисов открывает двери для написания большого количества многоразового «промежуточного программного обеспечения» для серверов и получила большую популярность в библиотеке Twitter Finagle для Scala; он также используется в библиотеке Facebook Wangle. В мире Rust уже существует библиотека под названием Tokio, которая создает абстракцию общих служб на основе нашей библиотеки Futures и может выполнять роль, аналогичную Finagle.

Впереди огромный объем работы:

Какими бы ни были ваши интересы, мы будем рады услышать от вас - мы acrichto и aturon на IRC-каналах Rust. Приходите поздороваться!