Производительность: Apache HttpAsyncClient и многопоточный URLConnection

Я пытаюсь выбрать лучший подход для параллельного выполнения большого количества http-запросов. Ниже приведены два подхода, которые у меня есть до сих пор:

  1. Использование Apache HttpAsyncClient и CompletableFutures:

    try (CloseableHttpAsyncClient httpclient = HttpAsyncClients.custom()
    .setMaxConnPerRoute(2000).setMaxConnTotal(2000)
    .setUserAgent("Mozilla/4.0")
    .build()) {
    httpclient.start();
    HttpGet request = new HttpGet("http://bing.com/");
    long start = System.currentTimeMillis();
    CompletableFuture.allOf(
            Stream.generate(()->request).limit(1000).map(req -> {
                CompletableFuture<Void> future = new CompletableFuture<>();
                httpclient.execute(req, new FutureCallback<HttpResponse>() {
                    @Override
                    public void completed(final HttpResponse response) {
                        System.out.println("Completed with: " + response.getStatusLine().getStatusCode())
                        future.complete(null);
                    }
                    ...
                });
                System.out.println("Started request");
                return future;
    }).toArray(CompletableFuture[]::new)).get();
    
  2. Обычный подход «поток на запрос»:

    long start1 = System.currentTimeMillis();
    URL url = new URL("http://bing.com/");
    ExecutorService executor = Executors.newCachedThreadPool();
    
    Stream.generate(()->url).limit(1000).forEach(requestUrl ->{
        executor.submit(()->{
            try {
                URLConnection conn = requestUrl.openConnection();
                System.out.println("Completed with: " + conn.getResponseCode());
            } catch (IOException e) {
                e.printStackTrace();
            }
        });
        System.out.println("Started request");
    });
    

После нескольких прогонов я заметил, что традиционный подход завершался почти в два раза быстрее, чем асинхронный/будущий подход.

Хотя я ожидал, что выделенные потоки будут работать быстрее, должна ли разница быть такой значительной или, возможно, что-то не так с асинхронной реализацией? Если нет, то каков правильный подход здесь?


person Erric    schedule 07.12.2018    source источник
comment
Я отбросил результаты только для того, чтобы сделать код простым, вы могли предположить чтение результатов.   -  person Erric    schedule 07.12.2018
comment
Если это тот же пункт назначения, я бы сказал, что наилучшая производительность заключается в том, чтобы не перегружать вашего партнера большим количеством параллельных подключений. С приличным ограничением в Parallelität синхронные запросы обычно быстрее, чем такое же количество асинхронных потоков. (И да, никто не хочет это слышать)   -  person eckes    schedule 08.12.2018
comment
Это было написано с естественным предположением, что партнер достаточно масштабирован. Скажем, вы пишете службу, которая должна обратиться к зависимой службе, прежде чем вернуть свой собственный ответ, в таких случаях вы хотите, чтобы 1000 потоков обслуживали 1000 запросов, или вы хотели бы, чтобы несколько потоков асинхронно обслуживали несколько запросов?   -  person Erric    schedule 08.12.2018
comment
В первом случае вы делаете запрос и получаете объект HttpResponse. Во втором случае вы только создаете URLConnection, но не выполняете никаких запросов.   -  person Alexei Kaigorodov    schedule 08.12.2018
comment
В своих тестах я читал код состояния ответа перед печатью затраченного времени (используя HttpResponse::getStatusLine().getStatusCode() и HttpURLConnection::getResponseCode() соответственно. Однако я только что отметил, что затраченное время примерно такое же, как и в тот момент, когда вы читаете поток ответов в виде строки. Интересно, почему это так? Оба не должны читать тело ответа, если поток не прочитан   -  person Erric    schedule 08.12.2018
comment
*Обновление: при дальнейшем тестировании оказалось, что многопоточная версия работает быстрее при чтении потока.   -  person Erric    schedule 11.12.2018
comment
Я не уверен, о чем вы спрашиваете: вы сравниваете производительность HttpClient и URL.openConnection или сравниваете производительность CompletableFutures и ThreadPool? Кажется, вы делаете и то, и другое одновременно, что сбивает с толку.   -  person tgdavies    schedule 13.12.2018
comment
Я сравниваю производительность асинхронного ввода-вывода и многопоточного ввода-вывода. Я просто случайно использовал эти два в качестве эталонных реализаций каждого.   -  person Erric    schedule 17.12.2018


Ответы (1)


Существующий вопрос зависит от множества факторов:

  • аппаратное обеспечение
  • операционная система (и ее конфигурация)
  • Реализация JVM
  • Сетевые устройства
  • Поведение сервера

Первый вопрос: должна ли разница быть такой значительной?

Зависит от нагрузки, размера пула и сети, но это может быть намного больше, чем наблюдаемый коэффициент 2 в каждом из направлений (в пользу асинхронного или многопоточного решения). Согласно вашему более позднему комментарию, разница больше из-за неправомерных действий, но ради аргумента я объясню возможные случаи.

Выделенные потоки могут быть довольно обузой. (Обработка прерываний и планирование потоков выполняются операционной системой в случае, если вы используете Oracle [HotSpot] JVM, поскольку эти задачи делегированы.) ОС/система может перестать отвечать на запросы, если потоков слишком много, что замедляет пакетную обработку ( или другие задачи). Существует много административных задач, связанных с управлением потоками, поэтому пулы потоков (и соединений) так важны. Хотя хорошая операционная система должна быть в состоянии обрабатывать несколько тысяч одновременных потоков, всегда есть шанс, что возникнут какие-то ограничения или события (ядра).

Вот где пригодятся объединение и асинхронное поведение. Например, существует пул из 10 физических потоков, выполняющих всю работу. Если что-то заблокировано (в данном случае ожидает ответа сервера), оно переходит в состояние «Заблокировано» (см. изображение), и следующая задача заставляет физический поток выполнить некоторую работу. Когда поток уведомляется (прибывают данные), он становится «выполняемым» (с этого момента механизм пула может его забрать [это может быть решение, реализованное в ОС или JVM]). Для дальнейшего чтения состояний потока я рекомендую W3Rescue. Чтобы лучше понять пулы потоков, я рекомендую эту статью о baeldung.

Переходы потоков

Второй вопрос: что-то не так с асинхронной реализацией? Если нет, то как здесь правильно поступить?

Реализация нормальная, проблем с ней нет. Поведение просто отличается от многопоточного способа. Главный вопрос в этих случаях в основном заключается в том, что такое SLA (соглашения об уровне обслуживания). Если вы являетесь единственным «пользователем услуги», то в основном вам приходится выбирать между задержка или пропускная способность, но решение будет касаться только вас. В большинстве случаев это не так, поэтому я бы порекомендовал какой-либо пул, который поддерживается используемой вами библиотекой.

Третий вопрос. Однако я только что отметил, что время, затрачиваемое на чтение потока ответов в виде строки, примерно такое же. Интересно, почему это?

Сообщение, скорее всего, пришло полностью в обоих случаях (вероятно, ответ не поток, а несколько http-пакетов), но если вы читаете только заголовок, который не требует разбора и загрузки самого ответа в регистры ЦП, тем самым уменьшая задержку чтения фактических полученных данных. Я думаю, что это отличное представление о задержках (источник и источник): Время доступа

Это получился довольно длинный ответ, так что TL.DR.: масштабирование — очень сложная тема, она зависит от многих вещей:

  • аппаратное обеспечение: количество физических ядер, многопоточность, скорость памяти, сетевой интерфейс.
  • операционная система (и ее конфигурация): управление потоками, обработка прерываний
  • Реализация JVM: управление потоками (внутреннее или переданное ОС), не говоря уже о конфигурациях GC и JIT.
  • Сетевые устройства: некоторые ограничивают одновременные подключения с заданного IP-адреса, некоторые объединяют не HTTPS подключения и действуют как прокси.
  • Поведение сервера: объединенные рабочие процессы или рабочие процессы по запросу и т. д.

Скорее всего в вашем случае узким местом был сервер, так как оба метода дали одинаковый результат в исправленном случае (HttpResponse::getStatusLine().getStatusCode() and HttpURLConnection::getResponseCode()). Чтобы дать правильный ответ, вы должны измерить производительность своих серверов с помощью некоторых инструментов, таких как JMeter или LoadRunner и т. д., а затем соответствующим образом масштабируйте свое решение. Эта статья больше посвящена пулу соединений с БД, но логика применима и здесь.

person Hash    schedule 10.12.2018
comment
Если асинхронная реализация верна, есть ли у вас теория, почему или когда асинхронность предпочтительнее другой? Может быть, случай, когда асинхронность будет быстрее, и случай, когда синхронизация будет быстрее? Также я не думаю, что сервер был узким местом, поскольку сервер не будет смещать задержки в зависимости от того, столкнется ли клиент с несколькими потоками или асинхронно. - person Erric; 11.12.2018
comment
Скорее всего в вашем случае узким местом был сервер, так как оба метода давали одинаковый результат в исправленном случае. Чтобы было ясно, когда читался только код состояния, асинхронная версия была в 2 раза медленнее. При дальнейшем тестировании даже чтение ответа в многопоточной версии происходит быстрее, хотя и с разницей менее чем в 2 раза. - person Erric; 11.12.2018
comment
В основном мы используем асинхронный шаблон на тот случай, если вы можете сделать какие-то полезные вычисления, пока ждете ответа. Например скажите пользователю, что сообщение было отправлено и вы ждете ответа, или выполните какие-то другие задачи. Я только что понял, что код на самом деле не асинхронный, поскольку вы вызываете .get(), который синхронно ожидает завершения Future<?>. - person Hash; 13.12.2018
comment
Я делаю get() для совокупного CompletableFuture (это означает, что каждое будущее по отдельности запускается неблокирующим образом), по сути, я ожидаю, что это даст мне P100 за 1000 запросов, которые поступали параллельно. На самом деле я ищу конкретную причину, по которой первый будет медленнее другого. Общие принципы асинхронного программирования и произвольное перечисление общеизвестных факторов производительности здесь не очень помогают. - person Erric; 13.12.2018
comment
Разве .parallel() не пропало в этом случае? - person Hash; 13.12.2018
comment
Извините, я не уловил этого, может быть, пример кода поможет? Если вы имеете в виду Stream().generate().parallel(), это здесь не требуется, так как я создаю потоки в forEach(). Итак, вы в основном просите распараллелить создание потоков - person Erric; 13.12.2018