JavaRush /Курси /JAVA 25 SELF /ExecutorService, Callable, Future: запуск завдань

ExecutorService, Callable, Future: запуск завдань

JAVA 25 SELF
Рівень 54 , Лекція 1
Відкрита

1. ExecutorService: керуємо потоками як дорослі

Чому не варто просто створювати потоки через new Thread

На перших порах багатопоточності все виглядає просто:

Thread t = new Thread(() -> {
    // щось робимо
});
t.start();

Такий спосіб працює, але швидко стає обтяжливим, коли завдань стає багато. Кожен виклик new Thread() створює новий потік, а десятки чи сотні потоків починають перевантажувати систему. До того ж керувати ними незручно: потрібно стежити, коли вони закінчуються, що робити у разі помилок, як зупиняти та перевикористовувати їх.

Саме тут на сцену виходить ExecutorService — розумний диспетчер потоків. Ви просто передаєте йому завдання, а він сам вирішує, яким потоком і коли їх виконати. У результаті все працює швидше, стабільніше й без головного болю.

Як влаштований ExecutorService

ExecutorService працює за простим, але ефективним принципом.

  • Усередині нього є пул потоків — заздалегідь створений набір робочих потоків (фіксований або динамічний).
  • Завдання потрапляють у чергу і підхоплюються вільними потоками.
  • Сервіс керує життєвим циклом: ви можете дочекатися завершення, коректно зупинити пул і звільнити ресурси.

Створення ExecutorService

Найпоширеніший спосіб — використовувати фабричні методи з класу Executors:

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

ExecutorService executor = Executors.newFixedThreadPool(4); // 4 потоки
  • newFixedThreadPool(N) — пул із N потоків (підходить для більшості завдань).
  • newCachedThreadPool() — динамічний пул, створює потоки за потреби (обережно: можна «упертися» в пам’ять під лавиною завдань).
  • newSingleThreadExecutor() — один потік (послідовне виконання).

Приклад: запуск Runnable через ExecutorService

executor.submit(() -> {
    System.out.println("Привіт з пулу потоків!");
});

Після того як ви закінчили роботу з ExecutorService, його потрібно коректно завершити:

executor.shutdown(); // Забороняє додавати нові завдання, чекає завершення поточних

Важливо: якщо не викликати shutdown(), програма може не завершитися — потоки з пулу чекатимуть на нові завдання.

2. Runnable vs Callable: завдання бувають різні

До Java 5 якщо ви хотіли щось виконати в потоці, ви писали реалізацію інтерфейсу Runnable. Це завдання, яке нічого не повертає і не кидає перевірюваних винятків.

Runnable task = () -> {
    System.out.println("Просто працюю, нічого не повертаю!");
};
executor.submit(task);

Callable: завдання з результатом (і винятками)

Іноді хочеться, щоб завдання не просто «щось робило», а повертало результат — наприклад, суму чисел, результат обчислень, дані з сервера. Для цього вигадали інтерфейс Callable<T>.

import java.util.concurrent.Callable;

Callable<Integer> sumTask = () -> {
    int sum = 0;
    for (int i = 1; i <= 100; i++) sum += i;
    return sum;
};
  • Метод call() повертає результат типу T.
  • Метод call() може кидати перевірюваний виняток.

Аналогія: Runnable — «іди й помий посуд» (результат неважливий), Callable — «іди й принеси чай і скажи, якої він температури» (результат важливий).

Запуск Callable: щоб отримати результат, використовуйте executor.submit(...). Він поверне об’єкт Future<T>.

3. Future: обіцянка результату

Future — це «обіцянка» повернути результат у майбутньому. Коли ви надсилаєте завдання в ExecutorService, ви отримуєте Future, з якого згодом зможете отримати результат, дізнатися, чи завершилося завдання, або скасувати його.

Основні методи Future

  • T get() — отримати результат (чекає, поки завдання завершиться).
  • boolean isDone() — чи завершене завдання.
  • boolean cancel(boolean mayInterruptIfRunning) — спробувати скасувати завдання.
  • boolean isCancelled() — чи було завдання скасоване.

Приклад: запуск Callable і отримання результату

import java.util.concurrent.*;

public class ParallelSumApp {
    public static void main(String[] args) throws Exception {
        ExecutorService executor = Executors.newFixedThreadPool(2);

        Callable<Integer> sumTask = () -> {
            int sum = 0;
            for (int i = 1; i <= 100; i++) sum += i;
            return sum;
        };

        Future<Integer> future = executor.submit(sumTask);

        System.out.println("Завдання запущено, можна робити щось іще...");

        // Отримуємо результат (метод блокує потік, якщо завдання ще не завершене)
        Integer result = future.get();
        System.out.println("Результат обчислень: " + result);

        executor.shutdown();
    }
}
  • Завдання відправляється в пул потоків.
  • Поки завдання виконується, основний потік може робити щось іще.
  • Коли результат потрібен, викликаємо future.get() — потік почекає, якщо завдання ще в роботі.
  • Щойно завдання завершиться, результат повернеться.

