Руководство по java.util.concurrent.Future

Руководство по java.util.concurrent.Future

1. обзор

В этой статье мы узнаем оFuture. Интерфейс, появившийся со времен Java 1.5, может быть весьма полезен при работе с асинхронными вызовами и параллельной обработкой.

2. СозданиеFutures

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

Давайте посмотрим, как написать методы, которые создают и возвращают экземплярFuture.

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

Вот несколько примеров операций, которые могут использовать асинхронный характерFuture:

  • вычислительные интенсивные процессы (математические и научные расчеты)

  • манипулирование большими структурами данных (большие данные)

  • удаленные вызовы методов (загрузка файлов, очистка HTML, веб-сервисы).

2.1. РеализацияFutures сFutureTask

В нашем примере мы собираемся создать очень простой класс, который вычисляет квадратInteger. Это определенно не подходит для категории «долго работающих» методов, но мы собираемся добавить к нему вызовThread.sleep(), чтобы он длился 1 секунду до завершения:

public class SquareCalculator {

    private ExecutorService executor
      = Executors.newSingleThreadExecutor();

    public Future calculate(Integer input) {
        return executor.submit(() -> {
            Thread.sleep(1000);
            return input * input;
        });
    }
}

Бит кода, который фактически выполняет вычисления, содержится в методеcall(), представленном как лямбда-выражение. Как видите, в этом нет ничего особенного, кроме упомянутого ранее вызоваsleep().

Это становится более интересным, когда мы обращаем внимание на использованиеCallable иExecutorService.

Callable - это интерфейс, представляющий задачу, которая возвращает результат и имеет единственный методcall(). Здесь мы создали его экземпляр, используя лямбда-выражение.

Создание экземпляраCallable никуда нас не приведет, мы все равно должны передать этот экземпляр исполнителю, который позаботится о запуске этой задачи в новом потоке и вернет нам ценный объектFuture. Вот тут-то и пригодитсяExecutorService.

Есть несколько способов получить экземплярExecutorService, большинство из них предоставляется статическими фабричными методами служебного классаExecutors. В этом примере мы использовали базовыйnewSingleThreadExecutor(), который дает намExecutorService, способный обрабатывать один поток за раз.

Когда у нас есть объектExecutorService, нам просто нужно вызватьsubmit(), передав нашCallable в качестве аргумента. submit() позаботится о запуске задачи и вернет объектFutureTask, который является реализацией интерфейсаFuture.

3. ПотреблениеFutures

До этого момента мы узнали, как создать экземплярFuture.

В этом разделе мы узнаем, как работать с этим экземпляром, изучив все методы, которые являются частью APIFuture.

3.1. ИспользованиеisDone() иget() для получения результатов

Теперь нам нужно вызватьcalculate() и использовать возвращенныйFuture, чтобы получить результирующийInteger. В этом нам помогут два метода из APIFuture.

Future.isDone() сообщает нам, завершил ли исполнитель обработку задачи. Если задача завершена, она вернетtrue, в противном случае она вернетfalse.

Метод, который возвращает фактический результат вычисления, -Future.get(). Обратите внимание, что этот метод блокирует выполнение до тех пор, пока задача не будет завершена, но в нашем примере это не будет проблемой, поскольку сначала мы проверим, завершена ли задача, вызвавisDone().

Используя эти два метода, мы можем запустить другой код, ожидая завершения основной задачи:

Future future = new SquareCalculator().calculate(10);

while(!future.isDone()) {
    System.out.println("Calculating...");
    Thread.sleep(300);
}

Integer result = future.get();

В этом примере мы выводим простое сообщение на вывод, чтобы пользователь знал, что программа выполняет вычисления.

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

Стоит отметить, чтоget() имеет перегруженную версию, которая принимает тайм-аут иTimeUnit в качестве аргументов:

Integer result = future.get(500, TimeUnit.MILLISECONDS);

Разница междуget(long, TimeUnit) иget() заключается в том, что первый вызоветTimeoutException, если задача не вернется до указанного периода ожидания.

3.2. ОтменаFuture с помощьюcancel()

Предположим, мы запустили задачу, но по какой-то причине нас больше не волнует результат. Мы можем использоватьFuture.cancel(boolean), чтобы сказать исполнителю остановить операцию и прервать ее базовый поток:

Future future = new SquareCalculator().calculate(4);

boolean canceled = future.cancel(true);

Наш экземплярFuture из приведенного выше кода никогда не завершит свою работу. Фактически, если мы попытаемся вызватьget() из этого экземпляра, после вызоваcancel(), результатом будетCancellationException. Future.isCancelled() сообщит нам, был ли уже отмененFuture. Это может быть весьма полезно, чтобы избежать полученияCancellationException.

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

4. Больше многопоточности с пуламиThread

Наш текущийExecutorService является однопоточным, поскольку он был получен с помощьюExecutors.newSingleThreadExecutor. Чтобы выделить эту «однопоточность», давайте одновременно запустим два вычисления:

