Почему неблокирующий асинхронный однопоточный быстрее для ввода-вывода, чем блокирующий многопоточный для некоторых приложений

#javascript #java #asynchronous #nonblocking #synchronous

#javascript #java #асинхронный #неблокирующий #синхронный

Вопрос:

Это помогает мне понять вещи, используя сравнение с реальным миром, в данном случае с fastfood.

В java для синхронной блокировки я понимаю, что каждый запрос, обрабатываемый потоком, может быть выполнен только по одному за раз. Например, заказ через drive through, поэтому, если я десятый в очереди, мне приходится ждать 9 машин впереди меня. Но я могу открыть больше потоков, чтобы несколько заказов выполнялись одновременно.

В javascript у вас может быть асинхронный неблокирующий, но однопоточный. Насколько я понимаю, выполняется несколько запросов, и эти запросы немедленно принимаются, но запрос обрабатывается каким-либо фоновым процессом некоторое время спустя, прежде чем вернуться. Я не понимаю, как это было бы быстрее. Если вы заказываете 10 бургеров одновременно, 10 запросов будут отправлены немедленно, но поскольку существует только один повар (single thread), для создания 10 бургеров все равно требуется столько же времени.

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

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

Комментарии:

1. Я бы сказал, перспектива — это не то, что быстро. перспектива неблокирующая и потокобезопасная.

Ответ №1:

Неблокирующий асинхронный однопоточный иногда быстрее

Это маловероятно. Откуда вы это взяли?

В многопоточном синхронном вводе-выводе примерно так это работает:

Операционная система и платформа appserver (например, JVM) работают вместе, создавая 10 потоков. Это структуры данных, представленные в памяти, и планировщик, работающий на уровне ядра / ОС, будет использовать эти структуры данных, чтобы сообщить одному из ядер вашего процессора «перейти» к некоторой точке кода для выполнения команд, которые он там найдет.

Структура данных, представляющая поток, содержит более или менее следующие элементы:

  • Каково местоположение в памяти инструкции, которую мы выполняли
  • Весь ‘стек’. Если какая-то функция вызывает вторую функцию, то нам нужно запомнить все локальные переменные и точку, в которой мы находились в этом исходном методе, чтобы, когда второй метод «возвращает», он знал, как это сделать. например, ваша средняя Java-программа, вероятно, содержит ~ 20 методов, так что это в 20 раз больше локальных переменных, 20 мест в коде для отслеживания. Все это делается в стеках. У каждого потока есть один. Они, как правило, имеют фиксированный размер для всего приложения.
  • Какие страницы кэша были развернуты в локальном кэше ядра, на котором выполняется этот код?

Код в потоке написан следующим образом: Все команды для взаимодействия с «ресурсами» (которые на порядки медленнее, чем ваш процессор; подумайте о сетевых пакетах, доступе к диску и т.д.) Указаны так, чтобы либо возвращать запрошенные данные немедленно (возможно, только если все, что вы просили, уже доступно и в памяти). Если это невозможно, потому что нужных вам данных просто еще нет (скажем, пакет, несущий нужные вам данные, все еще находится на проводе, направляясь на вашу сетевую карту), для кода, который запускает функцию «get me network data», остается только одно: дождаться, пока этот пакет прибудет и попадет в память.

Чтобы вообще ничего не делать, ОС / ЦП будут работать вместе, чтобы взять ту структуру данных, которая представляет поток, заморозить ее, найти другую такую замороженную структуру данных, разморозить ее и перейти к пункту «где мы все оставили» в коде.

Это «переключение потоков»: ядро A запускало поток 1. Теперь ядро A запускает поток 2.

Переключение потоков включает в себя перемещение большого объема памяти: все эти «живые» кэшированные страницы и этот стек должны находиться рядом с этим ядром, чтобы процессор выполнял свою работу, так что загрузка процессора в кучу страниц из основной памяти занимает некоторое время. Не много (наносекунды), но и не ноль. Современные процессоры могут работать только с данными, загруженными в соседнюю страницу кэша (размер которых составляет от 64 КБ до 1 МБ, не более того, в тысячу с лишним раз меньше того, что могут хранить ваши накопители RAM).

В однопоточном асинхронном вводе-выводе примерно так это работает:

Конечно, все еще существует поток (все вещи выполняются в одном), но на этот раз рассматриваемое приложение вообще не является многопоточным. Вместо этого он сам создает структуры данных, необходимые для отслеживания нескольких входящих подключений, и, что особенно важно, примитивы, используемые для запроса данных, работают по-другому. Помните, что в синхронном случае, если код запрашивает следующую группу байтов из сетевого подключения, поток в конечном итоге «зависнет» (сообщая ядру, чтобы оно нашло какую-то другую работу), пока данные не будут там. В асинхронных режимах вместо этого возвращаются данные, если они доступны, но если недоступны, функция ‘give me some data!’ все еще возвращается, но она просто говорит: Sorry bud. У меня есть 0 новых байтов для вас.

Затем само приложение решит перейти к работе с каким-либо другим соединением, и таким образом, один поток может управлять кучей соединений: Есть ли данные для соединения # 1? Да, отлично, я обработаю это. Нет? О, хорошо. Есть ли данные для соединения # 2? и так далее, и тому подобное.

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

Допустим, вы обрабатываете изображение, и половина данных PNG поступает по проводному каналу. Вы мало что можете с этим сделать, поэтому этот поток создаст буфер и сохранит в нем половину PNG. Когда он затем переходит к другому соединению, ему необходимо загрузить ~ 15% изображения, которое он уже получил, и добавить в этот буфер 10% изображения, которое только что прибыло в сетевом пакете.

Это приложение также приводит к тому, что кучу памяти приходится перемещать на страницы кэша и обратно одинаково, так что в этом смысле это не так уж и отличается, и если вы хотите обрабатывать 100 тысяч объектов одновременно, вам неизбежно придется перемещать содержимое на страницы кэша и обратно.

Итак, в чем разница? Можете ли вы выразить это в терминах fry cook?

Не совсем, нет. Это все просто структуры данных.

Ключевое различие заключается в том, что перемещается на эти страницы кэша и из них.

В случае асинхронности это именно то, что написанный вами код хочет буферизировать. Ни больше, ни меньше.

В случае синхронного это «структура данных, представляющая поток».

Возьмем, к примеру, java: это означает, по крайней мере, весь стек для этого потока. Это, в зависимости от -Xss параметра, около 128 кб данных. Итак, если у вас одновременно обрабатывается 100 тысяч подключений, это 12,8 ГБ оперативной памяти только для этих стеков!

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

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

Конечно, чтобы действительно ускорить его, буфер для хранения состояния в асинхронной модели должен быть небольшим (не так много смысла в этом, если вам нужно сохранить 128 Кб в памяти, прежде чем вы сможете работать с ним, вот насколько велики были эти стеки), и вам нужно обрабатывать так много вещей одновременно (10 КБ одновременно).

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

Вот почему синхронный обычно является лучшим вариантом, и на практике часто на самом деле быстрее (эти планировщики потоков ОС написаны опытными программистами и очень хорошо настроены. У вас нет шансов повторить их работу) — все это «вручную прокручивая мои буферы, я могу уменьшить количество байтов, которые нужно переместить на тонну!» должно перевесить потери.

Кроме того, асинхронность сложна как модель программирования.

В асинхронном режиме вы никогда не сможете заблокировать. Хотите сделать быстрый запрос к БД? Это может привести к блокировке, поэтому вы не можете этого сделать, вы должны написать свой код следующим образом: Хорошо, завершите это задание, и вот некоторый код для запуска, когда оно вернется. Вы не можете «ждать ответа», потому что в асинхронной среде ожидание запрещено.

В асинхронном режиме в любое время, когда вы запрашиваете данные, вы должны быть способны справиться с получением половины того, что вы хотели. В синхронизированном режиме, если вы запрашиваете 4k, вы получаете 4k. Тот факт, что ваш поток может зависнуть во время выполнения этой задачи до тех пор, пока не будет доступно 4k, — это не то, о чем вам нужно беспокоиться, вы пишете свой код так, как будто он просто поступает по вашему запросу, завершенный.

Bbbuutt… жаркое готовится!

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

Ответ №2:

Вы мысленно переносите узкое место из своего процесса (исполнителя заказа бургеров) в другой процесс (производителя бургеров).

Это не сделает ваше приложение быстрее.

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

Другими словами, ассоциируйте async не со словом «быстрый«, а со словом «свободный«. Свободен для выполнения другой работы.