4. Практика: кілька завдань, очікування завершення

Часто потрібно запустити одразу кілька завдань і дочекатися, коли вони всі завершаться. Наприклад, ви обробляєте масив даних, розбиваєте його на частини та рахуєте суму кожної частини в окремому завданні.

Приклад: сума елементів масиву частинами

import java.util.*;
import java.util.concurrent.*;

public class ParallelArraySum {
    public static void main(String[] args) throws Exception {
        int[] array = new int[1000];
        Arrays.setAll(array, i -> i + 1); // Заповнюємо числами від 1 до 1000

        ExecutorService executor = Executors.newFixedThreadPool(4);

        int chunkSize = array.length / 4;
        List<Future<Integer>> futures = new ArrayList<>();

        for (int i = 0; i < 4; i++) {
            int from = i * chunkSize;
            int to = (i == 3) ? array.length : (i + 1) * chunkSize;

            Callable<Integer> sumTask = () -> {
                int sum = 0;
                for (int j = from; j < to; j++) sum += array[j];
                System.out.println("Сума від " + from + " до " + (to - 1) + " = " + sum);
                return sum;
            };

            futures.add(executor.submit(sumTask));
        }

        int totalSum = 0;
        for (Future<Integer> f : futures) {
            totalSum += f.get(); // Чекаємо кожне завдання по черзі
        }

        System.out.println("Загальна сума: " + totalSum);

        executor.shutdown();
    }
}

Тут масив розбивається на 4 частини. Для кожної частини створюється завдання (Callable), яке рахує суму. Усі завдання надсилаються в ExecutorService, повертаються об’єкти Future. Наприкінці збираємо результати всіх завдань і підсумовуємо.

У реальних завданнях зручно використовувати invokeAll, щоб дочекатися виконання всіх завдань одразу.

5. Обробка помилок під час роботи з Future

Коли ви викликаєте future.get(), якщо завдання завершилося з винятком, його буде викинуто у вигляді ExecutionException. Це важливо: якщо в завданні щось пішло не так, ви дізнаєтеся про це лише під час виклику get().

Приклад: обробка винятків

Callable<Integer> errorTask = () -> {
    throw new IllegalArgumentException("Щось пішло не так!");
};

Future<Integer> badFuture = executor.submit(errorTask);

try {
    badFuture.get();
} catch (ExecutionException e) {
    System.out.println("Завдання завершилося з помилкою: " + e.getCause());
}
  • Усередині завдання викидається виняток.
  • Під час виклику get() він «загортається» в ExecutionException.
  • Справжню причину можна отримати через getCause().

6. Корисні нюанси

Як скасувати завдання

Future<?> f = executor.submit(() -> {
    while (true) {
        // Нескінченна робота
        if (Thread.currentThread().isInterrupted()) {
            System.out.println("Мене попросили завершитися!");
            break;
        }
    }
});

Thread.sleep(100); // Почекаємо трохи
f.cancel(true); // Спробуємо скасувати завдання
  • cancel(true) намагається перервати завдання, якщо воно ще не завершене.
  • Усередині завдання бажано перевіряти Thread.currentThread().isInterrupted() і коректно завершуватися.

shutdown vs shutdownNow

shutdown() — м’яка зупинка: забороняє додавати нові завдання і дає поточним спокійно завершитися. Використовується найчастіше.

shutdownNow() — жорстка зупинка: намагається перервати активні потоки і повертає список завдань, які не встигли стартувати. Застосовуйте обережно.

invokeAll і invokeAny

invokeAll(Collection<Callable<T>> tasks) запускає всі передані завдання і чекає, поки вони всі завершаться. Повертає список Future.

invokeAny(Collection<Callable<T>> tasks) чекає тільки перше успішно виконане завдання, повертає його результат і скасовує решту. Зручно, коли важлива перша успішна відповідь.

7. Типові помилки при роботі з ExecutorService, Callable і Future

Помилка № 1: не закривати ExecutorService. Якщо забути викликати shutdown(), програма може «висіти» після завершення main, тому що потоки пулу чекають на нові завдання.

Помилка № 2: очікування результату одразу після надсилання завдання. Якщо одразу після submit() викликати get(), ви не отримаєте переваг асинхронності — потік усе одно чекатиме. Виконуйте корисну роботу паралельно і запитуйте результат, коли він справді потрібен.

Помилка № 3: ігнорування винятків у завданнях. Якщо не обробляти ExecutionException під час виклику get(), можна пропустити важливі помилки, які сталися в завданні.

Помилка № 4: використання спільних змінюваних змінних без синхронізації. Якщо кілька завдань працюють з одними й тими самими даними — потрібна синхронізація або потокобезпечні колекції.

Помилка № 5: створення надто великої кількості потоків. Не варто робити пул із числом потоків, що значно перевищує кількість ядер процесора — це може навіть уповільнити виконання.

Помилка № 6: забувати про скасування завдань. Якщо завдання більше не потрібне, скасовуйте його через cancel(), щоб не витрачати ресурси.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