Содержание


Одновременное исполнение на платформе JVM

Блокировать или не блокировать?

Сравнение блокирующих и неблокирующих подходов к асинхронной обработке событий в языке Java 8

Comments

Серия контента:

Этот контент является частью # из серии # статей: Одновременное исполнение на платформе JVM

Следите за выходом новых статей этой серии.

Этот контент является частью серии:Одновременное исполнение на платформе JVM

Следите за выходом новых статей этой серии.

Асинхронная обработка событий играет ключевую роль в параллельных приложениях. Источниками событий могут быть отдельные вычислительные задачи, операции ввода/вывода или взаимодействия с внешними системами. Независимо от источника событий программный код приложения должен отслеживать события и координировать действия, предпринимаемые в ответ. В случае Java-приложений в распоряжении разработчиков имеется два базовых подхода к асинхронной обработке событий: 1) приложение имеет координирующий поток, который ждет наступления определенного события, а затем исполняет соответствующее действие; 2) событие способно инициировать исполнение определенного действия (которое нередко имеет форму исполнения кода, предоставляемого приложением) непосредственно в момент своего наступления. Подход, при котором поток ждет наступления того или иного события, носит название блокирующего. Подход, при котором поток не осуществляет в явном виде ожидания события, а событие само инициирует исполнение соответствующего действия, носит название неблокирующего.

Старый класс java.util.concurrent.Future дает простой способ обработки ожидаемого завершения события, но только посредством опроса или посредством ожидания завершения. Новый класс java.util.concurrent.CompletableFuture, добавленный в Java 8, расширяет эту возможность посредством набора методов для формирования или обработки событий (в предыдущей статье данного цикла Основы одновременного исполнения в Java 8 изложена вводная информация по CompletableFuture.) В частности, CompletableFuture поддерживает стандартные технические приемы для исполнения кода приложения в момент завершения события, в том числе различные способы комбинирования задач (представленных future-объектами). Это облегчает (как минимум по сравнению с предшествующими приемами) написание неблокирующего кода для обработки событий. В этой статье рассматривается использование CompletableFuture как для блокирующей, так и для неблокирующей обработки событий. Кроме того, в статье излагаются доводы в пользу неблокирующего подхода, оправдывающие приложение определенных дополнительных усилий. Загрузите полный демонстрационный код для данной статьи из репозитария автора на сайте GitHub.

Формирование событий

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

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

Блокировка проста в осуществлении и относительно неплохо защищена от неумелого обращения — до тех пор, пока у вас имеется единственный основной поток, ждущий завершения событий. При работе с несколькими потоками, которые в процессе ожидания блокируют друг друга, можно столкнуться со следующими проблемами.

  • Взаимные блокировки. Каждый из двух (или нескольких) потоков контролирует ресурсы, которые необходимы другому потоку (потокам) для продолжения работы.
  • Отсутствие ресурсов. Некоторые потоки могут оказаться неспособны продолжать свою работу, поскольку другие потоки захватили совместно используемые ресурсы.
  • Динамические взаимоблокировки. Потоки пытаются адаптироваться друг другу, но безуспешно и в конечном итоге не производят полезной работы.

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

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

Задачи и установление последовательности действий

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

  1. Поиск в базе данных информации для пользователя.
  2. Использование найденной информации для вызова веб-сервиса и, возможно, для следующего запроса к базе данных.
  3. Внесение изменений в базу данных на основе результатов предыдущего шага.

На рис. 1 показана структура этого типа.

Рисунок 1. Поток задач приложения
Diagram of four application tasks with ordered execution
Diagram of four application tasks with ordered execution

На рис. 1 вся обработка разбита на четыре отдельных задачи (task), соединенные стрелками, которые представляют собой зависимости от порядка исполнения. Задача task 1 может исполняться независимо, задачи task 2 и task 3 исполняются после завершения задачи task 1, задача task 4 исполняется после завершения задач task 2 и task 3. В этой статье я использую показанную выше структуру задач для иллюстрации асинхронной обработки событий. Реальные приложения — особенно серверные приложения с множеством "движущихся частей" — могут быть гораздо сложнее, однако этот простой пример хорошо иллюстрирует описываемые принципы.

Моделирование асинхронных событий

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

Листинг 1. Код с синхронизацией по событиям
import java.util.Timer;
import java.util.TimerTask;
import java.util.concurrent.CompletableFuture;

public class TimedEventSupport {
    private static final Timer timer = new Timer();
    
