ExecutorService: как запускать задачи и не устроить DDoS самому себе
В прошлом посте мы разобрали потокобезопасные коллекции.
Но возникает следующий вопрос: кто вообще должен работать с этими коллекциями?
Можно, конечно, сделать так:
for (int i = 0; i < 1000; i++) {
new Thread(() -> processTask()).start();
}
Работать будет. Пока задач немного.
А теперь представим, что таких задач не 1000, а 100 000.
Создавать отдельный Thread на каждую задачу — плохая идея: потоки потребляют память, а большое количество потоков приводит к переключениям контекста и конкуренции за CPU.
Что делает ExecutorService?
Мы отделяем задачу от потока, который её выполнит.
ExecutorService executor =
Executors.newFixedThreadPool(10);
for (int i = 0; i < 1000; i++) {
int taskId = i;
executor.submit(() -> processTask(taskId));
}
Здесь мы создали всего 10 рабочих потоков, а задач отправили 1000.
Остальные задачи будут ждать своей очереди.
Это и есть главное преимущество пула: количество одновременно работающих потоков контролируется.
А что делать с результатом?
Если задача должна что-то вернуть — используем Callable:
Future<String> future = executor.submit(() -> {
return loadUserName(42);
});
String name = future.get();
Future позволяет:
- получить результат;
- дождаться завершения;
- проверить отмену;
- отменить задачу.
Но есть важный момент:
future.get();
блокирует текущий поток, пока задача не завершится.
Поэтому бездумно ставить get() везде тоже не стоит.
А если задача упала?
При использовании submit() исключение не падает прямо в вызывающий поток.
Оно сохраняется внутри Future:
Future<?> future = executor.submit(() -> {
throw new RuntimeException("Что-то пошло не так");
});
try {
future.get();
} catch (ExecutionException e) {
System.err.println("Задача завершилась с ошибкой");
}
Это одна из причин, почему ошибки в фоновых задачах иногда «исчезают» из логов.
И самое важное — shutdown
Пул нужно корректно закрывать:
executor.shutdown();
try {
if (!executor.awaitTermination(30, TimeUnit.SECONDS)) {
executor.shutdownNow();
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
executor.shutdownNow();
}
shutdown() — перестаём принимать новые задачи, но даём уже отправленным завершиться.
shutdownNow() — пытаемся прервать выполняющиеся задачи через interrupt() и возвращаем задачи, которые ещё не начали выполняться.
Но interrupt() — не убийство потока.
Если ваш код игнорирует прерывание, shutdownNow() не гарантирует мгновенную остановку.
Какой размер пула выбрать?
Для CPU-bound задач:
количество потоков ≈ количество CPU-ядер
Для IO-bound задач поток большую часть времени ждёт сеть, БД или диск, поэтому потоков обычно может быть больше. но формула вида cores * 2 — это не закон.
Размер пула зависит от:
- времени CPU;
- времени ожидания;
- нагрузки;
- количества одновременных задач;
- внешних ограничений БД/API.
И вот здесь начинается самое интересное.
Пул потоков — это не просто способ не писать new Thread() - Это механизм управления конкуренцией.
Если поставить слишком маленький пул — задачи будут стоять в очереди.
Если слишком большой — можно положить CPU, БД или внешний API.
Поэтому в production важно контролировать не только количество потоков, но и очередь задач, время выполнения и политику отказа.
А Executors.newFixedThreadPool() — хороший способ познакомиться с ExecutorService, но в production часто нужен более явный контроль через ThreadPoolExecutor.