Однопоточный JavaScript и многопоточная Java: что быстрее?
Асинхронное выполнение на Java и JavaScript
При необходимости в JavaScript можно запускать дополнительные потоки. Но обычно в Node.js или в браузерах весь код на JavaScript выполняется в одном потоке. В браузерах один и тот же поток рендерит содержимое веб-страницы на экран. По сути, один поток выполнения занимается всеми задачами, потому что приложения JavaScript пользуются преимуществами асинхронного выполнения. Для асинхронного выполнения задача помещается в очередь задач. Задачи из очереди одна за другой выполняются единственным потоком. Например, вторая строка кода выполняет планирование асинхронной задачи, которая запускается после завершения текущей задачи:
console.log("1");
setTimeout(()=>console.log("2"));
console.log("3");
Результатом работы кода будет 1 3 2
.
В Java API под асинхронным выполнением обычно подразумевается, что задача выполняется в новом выделенном потоке. Например, представленный ниже код при помощи метода supplyAsync () планирует асинхронную задачу:
System.out.println("current thread: " + Thread.currentThread().getName());
var future = CompletableFuture.supplyAsync(() -> Thread.currentThread().getName());
System.out.println("current thread: " + Thread.currentThread().getName());
System.out.println("task thread: " + future.get());
Результат работы программы показывает, что текущий поток создал новый поток для выполнения задачи:
current thread: main
current thread: main
task thread: ForkJoinPool.commonPool-worker-1
Проблема множественных потоков заключается в том, что Java runtime не может создавать бесконечное их количество. Когда все запущенные потоки ожидают, а новые потоки создать нельзя, приложение тоже ничего не будет делать. Чуть ниже я проиллюстрирую этот случай, но сначала мне бы хотелось упомянуть менее серьёзный, но более распространённый пример.
Сравнение производительности многопоточных и однопоточных приложений
Теоретически многопоточные приложения должны быть более производительными, чем однопоточные, но на практике это не всегда так. Возьмём в качестве примера основной способ применения Java — серверы приложений Java. В логе видно, что HTTP-запросы обрабатывает множество параллельных потоков с собственными именами. Но если развёрнутое веб-приложение выполняет операции ввода-вывода, то многопоточность по большей мере теряет смысл, поскольку доступ к файловой системе — это узкое «бутылочное горлышко». Десять потоков не могут быть производительнее одного потока, вынужденного ждать содержимого от файловой системы. Например, Java-сервер Tomcat при передаче статичных файлов проявляет себя не лучше, чем один инстанс Node.js.
Когда многопоточная Java работает медленнее, чем однопоточный JavaScript
Давайте попробуем скачать содержимое примерно ста случайных URL. При этом воспользуемся возможностью и сравним производительность древнего HttpURLConnection
и современного HttpClient
.
Представленный ниже код извлекает все абсолютные ссылки с https://www.bbc.com/news/world (около 100 URL), загружает их содержимое, а затем выводит общее время, потраченное на параллельное получение содержимого:
public abstract class Runner {
abstract CompletableFuture> requestManyUrls(List urls) throws Exception;
void run() throws Exception {
var urls = getUrlsFromUrl("https://www.bbc.com/news/world");
var start = System.currentTimeMillis();
var contents = requestManyUrls(urls).get();
var time = System.currentTimeMillis() - start;
var totalLength = contents.stream()
.mapToInt(o -> o.txt().length())
.reduce((a, b) -> a + b).getAsInt();
System.out.println("fetched " + totalLength + " bytes from " + urls.size() + " urls in " + time + " ms");
}
}
Также код выводит общий размер загруженного содержимого, чтобы убедиться, что разные способы загружают один и тот же контент. Самое важное для нас в коде — это измерение времени, необходимого для параллельного выполнения множества HTTP-запросов.
UrlTxt
— это просто запись с двумя полями:
public record UrlTxt(String url,String txt) {}
Метод getUrlsFromUrl()
извлекает абсолютные URL из содержимого https://www.bbc.com/news/world:
public static List getUrlsFromUrl(String url) throws Exception {
return Pattern.compile("href=\"(https:[^\"]+)\"")
.matcher(get(url))
.results()
.map(r -> r.group(1))
.collect(Collectors.toList());
}
Параллельные HTTP-запросы при помощи древнего HttpURLConnection
Для получения содержимого URL используется обычный код:
public static String get(String url) throws Exception {
var con = (HttpURLConnection) new URL(url).openConnection();
con.setInstanceFollowRedirects(false);
if (con.getResponseCode() == HttpURLConnection.HTTP_NOT_FOUND) {
return ""; // 404 throws FileNotFoundException
}
try ( BufferedReader in = new BufferedReader(new InputStreamReader(con.getInputStream()))) {
var response = new StringBuilder();
String line;
while ((line = in.readLine()) != null) {
response.append(line);
}
return response.toString();
}
}
get()
используется в подклассе общего родителя Runner
. Чтобы использовать get()
асинхронным образом, я применяю метод-адаптер load()
. Кстати, обратите внимание на раздражающее ограничение стандартных функциональных интерфейсов — они не выдают исключений и реализующий их код часто необходимо оборачивать в некрасивые блоки try catch
.
public class URLRequests extends Runner {
CompletableFuture load(String url) {
return CompletableFuture.supplyAsync(() -> {
try {
return new UrlTxt(url, get(url));
} catch (Exception e) {
throw new IllegalStateException(e);
}
});
}
@Override
CompletableFuture> requestManyUrls(List urls) throws InterruptedException, ExecutionException {
CompletableFuture[] requests = urls
.stream().map(url -> load(url)).toArray(i -> new CompletableFuture[i]);
return CompletableFuture.allOf(requests)
.thenApply(v -> {
return Stream.of(requests)
.map(future -> future.join())
.collect(Collectors.toList());
});
}
public static void main(String[] args) Exception {
new URLRequests().run();
}
}
Функциональный код в requestManyUrls () адаптирован из самого современного рецепта по созданию параллельных запросов.
Результат работы кода:
fetched 39517285 bytes from 105 urls in 6211 ms
Если повторно запустить тот же код, общий размер будет близким, но не точно таким же. Предполагаю, что содержимое некоторых ссылок динамично.
Параллельные HTTP-запросы при помощи современного HttpClient
Похоже, в настоящее время HttpClient
— это лучший класс Java для создания HTTP-запросов. Кажется, он даже поддерживает HTTP/2, потому что иногда выдаёт ошибку HTTP/2 GOAWAY
.
public class HttpClientRequests extends Runner {
@Override
public CompletableFuture> requestManyUrls(List urls) throws InterruptedException, ExecutionException {
HttpClient client = HttpClient.newHttpClient();
CompletableFuture>[] requests = urls.stream()
.map(url -> URI.create(url))
.map(uri -> HttpRequest.newBuilder(uri))
.map(reqBuilder -> reqBuilder.build())
.map(request -> client.sendAsync(request, BodyHandlers.ofString()))
.toArray(i -> new CompletableFuture[i]);
return CompletableFuture.allOf(requests)
.thenApply(v -> {
return Stream.of(requests)
.map(future -> future.join())
.map(response -> new UrlTxt(response.uri().toString(), response.body()))
.collect(Collectors.toList());
});
}
public static void main(String[] args) throws Exception {
new HttpClientRequests().run();
}
}
Огромный код с современным HttpClient
выглядит пугающе, но по сравнению с предыдущим результатом в 6211 мс его работа радует:
fetched 39983157 bytes from 105 urls in 4910 ms
Параллельные HTTP-запросы на Node.js
В браузере JavaScript не может скачивать содержимое с других хостов, если целевой хост этого не разрешил. Это мера безопасности. Сайт bbc.com не разрешает другим хостам получать его содержимое. Поэтому я использую только Node.js.
Посмотрите, насколько прост полный аналог предыдущего кода на JavaScript:
import fetch from 'node-fetch';
const re = /href=\"(https:[^\"]+)\"/g;
function extractLinks(txt) {
return Array.from(txt.matchAll(re), ar => ar[1]);
}
function load(url) {
return fetch(url,{redirect:"manual"})
.then(res => res.text().then(txt => ({ url, txt })));
}
load("https://www.bbc.com/news/world")
.then(({ txt }) => extractLinks(txt))
.then(urls => {
const start = Date.now();
Promise.all(urls.map(url => load(url)))
.then(contents => {
const time= Date.now() - start ;
const totalLength = contents.reduce((total, { url, txt }) => total + txt.length , 0);
console.log("fetched " + totalLength + " bytes from " + urls.length + " urls in " + time + " ms");
});
});
Что бы вы ни писали на JavaScript, преимущество очевидно — чем меньше клавиш мы нажимаете, тем меньше тратите времени и тем меньше вероятность внести баги. Однако так думают не все. Многие любят преобразовывать JavaScript в Java-подобный код под названием TypeScript.
Результат работы файла на JavaScript:
fetched 39492499 bytes from 105 urls in 1744 ms
Почему разница между Java и JavaScript почти трёхкратная?
Код на JavaScript сначала выполняет один за другим 105 HTTP-запросов. Когда приходит ответ, движок JavaScript помещает в очередь задач небольшой обратный вызов. После получения всех ответов единственный поток по очереди обрабатывает их.
В Java это работает совершенно иначе. Создаётся множество потоков, каждый из которых отправляет один HTTP-запрос. После создания некого оптимального количества потоков стандартный оптимальный внутренний пул потоков больше не может создавать потоки. Несколько созданных потоков ждут ответов. Код ничего не делает. После поступления ответов создаются новые потоки для отправки новых запросов. И этот процесс повторяется, пока не будут отправлены все запросы. По сути, мой пример кода на Java (4910–1744)/4910=64% от общего времени не делает ничего, кроме как ждёт HTTP-откликов. Ситуация такая же, как и с вводом-выводом в серверах приложений Java, но для Интернет-содержимого время ожидания больше.
Если вы знаете, как реализовать более эффективные параллельные HTTP-запросы на Java, то напишите комментарий.
Исходный код можно скачать с https://github.com/marianc000/concurrentHTTPRequests.
АВТОР: Marian Čaikovski
ССЫЛКА НА ОРИГИНАЛ: marian-caikovski.medium.com/single-threaded-javascript-vs-multi-threaded-java-which-is-faster-d3c36a885878