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

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

Что такое событийное программирование

Подход к программированию на основе событий основан на отсутствии в нашем приложении кода блокировки. Это означает, что поток инициирует операцию ввода-вывода (например, поток в службе A вызывает REST API службы B), а затем переключается на выполнение других действий. Когда операция ввода-вывода завершается, этот поток уведомляется (прерывается), чтобы он вернулся и обработал результат своей операции (десериализацию ответа). С точки зрения вызова потока, весь этап ввода-вывода передается на аутсорсинг кому-то другому, который уведомит его, когда работа будет выполнена. Затем поток начинает выполнение пути кода с того места, где он остановился раньше.

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

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

Передача задачи обработки

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

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

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

Обработка завершения задачи

Epoll будет продолжать опрашивать все сокеты, зарегистрированные в нем, обычно используя один поток. Как только сокет получает данные, epoll вызывает обратный вызов, предоставленный ему приложением, с полученными данными и контекстом выполнения потока (также сохраненным здесь приложением при передаче на epoll). Конечно, epoll не понимает, что это за данные, просто есть некоторые байты, которые приложение может обработать. Этот обратный вызов прерывает поток приложения для обработки вывода (десериализации, выполнения бизнес-логики и т. Д.).

Обратите внимание, что в этом стиле, основанном на событиях, передача задачи и прерывание потока происходит через границу между приложением и ОС. Этот обмен часто называют циклом событий. Node.js прославил петли событий тем, что стал одной из первых сред разработки приложений, использующих NIO для создания однопоточных (!!!) приложений, которые, тем не менее, могли обслуживать огромный объем трафика, пока львиная доля работы приходилась на ввод-вывод. -граница. В наши дни на большинстве языков есть фреймворки, которые это делают (Vert.x - отличный пример).

Элегантность и масштабируемость

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

Время кодирования!

Давайте вернемся к коду для выполнения вызова HTTP API в модели «один поток на запрос».

public class Client {
    public Response get(String url, Request request) {
        // API calling logic
    }
}
public class CallingClass {
    private Client client = new Client();
    public void call() {
        String url = “some-api-url”;
        Request requestData = new RequestData();
        Response response = client.get(url, requestData);
        LOG.info(“Got data {}”, response);
   }
}

Поток, выполняющий код CallingClass, будет блокироваться на client.get (), пока не вернет данные.

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

public class AsyncClient {
    public Future<Response> get(String url, Request request) {
        // API calling logic
    }
}
public class CallingClass {
    private AsyncClient client = new AsyncClient();
    public void call() {
        String url = “some-api-url”;
        Request requestData = new RequestData();
        Future<Response> responseFuture = client.get(url, requestData);
        responseFuture.onComplete() {
          // callback handler for successful future completion
          LOG.info(“Success with data {}”, response);
        }.onFailure() {
          // callback handler for failed future completion
           LOG.error(“API call failed with response {}”, response);
        }
        LOG.info(“Moving on immediately”);
    }
}

Этот код будет регистрировать «Перемещение немедленно» после передачи управления вводом-выводом операционной системе. epoll прервет поток, выполняющий CallingClass, при получении ответа API. Затем приложение анализирует данные, чтобы понять, есть ли у нас успех или неудача, и затем вызывает соответствующий обработчик, привязанный к Future.

Обратите внимание, что этот код намного проще, чем код стиля кражи работы, хотя он страдает той же проблемой обратного вызова.

Ограничения по масштабу

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

Накладные расходы на память

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

Накладные расходы на копирование данных

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

Код блокировки в других частях приложения

Однажды я где-то читал, что «лучший способ что-то сделать в node.js - ничего не делать в node.js». Это утверждение многое говорит о том, что операции, не связанные с вводом-выводом, могут повлиять на масштабируемость приложения. В то время как наш ввод-вывод является неблокирующим, все остальное приложение все еще блокируется, начиная с сериализации и десериализации данных ввода-вывода. В результате общая пропускная способность приложения теперь определяется и ограничивается тем, сколько работы, связанной с процессором, оно должно выполнять.

Слишком много прерываний

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

Максимальное использование ЦП

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

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

Не все может быть неблокирующим

Парадигма программирования на основе событий предполагает наличие API, которые не блокируют поток для выполнения операций ввода-вывода. Обычно это сетевые вызовы удаленных API, запросы к БД, чтение / запись файлов и т. Д. На практике редко удается сделать ВСЕ наш код неблокирующим. Базы данных, особенно СУБД, обычно плохо поддерживают NIO (в первую очередь из-за того, как реализована поддержка транзакций). Контейнеры приложений тоже должны поддерживать асинхронные программы. Если моя служба REST возвращает Future ‹Response›, сервер приложений (например, Tomcat) должен понимать это и иметь соответствующий собственный управляемый событиями код для обработки клиентских запросов. К сожалению, это не нашло широкого распространения.

В результате у нас не всегда может быть система, полностью построенная на событиях. Чтобы обойти это, мы часто видим комбинацию NIO (где позволяют API), пулов потоков (для преобразования кода блокировки в асинхронный режим) и простого старого кода блокировки в большинстве приложений.

Мы действительно что-то изменили?

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

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

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