    /**
     * Построение future-объекта с целью возвращения значения после задержки.
     * 
     * @param delay
     * @param value
     * @return future
     */
    public static <T> CompletableFuture<T> delayedSuccess(int delay, T value) {
        CompletableFuture<T> future = new CompletableFuture<T>();
        TimerTask task = new TimerTask() {
            public void run() {
                future.complete(value);
            }
        };
        timer.schedule(task, delay * 1000);
        return future;
    }

    /**
     * Построение future-объекта, возвращающего исключение после задержки.
     * 
     * @param delay
     * @param t
     * @return future
     */
    public static <T> CompletableFuture<T> delayedFailure(int delay, Throwable t) {
        CompletableFuture<T> future = new CompletableFuture<T>();
        TimerTask task = new TimerTask() {
            public void run() {
                future.completeExceptionally(t);
            }
        };
        timer.schedule(task, delay * 1000);
        return future;
    }
}

Код в листинге 1 использует таймер java.util.Timer для планирования исполнения задач java.util.TimerTask после задержки. Каждая задача TimerTask выполняет ассоциированный с ней future-объект, когда он запускается. Метод delayedSuccess() планирует задачу для успешного завершения исполняющегося экземпляра CompletableFuture<T> и возвращает future-объект вызывающей стороне. Метод delayedFailure() планирует задачу для завершения с исключением исполняющегося экземпляра CompletableFuture<T> и возвращает future-объект вызывающей стороне.

В листинге 2 демонстрируется использование кода из листинга 1 для создания событий (в форме CompletableFuture<Integer>), соответствующих четырем задачам на рис. 1 (это код из класса EventComposition в демонстрационном коде).

Листинг 2. События для демонстрационных задач
// определения задач
private static CompletableFuture<Integer> task1(int input) {
    return TimedEventSupport.delayedSuccess(1, input + 1);
}
private static CompletableFuture<Integer> task2(int input) {
    return TimedEventSupport.delayedSuccess(2, input + 2);
}
private static CompletableFuture<Integer> task3(int input) {
    return TimedEventSupport.delayedSuccess(3, input + 3);
}
private static CompletableFuture<Integer> task4(int input) {
    return TimedEventSupport.delayedSuccess(1, input + 4);
}

Каждый из четырех методов задач в листинге 2 использует конкретные значения задержки для задания момента завершения своей задачи: 1 секунда для задачи task1, 2 секунды для задачи task2, 3 секунды для задачи task3 и 1 секунда для задачи task4. Кроме того, каждый метод принимает входное значение и использует его, а также номер задачи, в качестве (возможного) значения результата для future-объекта. Все эти методы используют success-форму future-объекта; примеры с использованием failure-формы будут показаны позднее.

Эти задачи должны исполняться в порядке, показанном на рис. 1. В каждую задачу должно передаваться значение результата, возвращенное предыдущей задачей (или сумма результатов двух предыдущих задач в случае задачи task4). Общее время исполнения должно составлять примерно 5 с (1 с + max(2 с, 3 с) + 1 с), если задача task 2 и задача task 3 исполняются одновременно. Если на вход задачи task1, поступает 1, то результат равен 2. Если этот результат передается в задачу task2 и в задачу task3, то результаты равны 4 и 5. Если сумма этих двух результатов (9) передается на вход задачи task4, то окончательный результат равен 13.

Блокирующие ожидания

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

Листинг 3. Блокирующее ожидание для задач
private static CompletableFuture<Integer> runBlocking() {
    Integer i1 = task1(1).join();
    CompletableFuture<Integer> future2 = task2(i1);
    CompletableFuture<Integer> future3 = task3(i1);
    Integer result = task4(future2.join() + future3.join()).join();
    return CompletableFuture.completedFuture(result);
}

Код в листинге 3 использует метод join() класса CompletableFuture для выполнения блокирующего ожидания. Метод join() ждет завершения, а затем возвращает значение результата, если исполнение было успешным, или выдает непроверенное исключение, если исполнение завершилось неудачей или было отменено. Код сначала ждет результата задачи task1, затем запускает задачу task2 и задачу task3, ждет от каждой из них возвращения future-объекта, и, наконец, ждет результата задачи task4. Метод runBlocking() возвращает CompletableFuture ради единообразия с неблокирующей формой, которую я покажу ниже, однако в данном случае future-объект фактически будет завершен перед возвращением результатов метода.

Применение операций compose и combine к future-объектам

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

