Pull to refresh

Что такое Tokio и Async I/O и зачем это нужно?

Reading time10 min
Views21K
Original author: Manish Goregaokar

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


Многим из участников сообщества, тем, которые не работали с веб-серверами и связанными с этим вещами, не ясно, чего же мы хотим добиться. Когда эти вещи обсуждались во времена версии 1.0, я тоже имел смутное представление об этом, никогда прежде не работав с этим раньше.


  • Что это такое — Async I/O?
  • Что такое корутины (coroutines)?
  • Что такое легковесные нити (threads)?
  • Что такое футуры? (futures)?
  • Как они сочетаются между собой?

Рассмотрим модели многопоточности на примере Rust и Go.




Какую проблему мы пытаемся решить?


Одной из ключевых особенностей Rust является "безбоязненная конкурентность" (fearless concurrency). Однако тот вид конкурентности, который нужен для обработки большого количества задач, зависящих от производительности ввода/вывода (I/O), и который имеется в Go, Elixir, Erlang — отсутствует в Rust.


Давайте предположим, что вы хотите собрать что-то наподобие веб-сервера. Он будет обрабатывать тысячи запросов в каждый момент времени (проблема c10k). Говоря общими словами, рассматриваемая нами проблема состоит из многих задач, выполняющих в основном I/O операции (особенно связанных с сетевым взаимодействием).


"Одновременная обработка N задач" — такая задача лучше всего решается использованием нитей. Однако… Тысячи нитей? Наверное, это слишком много. Работа с нитями может быть довольно ресурсозатратной: каждая нить должна выделить большой стек (stack), настроить нить, используя набор системных вызовов. Ко всему прочему переключение контекста тоже затратно.


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


Но в примерах подобных данному веб-серверу большинство этих нитей не будут делать никакой работы. Они будут ждать или поступления запроса, или отправки ответа.


C обычными нитями, когда вы производите блокирующую I/O операцию, системный вызов возвращает управление ядру, которое не возвратит нити управление обратно, потому что, вероятно, I/O операция еще не завершилась. Вместо этого ядро будет использовать данный момент как возможность "подгрузить" (swap in) другую нить и продолжить выполнение исходной нити (начавшей I/O операцию) когда I/O операция будет завершена, то есть когда исходная нить будет "разблокирована" (unblocked). Вот так вы решаете такие задачи в
Rust, когда не используете Tokio и подобные ей библиотеки — запускаете миллион нитей и позволяете ОС самостоятельно планировать (schedule) запуск и завершение нитей в зависимости от I/O.


Но, как мы уже выяснили, использование нитей не очень хорошо масштабируется для задач, подобных данной. (1)


Нам нужны более "легкие" нити.




Модель многопоточности основанная на легковесных нитях (lightweight threads). Думаю, для того чтобы лучше понять данную модель, нужно на некоторое время отвлечься от Rust и посмотреть на язык, который справляется с этим хорошо, Go.


Так, Go имеет легковесные нити, называемые горутинами (goroutines). Вы запускаете их посредством ключевого слова go. Веб-сервер может выполнять код, подобный следующему:


listener, err = net.Listen(...)
// обработать ошибку
for {
    conn, err := listener.Accept()
    // обработать ошибку

    // запустить горутину
    go handler(conn)
}

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


Если это не настоящие (поддерживаемые ОС) нити, то что тогда происходит?


Горутина является примером легковесной нити. ОС ничего о них не знает, она видит N нитей, которые находятся в распоряжении системы выполнения Go (Go runtime, далее СВ Go).


СВ Go отображает на них M горутин (2), подгружая и выгружая горутины подобно планировщику (scheduler) ОС. СВ Go может это делать благодаря тому, что Go-код допускает прерывание (interruptible), позволяя сборщику мусора (GC) делать свою работу. Все это делает возможным для планировщика намеренную остановку работы горутины.


Планировщик осведомлен о системе I/O, поэтому когда горутина ждёт завершения операции I/O, она возвращает право на исполнение планировщику обратно.


