Футуры с нулевой стоимостью в 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.
Проблема в том, что есть много болезненной работы по отслеживанию всех интересующих вас событий ввода-вывода и отправке их по нужным обратным вызовам (не говоря уже о программировании исключительно на основе обратных вызовов). Это одна из ключевых проблем, которую решает будущее.
Футуры
Так что же будущее?
По сути, будущее представляет собой ценность, которая может быть еще не готова. Обычно будущее становится полным (значение готово) из-за события, происходящего где-то в другом месте. Хотя мы рассматривали это с точки зрения базового ввода-вывода, вы можете использовать будущее для представления широкого спектра событий, например:
-
Запрос к базе данных, выполняемый в пуле потоков. Когда запрос завершается, будущее завершается, и его значение является результатом запроса.
-
Вызов RPC на сервер. Когда сервер отвечает, будущее завершено, и его значение - это ответ сервера.
-
Тайм-аут. Когда время истекает, будущее завершается, и его значение просто() («единичное» значение в Rust).
-
Долговременная задача с интенсивной загрузкой ЦП, выполняемая в пуле потоков. Когда задача завершается, будущее завершается, и его значение является возвращаемым значением задачи.
-
Чтение байтов из сокета. Когда байты готовы, будущее завершено - и в зависимости от стратегии буферизации байты могут быть возвращены напрямую или записаны как побочный эффект в некоторый существующий буфер.
И так далее. Дело в том, что футуры применимы к асинхронным событиям всех форм и размеров. Асинхронность отражается в том факте, что вы получаете будущее сразу, без блокировок, даже если значение, которое представляет будущее, станет готовым только в какое-то неизвестное время в… будущем.
В 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>;
Все эти функции будут немедленно возвращать свое будущее, независимо от того, завершено ли событие, которое представляет будущее; функции не блокируются.
Когда вы комбинируете футуры, все становится действительно интереснее. Есть бесконечное количество способов сделать это, например:
-
Последовательная композиция: f.and_then (| val | some_new_future (val)). Дает вам future, которое выполняет future f, берет созданный val для построения другого future some_new_future (val), а затем выполняет это future.
-
Отображение: f.map (| val | some_new_value (val)). Дает вам future, которое выполняет future f и дает результат some_new_value (val).
-
Присоединение: f.join (g). Дает вам future, в котором футуры f и g выполняются параллельно, и завершается, когда они оба завершены, возвращая оба их значения.
-
Выбор: f.select (g). Дает вам future, в котором футуры f и g выполняются параллельно, и завершается, когда одно из них завершается, возвращая свое значение и другое future. (Хотите добавить тайм-аут к любому будущему? Просто выберите это будущее и тайм-аут будущего!)
В качестве простого примера, используя приведенные выше футуры, мы могли бы написать что-то вроде:
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 предоставляет абстракцию с нулевой стоимостью, поскольку она компилируется в нечто очень близкое к коду конечного автомата, который вы пишете вручную. Чтобы сделать это более конкретным:
-
Ни один из будущих комбинаторов не требует выделения памяти. Когда мы делаем такие вещи, как цепное использование and_then, мы не только не выделяем, мы фактически создаем большое перечисление, которое представляет конечный автомат. (Для каждой «задачи» требуется одно выделение, которое обычно соответствует одному на соединение.)
-
Когда наступает событие, требуется только одна динамическая отправка.
-
Отсутствие дополнительных затрат на синхронизацию; если вы хотите связать данные, которые живут в вашем цикле событий, и получить к ним однопоточный доступ из Futures, мы дадим вам инструменты для этого.
И так далее. Позже в блогах будут подробно описаны эти утверждения и показано, как мы используем Rust для достижения нулевых затрат.
Но доказательство в пудинге. Мы написали простую структуру HTTP-сервера, minihttp, которая поддерживает конвейерную обработку и TLS. Этот сервер использует футуры на каждом уровне своей реализации, от чтения байтов из сокета до обработки потоков запросов. Помимо того, что это приятный способ написания сервера, это обеспечивает довольно сильный стресс-тест на накладные расходы абстракции футур.
Чтобы получить базовую оценку этих накладных расходов, мы затем реализовали тест TechEmpower с открытым текстом. Этот микробенчмарк тестирует HTTP-сервер «привет, мир», отправляя ему огромное количество одновременных и конвейерных запросов. Поскольку «работа», которую сервер выполняет для обработки запросов, тривиальна, производительность в значительной степени является отражением основных накладных расходов серверной инфраструктуры (и в нашем случае фреймворка Futures).
TechEmpower используется для сравнения очень большого количества веб-фреймворков на разных языках. Мы сравнили minihttp с несколькими главными претендентами:
-
Rapidoid, Java-фреймворк, который показал лучшие результаты в последнем раунде официальных тестов.
-
Go, реализация, использующая поддержку HTTP стандартной библиотеки Go.
-
fasthttp, конкурент стандартной библиотеки Go.
-
node.js.
Вот результаты в количестве «Hello world!», Обслуживаемых в секунду на 8-ядерной машине Linux:
Можно с уверенностью сказать, что футуры не предполагают значительных накладных расходов.
Обновление: чтобы предоставить дополнительные доказательства, мы добавили сравнение minihttp с версией конечного автомата с прямым кодированием в Rust (см. «Raw mio» в ссылке). Эти два значения находятся в пределах 0,3% друг от друга.
Будущее
На этом мы завершаем наше бурное знакомство с футурами с нулевой стоимостью в Rust. Мы увидим более подробную информацию о дизайне в следующих публикациях.
На данный момент библиотека вполне пригодна для использования и довольно тщательно документирована; он поставляется с учебным пособием и множеством примеров, в том числе:
- простой эхо-сервер TCP;
- эффективный прокси-сервер SOCKSv5;
- minihttp, высокоэффективный HTTP-сервер, поддерживающий TLS и использующий парсер Hyper;
- пример использования minihttp для TLS-соединений,
а также различные интеграции, например основанный на футурых интерфейс для curl. Мы активно работаем с несколькими людьми из сообщества Rust, чтобы интегрировать их в их работу; если вам интересно, свяжитесь с Алексом или со мной!
Если вы хотите выполнять низкоуровневое программирование ввода-вывода с помощью futures, вы можете использовать futures-mio для этого поверх mio. Мы думаем, что это захватывающее направление для развития программирования асинхронного ввода-вывода в Rust в целом, и в последующих публикациях будет более подробно рассказано о механике.
В качестве альтернативы, если вы просто хотите говорить по HTTP, вы можете работать поверх minihttp, предоставив службу: функцию, которая принимает HTTP-запрос и возвращает будущий HTTP-ответ. Такая абстракция RPC / сервисов открывает двери для написания большого количества многоразового «промежуточного программного обеспечения» для серверов и получила большую популярность в библиотеке Twitter Finagle для Scala; он также используется в библиотеке Facebook Wangle. В мире Rust уже существует библиотека под названием Tokio, которая создает абстракцию общих служб на основе нашей библиотеки Futures и может выполнять роль, аналогичную Finagle.
Впереди огромный объем работы:
-
Во-первых, мы очень хотим услышать отзывы о будущем ядра и абстракциях потоков, а также есть некоторые конкретные детали дизайна некоторых комбинаторов, в которых мы не уверены.
-
Во-вторых, хотя мы создали ряд будущих абстракций на основе базовых концепций ввода-вывода, определенно есть больше возможностей для изучения, и мы будем признательны за помощь в их изучении.
-
В более широком смысле, существует бесконечное количество «привязок» будущего для различных библиотек (как на C, так и на Rust) для написания; Если у вас есть библиотека, для которой вам нужны привязки Futures, мы будем рады помочь!
-
В более долгосрочной перспективе очевидным конечным шагом будет изучение нотации async / await поверх Futures, возможно, таким же образом, как это предлагается в Javascript. Но прежде чем рассматривать такой шаг, мы хотим получить больше опыта, используя футуры непосредственно в качестве библиотеки.
Какими бы ни были ваши интересы, мы будем рады услышать от вас - мы acrichto и aturon на IRC-каналах Rust. Приходите поздороваться!