Листинг 4. Неблокирующее связывание
private static CompletableFuture<Integer> runNonblocking() {
    return task1(1).thenCompose(i1 -> ((CompletableFuture<Integer>)task2(i1)
        .thenCombine(task3(i1), (i2,i3) -> i2+i3)))
        .thenCompose(i4 -> task4(i4));
}

Код в листинге 4 по существу формирует план исполнения, который определяет, как должны исполняться различные задачи и как они связаны друг с другом. Этот код элегантен и лаконичен, однако может оказаться трудным для понимания, если вы незнакомы с методами CompletableFuture. В листинге 5 показан тот же самый код, преобразованный в более понятную форму посредством выделения задачи task2 и задачи task3 части в новый метод runTask2and3.

Листинг 5. Преобразованный код из листинга 4
private static CompletableFuture<Integer> runTask2and3(Integer i1) {
    CompletableFuture<Integer> task2 = task2(i1);
    CompletableFuture<Integer> task3 = task3(i1);
    BiFunction<Integer, Integer, Integer> sum = (a, b) -> a + b;
    return task2.thenCombine(task3, sum);
}

private static CompletableFuture<Integer> runNonblockingAlt() {
    CompletableFuture<Integer> task1 = task1(1);
    CompletableFuture<Integer> comp123 = task1.thenCompose(EventComposition::runTask2and3);
    return comp123.thenCompose(EventComposition::task4);    }

В листинге 5 метод runTask2and3() представляет среднюю часть потока задач, в которой задача task2 и задача task3 исполняются одновременно, а затем их значения их результатов объединяются. Эта последовательность кодируется с помощью метода thenCombine() во future-объекте, который в качестве своего первого параметра принимает другой future-объект, а в качестве своего второго параметра — экземпляр двоичной функции (у которой вводимые типы соответствуют типам результата future-объекта). Метод thenCombine() возвращает третий future-объект, представляющий значение функции, примененной к результатам двух исходных future-объектов. В данном случае этими двумя future-объектами являются задачи task2 и task3, а функция должна суммировать результирующие значения.

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

Вызов метода task1.thenCompose() возвращает future-объект для результата применения функции runTask2and3() к результату задачи task1, который сохраняется как comp123. Вызов метода comp123.thenCompose() возвращает future-объект для результата применения функции task4() к результату первого метода thenCompose(), который является совокупным результатом исполнения всех задач.

Тестирование примера

Демонстрационный код включает в себя метод main() для поочередного исполнения каждой версии кода событий и демонстрации корректности значения времени до завершения (примерно 5 с) и значения результата (13). В листинге 6 показан результат исполнения этого метода main() из консоли.

Листинг 6. Результаты исполнения метода main()
dennis@linux-guk3:~/devworks/scala3/code/bin> java com.sosnoski.concur.article3.EventComposition
Starting runBlocking
runBlocking returned 13 in 5008 ms.
Starting runNonblocking
runNonblocking returned 13 in 5002 ms.
Starting runNonblockingAlt
runNonblockingAlt returned 13 in 5001 ms.

Действия в случае неудачи

Вплоть до настоящего момента вы видели программный код для координации событий в форме future-объектов, которые всегда завершаются успешно. В реальных приложениях нельзя рассчитывать на то, что так будет всегда: у обрабатывающих задач время от времени возникают различные проблемы. В терминологии Java такие проблемы обычно представляются как Throwable.

Изменим определения задачи в листинге 2 так, чтобы вместо метода delayedFailure() использовать метод delayedSuccess(), как показано ниже для задачи task4:

private static CompletableFuture<Integer> task4(int input) {
    return TimedEventSupport.delayedFailure(1, new IllegalArgumentException("This won't work!"));
}

Если в коде, показанном в листинге 3, изменить только задачу task4, чтобы ее исполнение завершалось с исключением, то в результате вызова join() задачи task4 вы получите ожидаемое исключение IllegalArgumentException. Если проблема не перехвачена в методе runBlocking(), то это исключение передается вверх по цепочке вызовов и конечном итоге завершает исполняющийся поток. К счастью, этот код можно с легкостью модифицировать таким образом, чтобы в случае завершения любой задачи с исключением это исключение передавалось вызывающей стороне для обработки посредством возвращенного future-объекта. Соответствующие изменения показаны в листинге 7.

