Executor или исполнитель представляет объект, который выполняет полученные задачи. Исполнитель обычно используется вместо явного создания потоков и позволяет отделить отправку задачи от механизма ее выполнения.
Executor представляет функциональный интерфейс с одним методом execute():
void execute(Runnable command)
Предполагается, что данный метод принимает задачу Runnable и выполняет ее в некоторый момент в будущем. Простейший пример:
import java.util.concurrent.*;
class TaskExecutor implements Executor{
public void execute(Runnable task){
task.run();
}
}
class Program{
public static void main(String[] args) {
System.out.println("Main thread started...");
var executor = new TaskExecutor();
executor.execute(() -> {
System.out.println("Executing task in " + Thread.currentThread());
});
System.out.println("Main thread finished...");
}
}
В данном случае определяем класс TaskExecutor, который реализует интерфейс Executor и в котором метод execute() просто запускает переданную задачу на выполнение.
В методе main создаем объект этого класса и вызываем его метод execute(), передавая в него простешую задачу Runnable. Таким образом выполняется переданная задача.
В таком запуске задачи, конечно, нет большого смысла, так как мы просто могли бы запустить ту же задачу Runnable в том же методе main() без реализации интерфейса Executor.
Но тем не менее интерфейс Executor не требует строго асинхронного выполнения и может выполняться синхронно, как в примере выше, где задача выполнялась непосредственно в потоке метода main()
Однако чаще всего задачи выполняются в потоке, отличном от потока вызывающего объекта. Например, изменим код исполнителя, чтобы он создавал новый поток для каждой задачи:
class TaskExecutor implements Executor{
public void execute(Runnable task){
new Thread(task).start();
}
}
При запуске программы мы увидим, что теперь задача выполняется в отдельном потоке:
Main thread started... Main thread finished... Executing task in Thread[#34,Thread-0,5,main]
Нередко реализации Executor используются для координации выполнения задач. И в большинстве случаев нам не потребуется создавать свои собственные реализации интерфейса Executor,
так как в Java есть встроенные реализации этого интерфейса, например, класса ThreadPoolExecutor, который для управления выполнением задач
использует пул потоков, позволяя оптимизировать выполнение программы.
Интерфейс ExecutorService расширяет интерфейс Executor, предоставляя методы управления завершением работы, а также методы создания объекта Future для отслеживания хода выполнения одной или нескольких
асинхронных задач. Этот интерфейс упрощает применение исполнителей Executor для управления асинхронными задачами. Выше упомянутый класс ThreadPoolExecutor как раз реализует данный интерфейс
ExecutorService (а через него и Executor). Рассмотрим, как использовать ExecutorService.
Для создания исполнителей применяется один из методов класса Executors. Подобные методы в основном создают пулы потоков. Пул потоков представляет технику для оптимизации работы с платформенными потоками. Поскольку создание платформенных потоков довольно затратно и требует больших ресурсов,
то вместо создания новых потоков более производительным решением является использование уже готовых потоков. И пул потоков представляет набор свободных потоков, готовых к выполнению задач. Так, если надо выполнить
в потоке какую-нибудь задачу Runnable или Callable, то программа берет из пула один из потоков и использует его для выполнения задачи. После выполнения задачи поток не завершается,
а возвращается обратно в пул для обслуживания следующего запроса.
Некоторые из них:
Executors.newCachedThreadPool()
Создаёт пул потоков, который создаёт новые потоки по мере необходимости, и повторно использует ранее созданные потоки, когда они становятся доступны. Бездействующие потоки сохраняются в течение 60 секунд
Executors.newFixedThreadPool()
Создает пул из фиксированного количества потоков; бездействующие потоки сохраняются неограниченное время. Если число отправленных задач превышает число бездействующих потоков, необработанные задачи помещаются в очередь. Они запускаются после завершения других задач.
Executors.newSingleThreadExecutor()
Создает объект Executor (фактически пул из одного потока) с одним рабочим потоком, который последовательно выполняет отправленные задачи.
Executors.newVirtualThreadPerTaskExecutor()
Создает объект Executor, который запускает каждую задачу в новом виртуальном потоке.
Все эти методы возвращают объекты классов, которые реализуют интерфейс ExecutorService, который в свою очередь расширяет интерфейс Executor.
Для отправки задачи Runnable или Callable в ExecutorService применяется один из следующих методов:
Future<T> submit(Callable<T> task) Future<?> submit(Runnable task) Future<T> submit(Runnable task, T result)
При вызове метода submit() исполнитель - объект Executor планирует выполнение отправленной задачи.
В качестве результата методы возвращают объект Future, который можно использовать для получения результата или отмены задачи.
Вторая версия метода submit возвращает объект Future<?>. Его метод get() блокирует выполнение до завершения задачи, а затем возвращает значение null.
Третья версия метода submit возвращает объект Future, у которого метод get() после завершения задачи возвращает объект, переданный через второй параметр.
После завершения работы исполнителя для освобождения ресурсов вызывается метод close():
void close()
Закрытый исполнитель не принимает новых задач. Метод close() блокирует выполнение до завершения всех отправленных задач.
(Если поток, вызывающий close, прерывается, отправленные задачи отменяются.) Для более удобного неявного вызова метода close() можно использовать конструкцию try-with-resources
(try с ресурсами):
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
...............
Future<V> f = submit(myCallable);
.......................
} // здесь вызывается метод executor.close()
В итоге общий план применения объекта Executor для выполнения задачи:
С помощью одного из статических методов класса Executors (newCachedThreadPool, newFixedThreadPool и т.д) создается объект ExectorService
У объекта ExectorService для отправки задач Callable или Runnable вызывается метод submit()
Метод submit() возвращает объект Future. Этот объект можно сохранить возвращаемые, чтобы иметь возможность запрашивать состояния задач, получать результат задачи или отменять ее.
В конце, если больше не планируется отправлять задачи на выполнение, у объекта ExectorService вызывается метод close(), который завершает работу исполнителя.
Рассмотрим простейший пример:
import java.util.concurrent.*;
class Program{
public static void main(String[] args) {
System.out.println("Main thread started...");
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
// запускаем 5 задач в виртуальных потоках
for (int i = 0; i < 5; i++) {
int taskNumber = i;
executor.submit(() -> {
try{
Thread.sleep(1000);
}
catch(Exception ex) {
System.out.println(ex.getMessage());
}
System.out.println("Executing task " + taskNumber + " in " + Thread.currentThread());
});
}
} // executor.close() вызывается автоматически
System.out.println("Main thread finished...");
}
}
Здесь ExecutorService создает новый виртуальный поток для каждой задачи Runnable:
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
Через метод submit() передаем на выполнение задачу в виде лямбда-выражения:
executor.submit(() -> {
Для имитации работы останавливаем каждую задачу на 1 секунду. В итоге мы получим консольный вывод наподобие следующего:
Main thread started... Executing task 2 in VirtualThread[#39]/runnable@ForkJoinPool-1-worker-2 Executing task 3 in VirtualThread[#42]/runnable@ForkJoinPool-1-worker-5 Executing task 1 in VirtualThread[#37]/runnable@ForkJoinPool-1-worker-1 Executing task 0 in VirtualThread[#35]/runnable@ForkJoinPool-1-worker-4 Executing task 4 in VirtualThread[#46]/runnable@ForkJoinPool-1-worker-3 Main thread finished...