В сущности скомпилированная Go-функция будет иметь набор разбросанных по ней мест, где она говорит планировщику и GC: "Возьмите управление себе, если вы хотите" (и также "Я ожидаю то-то и то-то, пожалуйста, возьмите контроль себе).


Когда горутина подгружена в нить ОС, некоторые регистры будут сохранены и указатель на текущую инструкцию (program counter, PC) будет переведён на новую горутину.


Но что происходит со стеком? Нити ОС имеют большой стек при себе, он нужен для того, чтобы функции могли работать.


Go использует сегментированные стеки (segmented stacks). Причиной того, почему нить нуждается в большом стеке, заключается в том, что большинство языков программирования (ЯП), включая C, ожидают, что стек будет непрерывным, и стеки не могут быть перевыделены (reallocated), как мы поступаем с растущими буферами памяти, потому что мы ожидаем, что данные на стеке будут оставаться на том же месте, позволяя указателям на данные на стеке продолжать работать. Так что мы благоразумно резервируем для себе весь стек, который по нашему мнению, нам может понадобиться (примерно 8 МБ). При этом мы ожидаем, что этого нам хватит.


Но ожидание того, что стеки будут непрерывными, строго говоря, не обязательно. В Go стеки состоят из небольших кусочков. Когда функция вызывается, она проверяет, достаточно ли места на стеке для ее исполнения, и если нет, то выделяет новый кусочек стека и запускается на нём. Так что если вы захотите иметь тысячи нитей, выполняющих небольшой объём работы, они все получат тысячи небольших стеков, и всё будет хорошо.


На самом деле сегодня Go делает немножко другое: он копирует стеки. Я упомянул, что стеки не могут быть просто так перевыделены, Ожидается, что данные на стеке будут оставаться на том же месте. Но это не всегда так, потому что Go имеет GC, поэтому в любом случае знает, какие указатели на что указывают, и может при необходимости переписывать указывающие на стек указатели.


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


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


Легковесные-нити были удалены из Rust до выпуска версии 1.0.




Асинхронный ввод/вывод


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


Вместо этого вы используете неблокирующий I/O, где нить ставит в очередь запрос на выполнение I/O в ОС и продолжает выполнение. I/O-запрос будет выполнен ядром через некоторое время. После этого нить должна будет спросить у ОС: "Завершилась ли моя I/O-операция?", перед тем как использовать результат I/O.


Разумеется, частое выяснение у ОС того, завершила ли она I/O, требует ресурсы. Вот почему имеются такие системные вызовы, как epoll. Здесь вы можете собрать вместе набор незавершённых I/O-операций и затем попросить ОС разбудить вашу нить, когда когда какая-либо из этих операций будет завершена. Так что вы можете иметь нить планировщика (настоящую нить), которая выгружает легковесные нити, которые ожидают завершения I/O. Когда же ничего не происходит, нить планировщика может уйти в сон (sleep), вызвав epoll, ожидая до тех пор, пока ОС не разбудит её (когда одна из I/O операций будет завершена).


Задействованный здесь внутренний механизм, вероятно, сложнее.


Вернемся к Rust. Rust имеет библиотеку mio, которая является платформо-независимой обёрткой (wrapper) над неблокирующим I/O и инструментами, как epoll (GNU/Linux), kqueue (FreeBSD), и т. д. Это строительный блок, и хотя тем, которые привыкли использовать epoll в C напрямую, это может показаться удобным, это не предоставляет замечательной
модели как Go. Однако мы можем этого достичь.




Футуры (futures)


Являются ещё одним строительным блоком. Future — обещание того, что рано или поздно будет получено значение (в JavaScript они называются Promise).


Например, вы можете сделать запрос на приход запроса на сетевой сокет и получить Future обратно (на самом деле Stream, который подобен футуре, но используется для получения последовательности значений). Эта Future не будет содержать в себе ответа, но будет знать, когда он придёт. Вы можете вызвать wait() у Future, который будет заблокирован до тех пор, пока не будет получено результирующее значение. Также вы можете вызвать poll() у Future,
спрашивая, не готов ли у Future ответ (она даст вам полученный результат, если он имеется).


Футуры могут быть связаны в цепочку, так что вы можете писать код наподобие future.then(|result| process(result)). Переданное then замыкание (closure) само может произвести еще одну футуру, так что вы можете соединять в цепочку несколько сущностей, например, I/O операции. C футурами на цепочке poll() является способом постепенного исполнения программы; каждый раз, когда вы вызываете её, она будет переходить к следующей футуре, при условии, что текущая футура готова (содержит результат).


Это хорошое объяснение работы неблокирующего I/O.


Связывание футур в цепочку работает подобно связанным в цепочку итераторам. Каждый вызов and_then (или любой другой комбинатор) возвращает структуру, которая содержит в себе внутреннюю футуру, которая дополнительно может содержать в себе замыкание. Сами по себе замыкания несут свои ссылки и данные с собой, так что все это очень похоже на маленькие стеки!




Tokio


Tokio — это в сущности замечательная обёртка над mio, которая использует футуры. Tokio имеет главный цикл обработки событий (event loop) и вы передаете ему замыкания, которые возвращают футуры. Этот цикл будет выполнять все замыкания, которые вы ему передадите, используя mio для выяснения того, какие футуры, могут прогрессировать, и продвигать их далее (вызывая poll()).


На идейном уровне это уже довольно похоже на то, что делает Go. Вы должны вручную настроить главный событийный цикл (планировщик), и как только вы сделаете это, вы сможете передавать циклу задачи, которые периодически делают I/O. Каждый раз когда одна из текущих задач будет заблокирована на I/O-операции, цикл будет подгружать новую задачу. Ключевым отличием является то, что Tokio работает в одной нити, в то время как планировщик Go может использовать несколько нитей ОС для исполнения. Однако вы можете переместить задачи, сильно зависящие от процессора, на другие нити ОС и использовать каналы (channels) для их координирования, что не сложно.


Хотя на идейном уровне это похоже на то, что мы имеем в Go, код выглядит не очень приглядно. Cледующий код на Go:


// обработка ошибок опущена для простоты

func foo(...) ReturnType {
    data := doIo()
    result := compute(data)
    moreData = doMoreIo(result)
    moreResult := moreCompute(data)
    // ...
    return someFinalResult
}

на Rust выглядит так:


// обработка ошибок опущена для простоты

fn foo(...) -> Future<ReturnType, ErrorType> {
    do_io().and_then(|data| do_more_io(compute(data)))
          .and_then(|more_data| do_even_more_io(more_compute(more_data)))
    // ......
}

Не красиво. Код становится хуже, если вы вводите ветвление и циклы. Проблемой является то, что в Go мы получали точки прерывания (interruption points) бесплатно, но в Rust мы должны вручную кодировать это связыванием комбинаторов в цепочку, получая некое подобие машины состояний (автомата).




Генераторы и async/await


Это то место, где появляются генераторы (ещё их называют корутинами).
Генераторы являются экспериментальной возможностью в Rust. Вот пример:


let mut generator = || {
    let i = 0;
    loop {
        yield i;
        i += 1;
    }
};
assert_eq!(generator.resume(), GeneratorState::Yielded(0));
assert_eq!(generator.resume(), GeneratorState::Yielded(1));
assert_eq!(generator.resume(), GeneratorState::Yielded(2));

Функции — это сущности, который выполняют задачу и возвращают результат вызывающему коду один раз. А генераторы могут возвращать (yield) несколько раз: они останавливают выполнение для того, чтобы вернуть некоторые данные, и могут быть продолжены, при это они будут выполняться до следующего yield. Хотя мой пример не показывает этого, генераторы могут завершать выполнение так же, как и обычные функции.


Замыкания в Rust являются синтаксическим сахаром над структурой, которая содержит захваченные (captured) переменные + реализацию одного из Fn-типажей (trait), для того, чтобы сделать структуру вызываемой (callable).


Генераторы похожи на них, ко всему прочему они реализуют типаж Generator и обычно содержат в себе enum, который представляет разные состояния.


"Нестабильная" книга имеет некоторые примеры того, как выглядит данная машина состояний.


Это гораздо ближе к тому, что мы искали! Теперь наш код выглядит так:


fn foo(...) -> Future<ReturnType, ErrorType> {
    let generator = || {
        let mut future = do_io();
        let data;
        loop {
            // опросить футуру, возвращая управление каждый раз
            // когда случается ошибка, но продолжая при успехе
            match future.poll() {
                Ok(Async::Ready(d)) => { data = d; break },
                Ok(Async::NotReady(d)) => (),
                Err(..) => ...
            };
            yield future.polling_info();
        }
        let result = compute(data);
        // делать то же самое для `doMoreIo()`, и т. д.
    }

    futurify(generator)
}

futurify — функция, принимающая генератор и возвращающая футуру, которая при каждом вызове poll будет делать resume() генератора и возвращать NotReady до тех пор, пока генератор не завершит выполнение.


Но постойте, это ещё более уродливо! В чём смысл преобразовывания нашего относительно чистой цепочки из callback'ов в это мессиво?


Если вы посмотрите внимательно, то увидите, что код является последовательным. Мы преобразовали наш callback-код в тот же линейный поток, как и Go-код, однако он содержит странный yield-код в цикле, futurify тоже выглядит не приглядно.


Здесь на помощь приходит futures-await. futures-await — это процедурное макроопределение, которое убирает данный избыточный код. В сущности оно позволяет переписать функцию так:


#[async]
fn foo(...) -> Result<ReturnType, ErrorType> {
    let data = await!(do_io());
    let result = compute(data);
    let more_data = await!(do_more_io());
    // ....

Аккуратно и чисто. Почти так же чисто, как и код на Go, также мы непосредственно вызываем await!(). Данные await-вызовы предоставляют ту же функциональность, что и автоматически установленные точки прерывания в Go.


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




Подводя итоги


Футуры в Rust могут быть соединены в цепочку для предоставления легковесной стеко-подобной системы. С async/await мы можете красиво писать цепочки футур, а await предоставляет явные точки прерывания на каждой I/O-операции. Tokio предоставляет "планировщик" — главный событийный цикл, которому вы можете передавать асинхронные функции, "под капотом" же используется mio для абстрагирования от низкоуровневых неблокирующих I/O-примитивов.


Это компоненты, которые могут быть использованы по отдельности — вы можете использовать Tokio с футурами, без async/await. Вы можете использовать async/await без использования Tokio. Например, это может подходить сетевому стеку Servo. Ему не нужно делать очень много операций параллельного I/O (по крайней мере, не порядка тысячи нитей), так что он может использовать мультиплексированные нити ОС. Однако мы по-прежнему хотим иметь пул нитей и
последовательно (pipeline) обрабатывать данные, а async/await является здесь хорошим подспорьем.


Обобщим: все эти компоненты, несмотря на небольшое количество избыточного (boilerplate) кода, дают нечто почти такое же "чистое", как и горутины в Go. А так как генераторы (а следовательно, и async/await) хорошо сосуществуют c анализатором заимствований (borrow checker, borrowck), то инварианты безопасности, которые поддерживает Rust, по-прежнему в силе, и мы получаем "безбоязненную конкурентность" для програм, которые выполняют большой объем I/O-задач.


Большое спасибо всем из сообщества Rustycrate, кто участвовал в переводе, вычитке и редактировании данной статьи. А именно: vitvakatu, ozkriff.




[1]: Это не обязательно выполняется для всех серверных приложений.
Например, Apache HTTP Server использует нити ОС. Часто нити ОС являются наилучшим
инструментом для решения задачи.


[2]: Про легковесные нити говорят, что они реализуют модель M:N модель — ("green" threading).

Only registered users can participate in poll. Log in, please.
Как вы оцениваете модель многопоточности в Rust?
30.86% 1:1 модель меня устраивает.50
38.27% Нужна встроенная (как в Go) поддержка легковесных нитей (M:N).62
17.28% Не хватает транзакционной памяти.28
43.21% Нужна нативная, а не в виде библиотек, реализация асинхронного I/O.70
6.79% Хочу поддержку OpenMP.11
19.75% Пусть сначала напишут модель памяти и возможности/ограничения unsafe-кода.32
9.26% Добавление опционального GC?15
162 users voted. 94 users abstained.
Only registered users can participate in poll. Log in, please.
Нужно ли включить опциональный GC в Rust?
16.83% Нужно.35
83.17% Нет, не нужно.173
208 users voted. 64 users abstained.
Only registered users can participate in poll. Log in, please.
Как лучше всего перевести thread?
86.8% Поток171
13.2% Нить26
197 users voted. 28 users abstained.
Tags:
Hubs:
Total votes 33: ↑32 and ↓1+31
Comments14

Articles