Листинг 7. Блокирующее ожидание с исключениями
private static CompletableFuture<Integer> runBlocking() {
    try {
        Integer i1 = task1(1).join();
        CompletableFuture<Integer> future2 = task2(i1);
        CompletableFuture<Integer> future3 = task3(i1);
        Integer result = task4(future2.join() + future3.join()).join();
        return CompletableFuture.completedFuture(result);
    } catch (CompletionException e) {
        CompletableFuture<Integer> result = new CompletableFuture<Integer>();
        result.completeExceptionally(e.getCause());
        return result;
    }}

Код в листинге 7 не нуждается в особых пояснениях. Первоначальный код заключен в блок try/catch, а оператор catch передает исключение обратно в качестве завершения возвращенного future-объекта. Этот подход несколько повышает уровень сложности, однако он по-прежнему достаточно прост для понимания любым Java-разработчиком.

Неблокирующий код в листинге 4 даже не требует добавления try/catch. Операции compose и combine класса CompletableFuture автоматически осуществляют передачу исключений, чтобы зависимые future-объекты также завершались с исключением.

Блокировать или не блокировать

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

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

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

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

Накладные расходы на переключения

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

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

Совокупные задержки на переключение контекста и на обращение непосредственно к памяти порождают значительные издержки в форме снижения производительности. На рис. 2 показаны издержки на переключение потоков на моей четырехъядерной AMD-системе с установленным на ней пакетом Oracle Java 8 for 64-bit Linux. В этом тесте используется переменное количество потоков — от 1 до 4096 (степени числа 2) — и переменный размер блока памяти, приходящейся на один поток — от 0 КБ до 64 КБ. Потоки исполняются поочередно, посредством использования CompletableFuture для инициирования исполнения. При каждом исполнении потока он сначала осуществляет простое вычисление, используя приходящийся на один поток объем данных для демонстрации издержек загрузки этих данных в кэш, а затем инкрементно увеличивает совместно используемую статическую переменную. В конце своего исполнения поток создает новый экземпляр CompletableFuture, чтобы инициировать его следующее исполнение, а затем запускает следующий поток в последовательности посредством завершения экземпляра CompletableFuture, которого ждет поток. И, наконец, если этот поток должен исполняться снова, он ждет завершения недавно созданного экземпляра CompletableFuture.

Рисунок 2. Издержки на переключение потоков
Chart showing thread switching costs
Chart showing thread switching costs

Диаграмма на рис. 2 наглядно демонстрирует влияние таких параметров, как количество потоков и объем данных в потоке. Количество потоков оказывает максимальное влияние вплоть до значения "четыре потока"; два потока функционируют почти с такой скоростью, как одиночный поток, пока объем данных в потоке остается достаточно небольшим. После увеличения количества потоков свыше четырех влияние этого параметра на производительность остается относительно небольшим. Чем больше объем данных в потоке, тем скорее два уровня кэша будут полностью заполнены, что приведет к изгибам на графике затрат на переключение.

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

На рис. 2 показана стоимость переключения потоков в микросекундах, поэтому даже если издержки на переключение потоков составляют десятки тысяч тактов процессора, абсолютные показатели не будут огромными. При времени переключения 12,5 мс, соответствующем 16 КБ на поток (желтый график) при умеренном количестве потоков, система способна произвести 80000 переключений потоков в секунду. Такое количество переключений потоков значительно превышает те значения, которые вы можете увидеть в каком-либо разумно написанном однопользовательском приложении и даже во многих серверных приложениях. Однако для высокопроизводительных серверных приложений, которые сталкиваются со многими тысячами событий в секунду, издержки блокировки способны стать важным фактором производительности. Для приложений этого типа необходимо свести к минимуму количество переключений потоков посредством применения неблокирующего кода везде, где это возможно.

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

Реактивные приложения

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

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

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

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

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

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

Заключение

В этой статье было показано, как использовать класс CompletableFuture языка Java 8 для объединения и комбинирования событий с целью формирования определенного плана исполнения, который может быть легко и лаконично выражен в программном коде. Эта разновидность неблокирующего подхода имеет большое значение для написания реактивных приложений, которые хорошо реагируют на рабочую нагрузку и корректно обрабатывают сбои.

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


Ресурсы для скачивания


Похожие темы


Комментарии

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

static.content.url=http://www.ibm.com/developerworks/js/artrating/
SITE_ID=40
Zone=Технология Java
ArticleID=1000639
ArticleTitle=Одновременное исполнение на платформе JVM: Блокировать или не блокировать?
publish-date=03162015