No history yet

Оптимизация supplyAsync

Механика работы supplyAsync

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

// Пример: асинхронное получение данных пользователя
CompletableFuture<User> userFuture = CompletableFuture.supplyAsync(() -> {
    // Логика получения данных, например, из базы данных
    System.out.println("Выполняется в потоке: " + Thread.currentThread().getName());
    return userService.findById(123);
});

Ключевой вопрос: где именно выполняется этот код? По умолчанию supplyAsync использует общий для всего приложения пул потоков — . Это удобно для быстрых, вычислительных задач (CPU-bound). Однако для долгих операций, особенно связанных с вводом-выводом (I/O-bound), таких как запросы к базам данных или внешним API, использование общего пула может стать проблемой. Если все потоки в commonPool будут заблокированы ожиданием ответа от сети, другие асинхронные задачи в вашем приложении просто встанут в очередь, что приведёт к деградации производительности всей системы.

Передача пользовательских Executor

Чтобы избежать «пробок» в общем пуле, supplyAsync имеет перегруженную версию, которая принимает вторым аргументом кастомный Executor. Это позволяет вам точно контролировать, где и как будет выполняться ваша асинхронная задача. Вы можете создать отдельные пулы потоков для разных типов задач: один для быстрых вычислений, другой — для медленных сетевых запросов.

Разделяя задачи по разным пулам, вы изолируете их друг от друга. Проблемы в одном пуле не повлияют на работу другого.

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

// Создаем выделенный пул потоков для I/O операций
ExecutorService databaseExecutor = Executors.newFixedThreadPool(10);

// Передаем наш Executor в supplyAsync
CompletableFuture<Data> dataFuture = CompletableFuture.supplyAsync(() -> {
    // Этот код будет выполнен в одном из потоков databaseExecutor
    System.out.println("Выполняется в потоке: " + Thread.currentThread().getName());
    return repository.fetchData();
}, databaseExecutor);

// Важно не забыть остановить Executor, когда он больше не нужен
// databaseExecutor.shutdown();

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

Лямбды и инкапсуляция

Лямбда-выражения делают код с CompletableFuture кратким и выразительным. Однако их использование в асинхронном контексте требует аккуратности. Лямбды могут захватывать переменные из окружающей их области видимости. Этот механизм называется (closure).

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

❌ Плохо: сложная логика внутри лямбды.

String authToken = "some_token";
int userId = 123;

CompletableFuture<String> resultFuture = CompletableFuture.supplyAsync(() -> {
    // Множество строк кода для подготовки запроса, отправки и парсинга ответа
    HttpClient client = HttpClient.newHttpClient();
    HttpRequest request = HttpRequest.newBuilder()
            .uri(URI.create("https://api.example.com/data/" + userId))
            .header("Authorization", "Bearer " + authToken)
            .build();
    try {
        HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
        return response.body();
    } catch (Exception e) {
        throw new RuntimeException(e);
    }
});

Такой код трудно читать, тестировать и переиспользовать. Гораздо лучше вынести всю логику в отдельный метод.

✅ Хорошо: вызов инкапсулированного метода.

// Вспомогательный класс или сервис
class ApiClient {
    public String fetchDataForUser(int userId, String authToken) {
        // Вся сложная логика находится здесь
        HttpClient client = HttpClient.newHttpClient();
        HttpRequest request = HttpRequest.newBuilder()
                .uri(URI.create("https://api.example.com/data/" + userId))
                .header("Authorization", "Bearer " + authToken)
                .build();
        try {
            HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
            return response.body();
        } catch (Exception e) {
            throw new RuntimeException(e); // В реальном коде лучше использовать кастомное исключение
        }
    }
}

// ... в основном коде
ApiClient apiClient = new ApiClient();
String authToken = "some_token";
int userId = 123;

CompletableFuture<String> resultFuture = CompletableFuture.supplyAsync(
    () -> apiClient.fetchDataForUser(userId, authToken), 
    ioExecutor
);

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

Quiz Questions 1/5

Что по умолчанию использует метод CompletableFuture.supplyAsync() для выполнения асинхронной задачи?

Quiz Questions 2/5

В какой ситуации использование общего пула потоков (commonPool) с supplyAsync может привести к проблемам с производительностью всего приложения?