Хочу представить вашему вниманию перевод статьи Concurrency vs Multi-threading vs Asynchronous Programming: Explained.

В последние время, я выступал на мероприятиях и отвечал на вопрос аудитории между моими выступлениями о Асинхронном программировании, я обнаружил что некоторые люди путали многопоточное и асинхронное программирование, а некоторые говорили, что это одно и тоже. Итак, я решил разъяснить эти термины и добавить еще одно понятие Параллелизм. Здесь есть две концепции и обе они совершенно разные, первая синхронное и асинхронное программирование и вторая – однопоточные и многопоточные приложения. Каждая программная модель (синхронная или асинхронная) может работать в однопоточной и многопоточной среде. Давайте обсудим их подробно.

Синхронная программная модель – это программная модель, когда потоку назначается одна задача и начинается выполнение. Когда завершено выполнение задачи тогда появляется возможность заняться другой задачей. В этой модели невозможно останавливать выполнение задачи чтобы в промежутке выполнить другую задачу. Давайте обсудим как эта модель работает в одно и многопоточном сценарии.

Однопоточность – если мы имеем несколько задач, которые надлежит выполнить, и текущая система предоставляет один поток, который может работать со всеми задачами, то он берет поочередно одну за другой и процесс выглядит так:

image

Здесь мы видим, что мы имеем поток (Поток 1) и 4 задачи, которые необходимо выполнить. Поток начинает выполнять поочередно одну за одной и выполняет их все. (Порядок, в котором задачи выполняются не влияет на общее выполнение, у нас может быть другой алгоритм, который может определять приоритеты задач.

Многопоточность – в этом сценарии, мы использовали много потоков, которые могут брать задачи и приступать к работе с ними. У нас есть пулы потоков (новые потоки также создаются, основываясь на потребности и доступности ресурсов) и множество задач. Итак, поток может работать вот так:

image

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

Теперь давайте поговорим о Асинхронной модели и как она ведет себя в одно и многопоточной среде.

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

image

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

Если наша система способно иметь много потоков тогда все потоки могут работать в асинхронной модели как показано ниже:

image

Здесь мы можем видеть, что одна и та же задача скажем Т4, Т5, Т6 … обрабатывается несколькими потоками. Это красота этого сценария. Как мы можем видеть, что задача Т4 начала выполнение первой Потоком 1 и завершен Потоком 2. Подобным образом задча Т6 выполнена Потоком 2, Потоком 3 и Потоком 4. Это демонстрирует максимальное использование потоков.

Итак, до сих пор мы обсудили 4 сценария:

  • Синхронный однопоточный
  • Синхронный многопоточный
  • Асинхронный однопоточный
  • Асинхронный многопоточный

Давайте обсудим еще один термин – параллелизм.

Параллелизм

Проще говоря параллелизм способ обработки множественных запросом одновременно. Так как мы обсуждали два сценария когда обрабатывались множественные запросы, многопоточное программирование и асинхронная модель (одно и многопоточная). В случае асинхронной модели будь она однопоточной или многопоточной, в то время, когда выполняются множество задач, некоторые из них приостанавливаются, а некоторые выполняются. Существует много особенностей, но это выходит за рамки этой публикации.

Как обсуждалось ранее новая эпоха за асинхронным программированием. Почему это так важно?

Преимущества асинхронного программирования

Существует две вещи очень важные для каждого приложения – удобство использования и производительность. Удобство использования потому что пользователь нажав кнопку чтобы сохранить некоторые данные что в свою очередь требует выполнения множества задач таких как чтение и заполнение данных во внутреннем объекте, установление соединения с SQL и сохранения его там. В свою очередь SQL запускается на другой машине в сети и работает под другим процессом, это может потребовать много время. Таким образом если запрос обрабатывается одним процессом экран будет находится в зависшем состоянии до тех пор, пока процесс не завершится. Вот почему сегодня многие приложения и фреймворки полностью полагаются на асинхронную модель.

Производительность приложения и системы также очень важны. Было замечено в то время как выполняется запрос, около 70-80% из них попадают в ожидании зависимых задач. Таким образом, это может быть максимально использовано в асинхронном программирование, где, как только задача передается другому потоку (например, SQL), текущий поток сохраняет состояние и доступен для выполнения другого процесса, а когда задача sql завершается, любой поток, который является свободным, может заняться этой задачей.

Асинхронность в ASP.NET

Асинхронность в ASP.NET может стать большим стимулом для повышения производительности вашего приложения. Вот, как IIS обрабатывает запрос:

image

Когда запрос получен IIS, он берет поток из пула потоков CLR (IIS не имеет какого-либо пула потоков, а сам вместо этого использует пул потоков CLR) и назначает его ему, который далее обрабатывает запрос. Поскольку количество потоков ограничено, и новые могут быть созданы с определенным пределом, тогда если поток будет находится большую часть времени в состоянии ожидания, то это сильно ударит по вашему серверу, вы можете предположить, что это реальность. Но если вы пишете асинхронный код (который теперь становится очень простым и может быть написан почти аналогично синхронному при использовании новых ключевых слов async / await), то он будет работать намного быстрее, и пропускная способность вашего сервера значительно возрастет, потому что вместо ожидания какого-нибудь завершения, он будет доступен пулу потоков, для нового запроса. Если приложение имеет множество зависимостей и длительный процесс выполнения, то для этого приложения асинхронное программирование будет не меньшем благом.

Итак, теперь мы поняли разницу многопоточного, асинхронного программирования и преимущества, которые мы можем получить, используя асинхронную модель программирования.

Комментарии (44)


  1. vitaliy91
    09.09.2017 19:39

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


    1. lair
      09.09.2017 22:07
      +1

      … а будет ли это критично, если у вас не несколько сотен запросов в секунду?


      1. vitaliy91
        09.09.2017 22:51
        +1

        Синхронный код начнет проигрывать когда начнет истощаться пул потоков. Оценив будет ли это происходить в вашей среде исполнения можно выбирать ту или иную модель кода. Кроме того синхронный код немного чище.


        1. babylon
          09.09.2017 23:22
          -2

          Можно сделать рассинхронизацию так, что вы не поймете когда начнёт "наполняться пул потоков". Собственно правильные диспетчеры тасков так и работают перераспределяя нагрузку по ядрам.


          1. lair
            10.09.2017 11:19
            +2

            … а когда все ядра заняты?


        1. dmitry_dvm
          09.09.2017 23:34

          Оценив будет ли это происходить в вашей среде исполнения можно выбирать ту или иную модель кода.

          А потом, в случае роста, радостно переписывать
          Чище за счет отсутствия слов async/await?


          1. vitaliy91
            11.09.2017 15:38

            Это сугубо ваше дело как писать изначально. Чище за счет отсутствия слов async/await, необходимости оборачивать каждый результат в Task<> (ключевое слово var лучше не использую где возвращаемый тип не очевиден). Также пропадает необходимость писать ConfigureAwait(false) для каждой асинхронной задачи. Пропадает необходимость дописывать суффикс Async к названию метода как это требует MSDN


            1. mayorovp
              11.09.2017 22:26

              ConfigureAwait(false) для каждой задачи писать и так не обязательно.


              1. vitaliy91
                12.09.2017 02:36

                Обязательно если вы пишете библиотеку


                1. mayorovp
                  12.09.2017 10:23

                  Если вы про взаимоблокировки — то есть более красивые способы их избежать. Если про что-то другое — то жду аргументов.


                  1. lair
                    12.09.2017 11:41

                    Какие, кстати? Я бы с удовольствием почитал.


                    1. mayorovp
                      12.09.2017 12:18

                      1. Task.Run(...)
                      2. что-то вроде await ContextSwitcher.SwitchToBackground(); (нестандартное решение, но широко распространенное)
                      3. просто не вызывать нигде Task.Wait или Task<>.Result

                      Первые два решения подходят для библиотек. Необходимость третьего можно прописать в документации на библиотеку.


                      1. lair
                        12.09.2017 12:19

                        Спасибо.


                        (третье мне точно не подходит, я людям не верю)


                1. lair
                  12.09.2017 11:41
                  +1

                  Нет, обязательно тогда, когда вы знаете, что кусок после await можно не выполнять в том же синхронизационном контексте. Это не зависит от "библиотека-не библиотека".


                  Да и то, на самом деле, не "обязательно", а желательно.


                  1. vitaliy91
                    12.09.2017 13:02

                    Если вы пишете библиотеку не только для себя то вы не можете знать где она будет работать. Если контекст синхронизации вам не нужен то необходимо его отпустить


                    1. lair
                      12.09.2017 13:08
                      +1

                      Нет никакого "необходимо". Это правило хорошего тона по отношению к людям, которые будут использовать вашу библиотеку неправильно.


                      Особенно смешно это "необходимо" выглядит в ситуации, когда после await вызывается код, который вы не контролируете (например, переданный делегат или вброшенный сервис).


                      1. vitaliy91
                        12.09.2017 13:50

                        Эту рекомендацию я взял из Best Practice и всегда ей придерживаюсь. Если вы вызываете код который вы не контролируете то очевидно контекст синхронизации вам нужен, просто восстановите его перед вызовом делегата. Но до тех пор пока контекст вам не нужен, зачем его дергать после каждого await?

                        Вот пример с делегатом:

                        public async Task DoWorkAsync(Action<byte[]> callback)
                        {
                            byte[] content = await GetFileAsync();
                            Thread.Sleep(3000); // подготовка данных для делегата
                            callback(content);
                        }


                        Вот как я бы это сделал:
                        public async Task DoWorkAsync(Action<byte[]> callback)
                        {
                            var context = SynchronizationContext.Current;
                            if(context != null)
                                await new NoContextYieldAwaitable();
                        
                            byte[] content = await GetFileAsync();
                            Thread.Sleep(3000); // подготовка данных для делегата
                        
                            if (context != null)
                                await context.RestoreAsync();
                        
                            callback(content);
                        }
                        


                        1. mayorovp
                          12.09.2017 13:54

                          Не вижу в вашем коде ConfigureAwait(false). Видимо, писать его и не настолько обязательно :-)


                          1. vitaliy91
                            12.09.2017 14:00

                            Вы зря придираетесь.

                            public async Task DoWorkAsync(Action<byte[]> callback)
                            {
                                var context = SynchronizationContext.Current;
                                byte[] content = await GetFileAsync().ConfigureAwait(false);
                                Thread.Sleep(3000); // подготовка данных для делегата
                            
                                if (context != SynchronizationContext.Current)
                                    await context.RestoreAsync();
                            
                                callback(content);
                            }


                            1. mayorovp
                              12.09.2017 14:05
                              +1

                              Я не придираюсь, а напоминаю вам, что исходно вы говорили об обязательности этой конструкции. Если вы признаете, что той же цели можно достичь альтернативными методами — то о чем вообще спор-то?


                              1. vitaliy91
                                12.09.2017 14:16

                                Последнее я говорил

                                Если контекст синхронизации вам не нужен то необходимо его отпустить

                                Не столь важно как вы это сделаете, через ConfigureAwait или другим способом. Я лишь хочу показать на сколько это важно. К примеру возьму я библиотеку из nuget для WinForm и пока выполняется асинхронная функция она не должна прерывать UI без необходимости. Или еще хуже: может произойти дедлок если UI синхронно ждет завершение этого таска.


                                1. lair
                                  12.09.2017 14:22
                                  +1

                                  Я лишь хочу показать на сколько это важно.

                                  Ну пока в качестве доказательства важности вы только ссылаетесь на Best Practices.


                                  К примеру возьму я библиотеку из nuget для WinForm и пока выполняется асинхронная функция она не должна прерывать UI без необходимости.

                                  Вообще-то, первое, что нужно сделать при вызове асинхронной функции из UI — это вызывать ее на контексте, отличном от UI-ного. Не надо надеяться, что функция внутри сделает это сама.


                              1. vitaliy91
                                12.09.2017 14:28
                                -1

                                при вызове асинхронной функции из UI — это вызывать ее на контексте, отличном от UI-ного.

                                То-есть всегда писать так?
                                await Task.Run(() => httpClient.GetAsync(...));


                                1. mayorovp
                                  12.09.2017 14:30
                                  -1

                                  Где я писал про "всегда"?


                                1. lair
                                  12.09.2017 14:49

                                  Если этот код внутри UI-обработчика, и вы хотите полностью отзывчивый UI, то да. Но зачем у вас обращение к HTTP-клиенту внутри UI-обработчика?


                        1. mayorovp
                          12.09.2017 13:58

                          Кстати, я бы этот код написал чуть проще:


                          public async Task DoWorkAsync(Action<byte[]> callback)
                          {
                              byte[] content = null;
                              await Task.Run(async () => 
                              {
                                  content  = await GetFileAsync();
                                  Thread.Sleep(3000); // подготовка данных для делегата
                              });
                              callback(content);
                          }


                        1. lair
                          12.09.2017 13:59
                          +1

                          Эту рекомендацию я взял из Best Practice и всегда ей придерживаюсь.

                          Это все же рекомендация, а не обязательство.


                          Вот как я бы это сделал

                          … вот вы и усложнили код безо всякой на то необходимости.


        1. lair
          09.09.2017 23:50

          Синхронный код начнет проигрывать когда начнет истощаться пул потоков.

          Вопрос не в том, когда один будет проигрывать другому, а в том, когда потери будут заметны.


          Кроме того синхронный код немного чище.

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


          1. Lofer
            10.09.2017 11:30

            Недавно искал причину, как раз порождающуюю такую проблему.
            Так что

            Вопрос не в том, когда один будет проигрывать другому, а в том, когда потери будут заметны.
            очень корректное высказывание.
            По нашим тестам получилось что «цена» асинхронности может составлять до 30%, или давать выйгрыш в 10% относительно «эталонного» синхронного исполнения. -10% +30% времени.
            Основная проблема — соотношение создания «новых» задач со временем ожидания результата от уже запущенных. В худшем сценарии просто выжирался пул потоков, а после плавно возвращались результаты, но долго :) Наиболее оптимальный сценарий оказался, если скорость создания задач была +- равна скорости получения результатов, тогда количество потоков равномерно.
            Что в принципе и так написано в любой книжке: выравнивайте нагрузку


            1. lair
              10.09.2017 11:33

              По нашим тестам получилось что «цена» асинхронности может составлять до 30%,

              Я не очень понимаю, каким образом асинхронная машина может давать накладные расходы, пропорциональные времени запроса (а не количеству точек асинхронии).


              1. Lofer
                10.09.2017 11:38

                А вот это другой очень и очень интересный вопрос. Есть пару подозрений, как освобожусь — проверю.
                Там уже выходит за границу нашего приложения. а где-то «на границе» происходит что-то любопытное


  1. IL_Agent
    09.09.2017 20:01

    Отличие асинхронности от многопоточности хорошо проилюстрюрованно. А вот абзац про параллелизм написан сумбурно и с ошибками.


  1. gresolio
    09.09.2017 23:08

    Таки конкурентность != параллелизм, очень часто путаются эти термины.
    [1, 2, 3, 4, 5]


  1. AndreyRubankov
    10.09.2017 12:15
    +1

    И снова статья, где автор преувеличивает недостатки синхронной модели и скрывает недостатки асинхронной.

    Во-первых, графики, они построены так, чтобы казалось: «Воу! Асинхронная модель выполняет больше задач!», а по факту, там просто какие-то кубики без привязки ко времени и размерам задачи. Классический маркетинговый прием.

    Во-вторых, возьмем к примеру цитату:

    Здесь мы можем видеть, что одна и та же задача скажем Т4, Т5, Т6 … обрабатывается несколькими потоками. Это красота этого сценария. Как мы можем видеть, что задача Т4 начала выполнение первой Потоком 1 и завершен Потоком 2. Подобным образом задча Т6 выполнена Потоком 2, Потоком 3 и Потоком 4. Это демонстрирует максимальное использование потоков.

    Автор рассказывает, как же это круто, что 4 потока обрабатывают один запрос, но ни слова не говорит про синхронизацию этих 4х потоков, чтобы они могли работать с shared state задачи.
    т.е. на графике, между каждым кубиком нужно добавить еще добавить прослойку из задержек на синхронизацию состояний.
    Так же автор ничего не сказал про то, что состояния должны быть «сохранены» в неком объекте для continuation (продолжения выполнения через время).

    Доля правды в статье есть, асинхронность – это хороший подход, но не на столько хороший, как его расхваливает автор.


    1. AndreyRubankov
      10.09.2017 14:03

      Распишу пример в цифрах:
      Задача занимает 1000 ед. времени.
      Задача может быть разделена на 4 подзадачи по 250 ед. времени.
      Синхронизация «железных потоков» 10 ед. времени.
      Сохранение состояния континуации 1 ед. времени (допустим она сверхлегкая).
      Есть 4 потока и 8 задач.

      вариант1. Каждая подзадача зависит от предыдущей и не может быть выполнена в параллели.
      (подготовить запрос в базу, сходить в базу, обработать результат, отдать наружу)
      вариант2. Каждая подзадача независима и может быть выполнена в параллели.
      (проксировать запрос на 4 других сервиса, и больше ничего не делать)
      вариани3. Каждая 2 подзадачи независимы, 2 – зависимы.
      (подготовить 2 запроса, отправить 2 запроса в 2 разных системы, смержить ответы и отдать наружу)

      «Синхронная» многопоточная модель:
      ход выполнения: 2 задачи + 2 синхронизации
      8 задач = 1000 + 10 + 1000 + 10 = 2020 ед. времени (в каждом потоке).
      1 задача будет выполнена за 1010 ед. времени.

      «Асинхронная» многопоточная модель:
      #1: Каждая подзадача зависит от предыдущей и не может быть выполнена в параллели.
      ход выполнения: 8 подзадач + 8 сохранений состояния + 8 синхронизаций.
      8 задач = (8 * 250) + (8 * 1) + (8 * 10) = 2088 е.д. времени.
      1 задача будет выполнена за: (4 * 250) + (4 * 1) + (4 * 10) = 1044 ед. времени

      #2: Каждая подзадача независима и может быть выполнена в параллели.
      ход выполнения: 8 подзадач + 8 сохранений состояния + 8 синхронизаций.
      8 задач = (8 * 250) + (8 * 1) + (8 * 10) = 2088 е.д. времени.
      1 задача будет выполнена за: (1 * 250) + (1 * 1) + (1 * 10) = 261 ед. времени

      #3: Каждая 2 подзадачи независимы, 2 – зависимы.
      ход выполнения: 8 подзадач + 8 сохранений состояния + 8 синхронизаций.
      8 задач = (8 * 250) + (8 * 1) + (8 * 10) = 2088 е.д. времени.
      1 задача будет выполнена за: (3 * 250) + (3 * 1) + (3 * 10) = 788 ед. времени
      *но тут все на много интереснее: этот вариант не может быть идеально положен на таймлайн потоков, потому 3-4 задачи из 8 будет раскиданы по таймлайну и выполнены в среднем за:
      (5 * 250) + (5 * 1) + (5 * 10) = 1305 ед. времени, на ~25% дольше чем в синхронной модели.

      Подводя итог:
      — Синхронная модель = стабильность и предсказуемость по времени обработки и отдачи результата.
      — Асинхронная модель = менее предсказуема в поведении и необходимо изучать среду в которой будет использована данная модель и требования к системе.

      В идеальном варианте (#2) такой подход может дать в ~3.5 раза больше скорость обработки. Но таких задач на практике крайне мало, если есть вообще.
      В более реальном случае (#1), такой подход может даже стабильно замедлить систему.
      Или сделать ее нестабильной (#3) – когда все зависит от планировщика подзадач и часть задач может отдаваться на 25% быстрее, а часть на 25% медленнее, чем в синхронной модели.

      ps: в примере #3, если нагрузка будет не сильно плотной, то система получит стабильный прирост на ~25%, если же нагрузка на систему возрастет, то система станет менее стабильной и может перейти даже в разряд деградации производительности. Потому, в этом случае, нужно делать замеры и подстраиваться под возрастающую нагрузку заблаговременно.

      pps: данные примеры – это крайне упрощенная теория, а не реальные исследования. В реальном мире все на много сложнее и сильно может отличаться от приведенных цифр!


      1. lair
        10.09.2017 15:46

        Вот поэтому и говорят, что асинхрония в первую очередь выгодна для IO-bound задач, а не вычислительных.


        1. AndreyRubankov
          10.09.2017 17:23

          Да именно, и я прекрасно это понимаю.
          Но к сожалению, очень многие этого не понимают. А подобные статьи лишь усиливают мнение, что Асинхронность – это идеальный подход без недостатков.

          В статье явно прослеживается посыл: «синхронность – плохо, асинхронность – хорошо. точка.»


  1. mrigi
    10.09.2017 12:15
    -1

    Важно помнить, что .net пытается быть умнее чем ему следовало бы. По умолчанию, если я не ошибаюсь, количество одновременных потоков на пуле равно количеству ядер процессора. И вот ваша супер-пупер параллельная программа на дефолтных настройках может вести себя совсем по разному на разных процессорах. Да, это всё решаемо через конфиги, но зачем усложнять? Если физических ядер нехватает, не значит что не нужно создавать новые потоки когда это напрямую говорит разработчик.
    Похожая история с параллельными соединениями того же HttpClient или сервисов. Например вы хотите асинхронно в 10 запросов выкачать что-то с какого-то сайта. Всё будет работать, но сильно медленее чем вам хотелось бы. И вот вы убиваете не один час дебажа свой проект и проклиная криворуких разработчиков того сайта, который медленно качается. А может даже махаете рукой и пускаете в production. Но потом оказывается, что на сетевые запросы тоже пул и вы по умолчанию зачем-то ограничены 2-мя соединениями на сайт. Это тоже решаемо, но кто их просил? Спасибо разработчикам дотнета за столь очевидную функциональность!
    Обычно если с инструментами приходится бороться, то это записывают в минус инструментам.


    1. mayorovp
      10.09.2017 14:54
      +1

      Если физических ядер нехватает, не значит что не нужно создавать новые потоки когда это напрямую говорит разработчик.

      Когда программисту нужен новый поток — он его создает через new Thread. И никто не ограничивает число потоков созданных этим способом.


      Если же разработчик использует другие инструменты — то он не "непрямую говорит", а завязывается на детали реализации. Ну и кто ему злобный буратина при этом?


      1. mrigi
        10.09.2017 17:52
        -2

        «Другие инструменты» продвигаются компанией разработчиком фреймворка как «best practices». Действительно, зачем им следовать. Особенно новым разработчикам.
        Да и с new Thread немного повезло, в те далёкие времена видно вещества не так торкали разработчиков .net. А вот создание «напрямую» new HttpClient абсолютно ничем не поможет. Всё равно будет ограничение на количество параллельных сетевых соединений. «Детали реализации» спрятаны где-то глубоко в виде анального зонда. Кому такой заднеприводный подход доставляет удовольствие, могут быть радостными буратинами. Каждому своё.


        1. lair
          10.09.2017 18:17
          +1

          «Другие инструменты» продвигаются компанией разработчиком фреймворка как «best practices».

          Ну так в тех же best practices и объясняется, как эти инструменты работают.


    1. lair
      10.09.2017 18:20
      +1

      на сетевые запросы тоже пул и вы по умолчанию зачем-то ограничены 2-мя соединениями на сайт.

      Ну как "зачем-то"...


      A single-user client SHOULD NOT maintain more than 2 connections with any server or proxy.

      HTTP/1.1


      1. mrigi
        10.09.2017 18:45
        +1

        RFC2616 obsolete уже далеко не один год. И это условное ограничение, которое и так носило больше рекомендательный характер, сто лет в обед как убрали. От сетевого примитива подобное поведение не ожидается вообще, так что кроме как подводным камнем подобное не назовёшь. Тут просто нечего защищать и оправдывать.


        1. lair
          10.09.2017 18:53
          +1

          Ожидания — вещь хрупкая и неожиданная, люди таких разных вещей ожидают или не ожидают… вот то, что в документации на видном месте этого нет — это правда печаль.


          (В той же документации, кстати, неоднократно сказано "не надо создавать новые HttpClient, неполезно это".)