SquareCalculator squareCalculator = new SquareCalculator();

Future future1 = squareCalculator.calculate(10);
Future future2 = squareCalculator.calculate(100);

while (!(future1.isDone() && future2.isDone())) {
    System.out.println(
      String.format(
        "future1 is %s and future2 is %s",
        future1.isDone() ? "done" : "not done",
        future2.isDone() ? "done" : "not done"
      )
    );
    Thread.sleep(300);
}

Integer result1 = future1.get();
Integer result2 = future2.get();

System.out.println(result1 + " and " + result2);

squareCalculator.shutdown();

Теперь давайте проанализируем вывод этого кода:

calculating square for: 10
future1 is not done and future2 is not done
future1 is not done and future2 is not done
future1 is not done and future2 is not done
future1 is not done and future2 is not done
calculating square for: 100
future1 is done and future2 is not done
future1 is done and future2 is not done
future1 is done and future2 is not done
100 and 10000

Понятно, что процесс не параллельный. Обратите внимание, как вторая задача запускается только после завершения первой задачи, поэтому весь процесс занимает около 2 секунд.

Чтобы сделать нашу программу действительно многопоточной, мы должны использовать другой вариантExecutorService. Давайте посмотрим, как изменится поведение нашего примера, если мы будем использовать пул потоков, предоставляемый фабричным методомExecutors.newFixedThreadPool():

public class SquareCalculator {

    private ExecutorService executor = Executors.newFixedThreadPool(2);

    //...
}

После простого изменения в нашем классеSquareCalculator теперь у нас есть исполнитель, который может использовать 2 одновременных потока.

Если мы снова запустим тот же самый клиентский код, мы получим следующий результат:

calculating square for: 10
calculating square for: 100
future1 is not done and future2 is not done
future1 is not done and future2 is not done
future1 is not done and future2 is not done
future1 is not done and future2 is not done
100 and 10000

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

Существуют и другие фабричные методы, которые можно использовать для создания пулов потоков, напримерExecutors.newCachedThreadPool(), который повторно использует ранее использованныеThreads, когда они доступны, иExecutors.newScheduledThreadPool(), который планирует выполнение команд после заданной задержки.с

Для получения дополнительной информации оExecutorService прочтите нашarticle, посвященный этой теме.

5. ОбзорForkJoinTask

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

В этом разделе мы быстро рассмотрим основные характеристикиForkJoinPool. Чтобы получить исчерпывающее руководство по теме, посетите нашGuide to the Fork/Join Framework in Java.

Тогда основной характеристикой aForkJoinTask является то, что он обычно порождает новые подзадачи как часть работы, необходимой для выполнения своей основной задачи. Он генерирует новые задачи, вызываяfork(), и собирает все результаты с помощьюjoin(),, таким образом, имя класса.

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

Давайте расширим наш предыдущий пример, чтобы создать класс, который, учитываяInteger, будет вычислять квадраты суммы для всех его факториальных элементов. Так, например, если мы передадим число 4 нашему калькулятору, мы должны получить результат из суммы 4² + 3² + 2² + 1², которая равна 30.

Прежде всего, нам нужно создать конкретную реализациюRecursiveTask и реализовать ее методcompute(). Здесь мы напишем нашу бизнес-логику:

public class FactorialSquareCalculator extends RecursiveTask {

    private Integer n;

    public FactorialSquareCalculator(Integer n) {
        this.n = n;
    }

    @Override
    protected Integer compute() {
        if (n <= 1) {
            return n;
        }

        FactorialSquareCalculator calculator
          = new FactorialSquareCalculator(n - 1);

        calculator.fork();

        return n * n + calculator.join();
    }
}

Обратите внимание, как мы достигаем рекурсивности, создавая новый экземплярFactorialSquareCalculator вcompute(). Вызываяfork(), неблокирующий метод, мы просимForkJoinPool инициировать выполнение этой подзадачи.

Методjoin() вернет результат этого вычисления, к которому мы добавим квадрат числа, которое мы сейчас посещаем.

Теперь нам просто нужно создатьForkJoinPool для обработки выполнения и управления потоками:

ForkJoinPool forkJoinPool = new ForkJoinPool();

FactorialSquareCalculator calculator = new FactorialSquareCalculator(10);

forkJoinPool.execute(calculator);

6. Заключение

В этой статье мы подробно рассмотрели интерфейсFuture, изучив все его методы. Мы также узнали, как использовать возможности пулов потоков для запуска нескольких параллельных операций. Также были кратко рассмотрены основные методы из классаForkJoinTask,fork() иjoin().

У нас есть много других замечательных статей о параллельных и асинхронных операциях в Java. Вот три из них, которые тесно связаны с интерфейсомFuture (некоторые из них уже упоминались в статье):

Проверьте исходный код, использованный в этой статье, в нашемGitHub repository.