Привет, Хабр! У многих из нас, при использовании какого-либо инструмента программирования, возникал вопрос: “Как? Как это работает?”. Часто при возникновении подобных вопросов я обращаюсь к гуглу, который популярным образом рассказывает общие принципы работы того или иного механизма. Но наверняка среди читателей есть те, которые, прочитав несколько статей, подумали: "Да, это интересно и, вроде, понятно. Но, все-таки, как оно работает?". Так вот, я тоже вхожу в число таких читателей. Поработав с каким-то механизмом, становится интересен принцип его работы. Поняв принцип работы, становится интересна сама реализация механизма. Я попробовал посмотреть Project Reactor изнутри и решил написать статью, в которой и систематизировать полученные знания. Если она окажется еще и интересной и найдет свою аудиторию, возможно, я попробую выйти на целый цикл статей под кодовым названием "внутренний мир". В данной статье не будет глубокого описания идей реактивного программирования - подобных статей уже написано чуть меньше, чем много, поэтому для читателей, которым это интересно я оставлю ссылки в конце на ресурсы по теме. Однако, некоторые основы я напишу. Реактивное программирование - это асинхронная обработка потоковых данных. Сама реализация построена на широко известном паттерне "Наблюдатель" (а точнее, его частном случае "Издатель-Подписчик"): есть поток, который выполняет роль издателя, на события которого подписывается множество подписчиков. В Java 9 был добавлен стандарт реактивных стримов (пакет org.reactivestreams), содержащих 4 интерфейса: Publisher - издатель, имеющий точку соединения между издателем и подписчиком.  Subscriber - подписчик, имеющий следующие методы обработки реактивного потока: onSubscribe() выполняется при подписке; onNext() выполняется для каждого элемента потока; onError() выполняется в случае возникновения ошибки; onComplete() выполняется при завершении потока.  Subscription - объект подписки. С его помощью можно запросить следующий объект, или отменить подписку.  Processor - обработчик, преобразовывающий объекты в потоке.  В целом, идея довольно проста:     Subscriber подписывается на события Publisher. В методе Subscriber.onSubscribe() посредством Subscription.request() выполняется запрос на события. События проходят через цепочку Processor'ов и попадают в метод Subscriber.onNext(). По завершению потока событий вызывается метод Subscriber.onComplete(). Стоит отметить одну важную вещь, называемую back-pressure (обратное давление). Можно заметить, что в методе запроса передается некое количество. Это сделано для того, чтобы ограничить передачу данных рамками возможностей обработки.  Для того, что бы получить данные, необходимо вызвать Subscription.request() в методе Subscriber.onSubscribe(). Если необходимо непрерывное получение данных, можно передать Long.MAX_VALUE. Если же мы используем механизм back-pressure, необходимо вызывать Subscription.request() в методе Subscriber.onNext(), что бы получать данные по мере их обработки. В целом, это и есть вся "общая" информация о реактивных стримах. Теперь хотелось бы перейти к самому Project Reactor (далее - реактор). В реакторе появился новый интерфейс Disposable. Разница между ним и Subscription в том, что Subscription используется для управления подпиской внутри подписчика, а Disposable - извне. Попробуем разобраться, как работает сам стрим в реакторе. Мы будем смотреть на примере Flux, но все, описанное ниже актуально и для Mono. Для примера будет проверять числа на простоту. Функция специально сделана максимально примитивно, что бы занимала время.   Теперь перейдем к самому стриму:     На таком маленьком примере мы начнем изучение внутренней работы реактора. Итак, что тут происходит? Условно, работу стрима можно поделить на 3 части: сборка, подписка и выполнение. Наш стрим начинается с фабричного метода Flux.fromArray(). Провалившись в него мы увидим, что класс Flux является абстрактным, а возвращается нам его реализация в виде FluxArray. Для себя я назвал такой flux "порождающим", поскольку он всегда стоит в начале цепочки и транслирует дальше элементы для обработки из передаваемого в него ресурса. Такой "порождающий" flux не единственный: например, Flux.fromIterable(), Flux.fromStream() и Flux.range() возвращают FluxIterable, FluxStream и FluxRange соответственно. Каждый "порождающий" flux возвращает собственную реализацию. Далее мы сразу отсеиваем четные числа, поскольку они нас не интересуют, вызвав метод filter(). Провалившись в него, мы узнаем, что он тоже возвращает собственную реализацию flux - FluxFilter. Получается, существует не только много "порождающих" версий flux, но и "модифицирующих". Так, вызванный нами метод filter() возвращает уже FluxFilter, ресурсом для которого является вышестоящий flux (в нашем случае - FluxArray). Вызванный нами далее map() в этом плане имеет точно такое же поведение, но возвращает уже FluxMap (а ресурсом для него является FluxFilter). Для большей наглядности покажу, как могло бы выглядеть все вышеописанное без реактора: Результирующий flux можно описать таким псевдокодом:  FluxMap(FluxFilter(FluxArray(source)))  На вызове метода subscribe() заканчивается этап сборки и начинается этап подписки.  Прежде чем мы приступим к этапу подписки, хотелось бы написать несколько слов о самом методе subscribe(). Условно реализации метода subscribe() в реакторе можно поделить на 2 части: методы, принимающих лямбды для обработки различных сигналов стрима, и метод, принимающий полноценную реализацию CoreSubscriber.  В первом варианте мы можем передать функции для обработки сигналов стрима в различных вариациях: одну функцию (для обработки элементов), две функции (для обработки элементов и ошибок), три функции (для обработки элементов, ошибок и действие при завершении стрима) и так далее. Впоследствии из них собирается LambdaSubscriber. Во втором варианте мы можем передать полностью свою реализацию CoreSubscriber. Для его реализации рекомендуется использовать абстрактный класс BaseSubscriber, предоставленный реактором. Отмечу, что метод subscribe() возвращает нам объект Disposable, с помощью которого мы можем отменить подписку на стрим "снаружи". Метод subscribe() для "модифицирующих" flux реализован в абстрактном классе InternalFluxOperator, который они наследуют. Внутри него происходит новое "оборачивание", уже подписчиков: для каждого "модифицирующего" flux существует своя версия CoreSubscriber (в нашем случае это FilterSubscriber и MapSubscriber). Условным псевдокодом это можно описать так: Результирующего Subscriber можно описать таким псевдокодом: FilterSubscriber(MapSubscriber(Subscriber))  На этом заканчивается этап подписки и начинается этап выполнения потока. "Порождающие" flux не оборачивают Subscriber - они непосредственно вызывают у него метод onSubscribe() и передают в него свою реализацию Subscription. Каждый "порождающий" flux имеет свою реализацию Subscription: ArraySubscription, IterableSubscription, StreamSubscription, RangeSubscription и т.д. Каждый Subscriber в методе onSubscribe() сохраняет у себя ссылку на Subscription и передает ее вышестоящему подписчику, вплоть до конечной реализации, в которой метод onSubscribe() должен содержать логику запроса данных (а именно - вызов метода Subscription.request()) для того, чтобы стрим стартовал.  Если мы передали функцию Consumer<Subscription> в методе подписки стрима - вызовется она (subscriptionConsumer в коде), иначе LambdaSubscriber по умолчанию сделает запрос на получение данных по мере их поступления.  Subscription и является итоговым источником последовательности - она содержит соответствующий ресурс (массив, стрим и т.д.) и при вызове метода request()начинает передачу данных по потоку. Так же Subscription хранит и ссылку на конечного Subscriber (в нашем случае - FilterSubscriber), в которого и начинает транслировать последовательность, вызывая у него метод onNext(). Именно в реализациях подписчиков и выполняется вся логика потока. Для понятности опишу последовательность выполнения нашего стрима: LambdaSubscriber вызывает ArraySubscription.request(Long.MAX_VALUE).   ArraySubscription вызывает FilterSubscriber.onNext().  FilterSubscriber выполняет логику переданного нами предиката, и затем вызывает MapSubscriber.onNext() для элементов, прошедших проверку.   MapSubscriber выполняет логику переданной нами функции преобразования и вызывает LambdaSubscriber.onNext().  LambdaSubscriber выполняет переданную нами в методе subscribe() логику.   В конце последовательности ArraySubscription вызывает FilterSubscriber.onComplete(), который транслируется по той же цепочке до LambdaSubscriber и выполняется переданная пользователем логика (в нашем случае мы никакую логику не передавали, потому никаких действий не произойдет).   Это примерная логика работы стрима. Внимательный читатель может резонно заметить: а где же асинхронность? И действительно, в вышестоящем примере никакой асинхронности нет, все выполняется в одном потоке. Для того, что бы сделать наш код асинхронным, выполним ряд преобразований. Для начала немного изменим логику нашего стрима. С помощью Flux.create() создадим тестовую последовательность, которую будем преобразовывать в массивы чисел. Далее мы фильтруем массив на четные числа, и начинаем проверять оставшиеся на простоту в операторе flatMap(). FluxFlatMap принимает функцию, преобразующую элемент в Publisher - другой стрим - и встраивает его в основную цепочку преобразований. Сам подписчик FlatMapMain отличается от остальных - его реализация на порядок сложнее.   После выполнения отметим, что все объекты обрабатываются в main потоке. Теперь добавим в цепочку оператор subscribeOn() с аргументом Schedulers.boundedElastic(). Schedulers - аналог ExecutorService в реакторе, а сам Schedulers.boundedElastic() - аналог Executors.newCachedThreadPool(). Оператор subscribeOn() позволяет изменить рабочий поток выполнения стрима, начиная с подписки.  В реакторе представлены 3 типа шедулеров, по аналогии с ExecutorService: Schedulers.boundedElastic(): аналог Executors.newCachedThreadPool(). Динамически создает рабочие потоки и кэширует пулы потоков выполнения. Максимальное число создаваемых пулов потоков выполнения не ограничивается, поэтому этот планировщик можно использовать для организации выполнения задач, связанных с вводом/выводом. Schedulers.parallel(): аналог Executors.newFixedThreadPool(). Имеет фиксированный размер пула рабочих потоков (по умолчанию количество потоков ограничивается числом ядер процессора). Поддерживает планирование задач по времени.   Schedulers.single(): аналог Executors.newSingleThreadExecutor(). Используется для выполнения задач в одном выделенном потоке. Поддерживает планирование задач по времени.   Каждый из них также имеет методы создания с параметрами, а так же имеется возможность создать свой шедулер из Executor или ExecutorService. Будьте осторожны: методы Schedulers.boundedElastic(), Schedulers.parallel() и Schedulers.single() создают шедулер единожды при первом вызове и кэшируют его. Дальнейшие вызовы этих методов вернут кэшированный экземпляр шедулера, что может быть особенно критично при использовании Schedulers.parallel() и Schedulers.single().  Запустим наш стрим и... увидим, что ничего не выполнилось. Программа просто завершилась. Это произошло, потому что выполнение стрима перенеслось в другой поток, а поток main попросту завершился вместе с программой. В реальном приложении такое вряд ли произойдет, но для нашего теста добавим замок для основного потока.   В методе doFinaly() добавим уменьшение счетчика для нашего замка. Отмечу, что если в созданной последовательности не указать s.complete(), данный метод вызван не будет, поскольку сигнала об окончании последовательности не поступит. Запустим, и увидим, что все действия выполнялись в другом потоке. Снова изменим наш стрим, заменив subscribeOn() на publishOn().  Данный оператор позволяет перенести определенный этап выполнения в другой поток.  publishOn() имеет очередь, в которую записываются поступившие элементы, которые впоследствии извлекает и обрабатывает рабочий поток. Как следствие, последовательность разбивается на два независимых этапа обработки данных.    Запустим, и увидим, что первая часть стрима отрабатывает в main потоке, а оканчивается в другом. Мы разделили выполнение стрима на 2 части - формирование данных и их обработка. Однако, что, если мы хотим больше? Например, обработать каждый сгенерированный массив в отдельном потоке?    Прекрасно, теперь каждый массив обрабатывается в отдельном потоке. Но что если мы хотим выделить поток для каждой задачи? Например, проверка происходит не в нашем приложении, а производится rest вызов, и мы хотим для каждого элемента сделать асинхронный запрос?   И вот мы применили целых 5 стратегий выполнения потока: синхронно полностью в основном потоке, синхронно полностью в выбранном потоке, асинхронно в двух потоках (в одном подготовка, в другом обработка), асинхронно по потоку на массив, асинхронно по потоку на элемент, при этом почти не меняя сам код. Холодные потоки генерируют всю последовательность заново для каждого нового подписчика. Примеры таких стримов приведены выше. Горячие потоки генерируют последовательность независимо от наличия подписчиков. При появлении подписчика такой стрим посылает ему только новые данные. Таким образом, подписчик получает только те данные, на которые успел подписаться. Например, если запустить следующий код, первый подписчик начнет обрабатывать элементы, начиная с 3, а второй - с 7.   Еще одной интересной особенностью реактора является контекст стрима. В “классическом” программировании (“поток на задачу”) контекст задачи сделать довольно просто, реализовав его ThreadLocal<?> переменной. В реактивных стримах такой возможности нет, т.к. одну задачу могут выполнять разные потоки в разное время. Соответственно, есть необходимость как то хранить контекст для задачи, не привязываясь к потоку-исполнителю. Рассмотрим контекст на простом примере - наш стрим будет проверять, является ли число в контексте делителем числа из потока. В реакторе есть несколько способов создать контекст потока: передать его в качестве аргумента в методе подписки   передать его в операторе contextWrite()       Контекст будет виден во всех операторах, объявленных выше по цепочке. Если мы объявим контекст в начале стрима, то увидим ошибку   Также мы можем объявить контекст для “внутренней” цепочки. В примере специально  в контекст установлено рандомное число: в примерах выше оно генерируется единожды и является единым для всего стрима. В новом примере для каждого элемента последовательности генерируется свое число для проверки.   И важная особенность: контекст, объявленный во “внутренней” цепочке, не будет виден во “внешней”. В примере ниже выйдет ошибка.  Олег Докука, Игорь Лозинский “Практика реактивного программирования в Spring 5” - прекрасная книга для изучения и начала работы в реактивной парадигме. Единственный минус - часть про реактор актуальна для 2 версии реактора: в примерах книги в некоторых местах используются deprecated методы, а описание механизмов может быть неточным для актуальной версии. Спецификация реактивных стримов   Реактивный манифест   Реактивное программирование со Spring, часть 1 Введение   Реактивное программирование со Spring, часть 2 Project Reactor   Реактивное программирование на Java: как, зачем и стоит ли?