Производительность ввода-вывода на стороне сервера: Node, PHP, Java, Go
Опубликовано: 2022-03-11Понимание модели ввода-вывода (I/O) вашего приложения может означать разницу между приложением, которое справляется с нагрузкой, которой оно подвергается, и приложением, которое сминается перед лицом реальных вариантов использования. Возможно, пока ваше приложение маленькое и не обслуживает большие нагрузки, это может иметь гораздо меньшее значение. Но по мере увеличения нагрузки трафика вашего приложения работа с неправильной моделью ввода-вывода может причинить вам боль.
И, как и в большинстве других ситуаций, когда возможны несколько подходов, вопрос не только в том, какой из них лучше, но и в понимании компромиссов. Давайте прогуляемся по ландшафту ввода-вывода и посмотрим, что мы можем шпионить.
В этой статье мы сравним Node, Java, Go и PHP с Apache, обсудим, как различные языки моделируют свой ввод-вывод, преимущества и недостатки каждой модели, а в заключение приведем некоторые элементарные тесты. Если вас беспокоит производительность ввода-вывода вашего следующего веб-приложения, эта статья для вас.
Основы ввода/вывода: краткое ознакомление
Чтобы понять факторы, связанные с вводом-выводом, мы должны сначала рассмотреть концепции на уровне операционной системы. Хотя маловероятно, что вам придется иметь дело со многими из этих концепций напрямую, вы все время имеете дело с ними косвенно через среду выполнения вашего приложения. И детали имеют значение.
Системные вызовы
Во-первых, у нас есть системные вызовы, которые можно описать так:
- Ваша программа (в «пользовательской зоне», как они говорят) должна просить ядро операционной системы выполнить операцию ввода-вывода от ее имени.
- «Системный вызов» — это средство, с помощью которого ваша программа просит ядро что-то сделать. Специфика того, как это реализовано, зависит от ОС, но основная концепция одна и та же. Там будет какая-то конкретная инструкция, которая передает управление из вашей программы в ядро (например, вызов функции, но с каким-то особым соусом специально для этой ситуации). Вообще говоря, системные вызовы блокируются, а это означает, что ваша программа ожидает, пока ядро вернется к вашему коду.
- Ядро выполняет основную операцию ввода-вывода на рассматриваемом физическом устройстве (диске, сетевой карте и т. д.) и отвечает на системный вызов. В реальном мире ядру, возможно, придется сделать несколько вещей, чтобы выполнить ваш запрос, включая ожидание готовности устройства, обновление его внутреннего состояния и т. д., но как разработчик приложения это вас не волнует. Это работа ядра.
Блокирующие и неблокирующие вызовы
Я только что сказал выше, что системные вызовы блокируются, и это верно в общем смысле. Однако некоторые вызовы классифицируются как «неблокирующие», что означает, что ядро принимает ваш запрос, помещает его в очередь или буфер где-то, а затем немедленно возвращает, не дожидаясь фактического выполнения ввода-вывода. Таким образом, он «блокируется» только на очень короткий период времени, достаточно долго, чтобы поставить ваш запрос в очередь.
Некоторые примеры (системных вызовов Linux) могут помочь прояснить: - read()
является блокирующим вызовом - вы передаете ему дескриптор, указывающий, какой файл и буфер, куда доставить прочитанные данные, и вызов возвращается, когда данные есть. Обратите внимание, что это имеет то преимущество, что оно красивое и простое. - epoll_create()
, epoll_ctl()
и epoll_wait()
— это вызовы, которые, соответственно, позволяют вам создать группу дескрипторов для прослушивания, добавить/удалить обработчики из этой группы, а затем заблокировать, пока не будет какой-либо активности. Это позволяет эффективно управлять большим количеством операций ввода-вывода с помощью одного потока, но я забегаю вперед. Это здорово, если вам нужна функциональность, но, как вы можете видеть, ее, безусловно, сложнее использовать.
Здесь важно понимать порядок величины разницы во времени. Если ядро ЦП работает на частоте 3 ГГц, не вдаваясь в оптимизации, которые может выполнять ЦП, оно выполняет 3 миллиарда циклов в секунду (или 3 цикла в наносекунду). Для завершения неблокирующего системного вызова может потребоваться порядка 10 секунд циклов — или «относительно несколько наносекунд». Вызов, который блокирует получение информации по сети, может занять гораздо больше времени, скажем, 200 миллисекунд (1/5 секунды). И скажем, например, неблокирующий вызов занял 20 наносекунд, а блокирующий вызов занял 200 000 000 наносекунд. Ваш процесс только что ждал блокирующего вызова в 10 миллионов раз дольше.
Ядро предоставляет средства для выполнения как блокирующего ввода-вывода («прочитать из этого сетевого подключения и передать мне данные»), так и неблокирующего ввода-вывода («сообщить мне, когда какое-либо из этих сетевых подключений получит новые данные»). И используемый механизм будет блокировать вызывающий процесс на совершенно разные периоды времени.
Планирование
Третье, за чем очень важно следить, это то, что происходит, когда у вас много потоков или процессов, которые начинают блокироваться.
Для наших целей нет большой разницы между потоком и процессом. В реальной жизни наиболее заметное различие, связанное с производительностью, заключается в том, что, поскольку потоки совместно используют одну и ту же память, а каждый процесс имеет собственное пространство памяти, создание отдельных процессов, как правило, занимает гораздо больше памяти. Но когда мы говорим о планировании, то на самом деле оно сводится к списку вещей (потоков и процессов), каждый из которых должен получить часть времени выполнения на доступных ядрах ЦП. Если у вас есть 300 запущенных потоков и 8 ядер для их запуска, вы должны разделить время так, чтобы каждый получил свою долю, при этом каждое ядро работало в течение короткого периода времени, а затем переходило к следующему потоку. Это делается с помощью «переключения контекста», заставляющего ЦП переключаться с одного потока/процесса на другой.
С этими переключениями контекста связана стоимость — они занимают некоторое время. В некоторых быстрых случаях это может быть меньше 100 наносекунд, но нередко это занимает 1000 наносекунд или больше в зависимости от деталей реализации, скорости процессора/архитектуры, кэш-памяти ЦП и т. д.
И чем больше потоков (или процессов), тем больше переключение контекста. Когда мы говорим о тысячах потоков и сотнях наносекунд для каждого, все может стать очень медленным.
Однако неблокирующие вызовы, по сути, сообщают ядру: «Позвоните мне только тогда, когда у вас появятся какие-то новые данные или событие по одному из этих соединений». Эти неблокирующие вызовы предназначены для эффективной обработки больших нагрузок ввода-вывода и уменьшения переключения контекста.
Со мной до сих пор? Потому что теперь самое интересное: давайте посмотрим, что некоторые популярные языки делают с этими инструментами, и сделаем некоторые выводы о компромиссах между простотой использования и производительностью… и другие интересные факты.
В качестве примечания, хотя примеры, показанные в этой статье, тривиальны (и частичны, показаны только соответствующие биты); доступ к базе данных, внешние системы кэширования (memcache и т. д.) и все, что требует ввода-вывода, в конечном итоге будет выполнять какой-то вызов ввода-вывода под капотом, который будет иметь тот же эффект, что и показанные простые примеры. Кроме того, для сценариев, где ввод-вывод описывается как «блокирующий» (PHP, Java), чтение и запись HTTP-запросов и ответов сами по себе являются блокирующими вызовами: Опять же, в системе скрыто больше операций ввода-вывода с сопутствующими проблемами производительности. принять во внимание.
Есть много факторов, которые влияют на выбор языка программирования для проекта. Есть даже много факторов, когда вы рассматриваете только производительность. Но если вы обеспокоены тем, что ваша программа будет ограничена главным образом вводом-выводом, если производительность ввода-вывода имеет решающее значение для вашего проекта, вам необходимо знать об этом.
Подход «Не усложняйте»: PHP
Еще в 90-х многие люди носили обувь Converse и писали CGI-сценарии на Perl. Затем появился PHP, и, как бы некоторым ни хотелось его ругать, он значительно упростил создание динамических веб-страниц.
Модель, которую использует PHP, довольно проста. Есть несколько вариаций, но ваш средний PHP-сервер выглядит так:
HTTP-запрос поступает из браузера пользователя и попадает на ваш веб-сервер Apache. Apache создает отдельный процесс для каждого запроса с некоторыми оптимизациями для их повторного использования, чтобы свести к минимуму количество операций (создание процессов, относительно говоря, медленное). Apache вызывает PHP и говорит ему запустить соответствующий файл .php
на диске. PHP-код выполняется и блокирует вызовы ввода-вывода. Вы вызываете file_get_contents()
в PHP, а под капотом он выполняет системные вызовы read()
и ожидает результатов.
И, конечно же, реальный код просто встраивается прямо в вашу страницу, а операции блокируются:
<?php // blocking file I/O $file_data = file_get_contents('/path/to/file.dat'); // blocking network I/O $curl = curl_init('http://example.com/example-microservice'); $result = curl_exec($curl); // some more blocking network I/O $result = $db->query('SELECT id, data FROM examples ORDER BY id DESC limit 100'); ?>
С точки зрения того, как это интегрируется с системой, это выглядит так:
Довольно просто: один процесс на запрос. Вызовы ввода-вывода просто блокируются. Преимущество? Это просто и это работает. Недостаток? Ударьте по нему 20 000 клиентов одновременно, и ваш сервер взорвется. Этот подход плохо масштабируется, потому что инструменты, предоставляемые ядром для работы с большими объемами ввода-вывода (epoll и т. д.), не используются. И вдобавок ко всему, запуск отдельного процесса для каждого запроса имеет тенденцию использовать много системных ресурсов, особенно памяти, которая часто является первой вещью, которая у вас заканчивается в подобном сценарии.
Примечание. Подход, используемый для Ruby, очень похож на подход для PHP, и в широком, общем смысле их можно считать одинаковыми для наших целей.
Многопоточный подход: Java
Итак, появляется Java, как раз в то время, когда вы купили свое первое доменное имя, и было круто просто случайно сказать «точка com» после предложения. А в язык Java встроена многопоточность, что (особенно на момент его создания) довольно круто.
Большинство веб-серверов Java работают, запуская новый поток выполнения для каждого входящего запроса, а затем в этом потоке в конечном итоге вызывая функцию, которую вы, как разработчик приложения, написали.
Выполнение ввода-вывода в Java-сервлете выглядит примерно так:
public void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { // blocking file I/O InputStream fileIs = new FileInputStream("/path/to/file"); // blocking network I/O URLConnection urlConnection = (new URL("http://example.com/example-microservice")).openConnection(); InputStream netIs = urlConnection.getInputStream(); // some more blocking network I/O out.println("..."); }
Так как наш вышеприведенный метод doGet
соответствует одному запросу и выполняется в собственном потоке, вместо отдельного процесса для каждого запроса, требующего своей памяти, у нас есть отдельный поток. У этого есть некоторые приятные преимущества, такие как возможность совместного использования состояния, кэшированных данных и т. д. между потоками, потому что они могут получить доступ к памяти друг друга, но влияние на то, как это взаимодействует с расписанием, по-прежнему почти идентично тому, что делается в PHP. пример ранее. Каждый запрос получает новый поток, и различные операции ввода-вывода блокируются внутри этого потока до тех пор, пока запрос не будет полностью обработан. Потоки объединяются, чтобы свести к минимуму стоимость их создания и уничтожения, но, тем не менее, тысячи соединений означают тысячи потоков, что плохо для планировщика.
Важной вехой является то, что в версии 1.4 Java (и снова значительное обновление в версии 1.7) получила возможность выполнять неблокирующие вызовы ввода-вывода. Большинство приложений, веб и других, не используют его, но, по крайней мере, он доступен. Некоторые веб-серверы Java пытаются использовать это различными способами; однако подавляющее большинство развернутых Java-приложений по-прежнему работают так, как описано выше.
Java сближает нас и, безусловно, имеет хорошие готовые функциональные возможности для ввода-вывода, но на самом деле он все еще не решает проблему того, что происходит, когда у вас есть приложение, сильно связанное с вводом-выводом, которое загоняется в земля со многими тысячами блокирующих нитей.
Неблокирующий ввод-вывод как гражданин первого класса: узел
Популярный ребенок в блоке, когда речь заходит об улучшении ввода-вывода, — это Node.js. Любой, кто хоть немного знаком с Node, знает, что он «не блокирует» и эффективно обрабатывает ввод-вывод. И это верно в общем смысле. Но дьявол кроется в деталях, и средства, которыми было достигнуто это колдовство, имеют значение, когда дело доходит до исполнения.
По сути, сдвиг парадигмы, который реализует Node, заключается в том, что вместо того, чтобы говорить «напишите здесь свой код для обработки запроса», они вместо этого говорят «напишите здесь код, чтобы начать обработку запроса». Каждый раз, когда вам нужно сделать что-то, связанное с вводом-выводом, вы делаете запрос и даете функцию обратного вызова, которую Node вызовет, когда это будет сделано.

Типичный код Node для выполнения операции ввода-вывода в запросе выглядит следующим образом:
http.createServer(function(request, response) { fs.readFile('/path/to/file', 'utf8', function(err, data) { response.end(data); }); });
Как видите, здесь есть две функции обратного вызова. Первый вызывается при запуске запроса, а второй вызывается, когда данные файла доступны.
Это в основном дает Node возможность эффективно обрабатывать ввод-вывод между этими обратными вызовами. Сценарий, в котором это было бы еще более уместно, - это когда вы выполняете вызов базы данных в Node, но я не буду приводить пример, потому что это тот же самый принцип: вы запускаете вызов базы данных и даете Node функцию обратного вызова, она выполняет операции ввода-вывода отдельно, используя неблокирующие вызовы, а затем вызывает вашу функцию обратного вызова, когда запрашиваемые данные доступны. Этот механизм постановки вызовов ввода-вывода в очередь и предоставления возможности Node обрабатывать их, а затем получения обратного вызова называется «Цикл событий». И это работает очень хорошо.
Однако у этой модели есть одна загвоздка. Под капотом причина этого гораздо больше связана с тем, как реализован движок JavaScript V8 (JS-движок Chrome, который используется Node) 1 , чем что-либо еще. Код JS, который вы пишете, выполняется в одном потоке. Подумайте об этом на мгновение. Это означает, что в то время как ввод-вывод выполняется с использованием эффективных неблокирующих методов, ваш JS может выполнять операции, связанные с процессором, в одном потоке, при этом каждый фрагмент кода блокирует следующий. Распространенным примером того, где это может произойти, является цикл по записям базы данных, чтобы каким-то образом обработать их, прежде чем выводить их клиенту. Вот пример, который показывает, как это работает:
var handler = function(request, response) { connection.query('SELECT ...', function (err, rows) { if (err) { throw err }; for (var i = 0; i < rows.length; i++) { // do processing on each row } response.end(...); // write out the results }) };
Хотя Node эффективно обрабатывает ввод-вывод, цикл for
в приведенном выше примере использует циклы ЦП внутри вашего единственного основного потока. Это означает, что если у вас есть 10 000 подключений, этот цикл может привести к сканированию всего вашего приложения, в зависимости от того, сколько времени это займет. Каждый запрос должен разделять часть времени, по одному, в вашем основном потоке.
Предпосылка, на которой основана вся эта концепция, заключается в том, что операции ввода-вывода являются самой медленной частью, поэтому наиболее важно эффективно обрабатывать их, даже если это означает последовательное выполнение другой обработки. Это верно в некоторых случаях, но не во всех.
Другой момент заключается в том, что, хотя это только мнение, может быть довольно утомительно писать кучу вложенных обратных вызовов, и некоторые утверждают, что это значительно усложняет код. Нередко можно увидеть обратные вызовы, вложенные на четыре, пять или даже больше уровней глубоко внутри кода Node.
Мы снова вернулись к компромиссам. Модель Node хорошо работает, если ваша основная проблема с производительностью связана с вводом-выводом. Тем не менее, его ахиллесова пята заключается в том, что вы можете войти в функцию, которая обрабатывает HTTP-запрос, добавить код, интенсивно использующий ЦП, и сканировать каждое соединение, если вы не будете осторожны.
Естественно Неблокирующий: Перейти
Прежде чем я перейду к разделу, посвященному го, мне следует сообщить, что я фанат го. Я использовал его во многих проектах, и я открыто поддерживаю его преимущества в производительности, и я вижу их в своей работе, когда использую его.
Тем не менее, давайте посмотрим, как это работает с вводом-выводом. Одной из ключевых особенностей языка Go является то, что он содержит собственный планировщик. Вместо того, чтобы каждый поток выполнения соответствовал одному потоку ОС, он работает с концепцией «горутин». И среда выполнения Go может назначить горутину потоку ОС и заставить ее выполняться или приостановить ее и не связывать с потоком ОС, в зависимости от того, что делает эта горутина. Каждый запрос, поступающий с HTTP-сервера Go, обрабатывается отдельной горутиной.
Схема работы планировщика выглядит так:
Под капотом это реализуется различными точками в среде выполнения Go, которые реализуют вызов ввода-вывода, делая запрос на запись/чтение/подключение/и т. д., переводят текущую горутину в спящий режим с информацией, чтобы снова разбудить горутину. вверх, когда можно будет предпринять дальнейшие действия.
По сути, среда выполнения Go делает что-то очень похожее на то, что делает Node, за исключением того, что механизм обратного вызова встроен в реализацию вызова ввода-вывода и автоматически взаимодействует с планировщиком. Он также не страдает от ограничения, связанного с необходимостью запуска всего кода вашего обработчика в одном потоке, Go автоматически сопоставит ваши горутины с таким количеством потоков ОС, которое он сочтет целесообразным, исходя из логики своего планировщика. В результате получается такой код:
func ServeHTTP(w http.ResponseWriter, r *http.Request) { // the underlying network call here is non-blocking rows, err := db.Query("SELECT ...") for _, row := range rows { // do something with the rows, // each request in its own goroutine } w.Write(...) // write the response, also non-blocking }
Как вы можете видеть выше, базовая структура кода того, что мы делаем, напоминает более упрощенные подходы, и все же обеспечивает неблокирующий ввод-вывод под капотом.
В большинстве случаев это оказывается «лучшее из обоих миров». Неблокирующий ввод-вывод используется для всех важных вещей, но ваш код выглядит так, как будто он блокирует, и поэтому его легче понять и поддерживать. Об остальном позаботится взаимодействие планировщика Go и планировщика ОС. Это не полное волшебство, и если вы строите большую систему, стоит потратить время на более подробное понимание того, как она работает; но в то же время среда, которую вы получаете «из коробки», работает и масштабируется достаточно хорошо.
У Go могут быть свои недостатки, но, вообще говоря, то, как он обрабатывает ввод-вывод, не входит в их число.
Ложь, наглая ложь и ориентиры
Трудно указать точное время переключения контекста, связанного с этими различными моделями. Я также могу утверждать, что это менее полезно для вас. Поэтому вместо этого я дам вам несколько основных тестов, которые сравнивают общую производительность HTTP-сервера в этих серверных средах. Имейте в виду, что на производительность всего сквозного пути HTTP-запроса/ответа влияет множество факторов, и представленные здесь цифры — это лишь некоторые примеры, которые я собрал, чтобы дать базовое сравнение.
Для каждой из этих сред я написал соответствующий код для чтения в файле размером 64 КБ со случайными байтами, запустив хэш SHA-256 N раз (N указано в строке запроса URL, например, .../test.php?n=100
) и вывести полученный хэш в шестнадцатеричном формате. Я выбрал это, потому что это очень простой способ запуска тех же тестов с некоторым согласованным вводом-выводом и контролируемым способом увеличения использования ЦП.
См. эти примечания к эталонным тестам для более подробной информации об используемых средах.
Во-первых, давайте рассмотрим несколько примеров с низким параллелизмом. Выполнение 2000 итераций с 300 одновременными запросами и только одним хешем на запрос (N=1) дает нам следующее:
Трудно сделать вывод только из одного этого графика, но мне кажется, что при таком объеме подключений и вычислений мы наблюдаем времена, которые больше связаны с общим выполнением самих языков, тем более что ввод/вывод. Обратите внимание, что языки, которые считаются «скриптовыми языками» (свободная типизация, динамическая интерпретация), работают медленнее всего.
Но что произойдет, если мы увеличим N до 1000, по-прежнему с 300 одновременными запросами — та же нагрузка, но в 100 раз больше итераций хеширования (значительно больше нагрузки на ЦП):
Внезапно производительность Node значительно падает, потому что операции с интенсивным использованием ЦП в каждом запросе блокируют друг друга. И что интересно, производительность PHP становится намного лучше (по сравнению с другими) и превосходит Java в этом тесте. (Стоит отметить, что в PHP реализация SHA-256 написана на C, и путь выполнения занимает гораздо больше времени в этом цикле, поскольку сейчас мы делаем 1000 итераций хеширования).
Теперь давайте попробуем 5000 одновременных подключений (с N = 1) - или настолько близко к этому, насколько я мог прийти. К сожалению, для большинства из этих сред частота отказов не была незначительной. Для этой диаграммы мы рассмотрим общее количество запросов в секунду. Чем выше, тем лучше :
И картина выглядит совсем иначе. Это предположение, но похоже, что при большом объеме соединений накладные расходы на каждое соединение, связанные с созданием новых процессов, и дополнительная память, связанная с этим в PHP + Apache, становятся доминирующим фактором и снижают производительность PHP. Очевидно, что Go является победителем, за ним следуют Java, Node и, наконец, PHP.
Несмотря на то, что факторов, влияющих на вашу общую пропускную способность, много и они сильно различаются от приложения к приложению, чем больше вы понимаете суть того, что происходит под капотом, и связанные с этим компромиссы, тем лучше вы будете.
В итоге
Учитывая все вышесказанное, совершенно ясно, что по мере развития языков, решения для работы с крупномасштабными приложениями, выполняющими большое количество операций ввода-вывода, развивались вместе с ними.
Справедливости ради следует отметить, что и в PHP, и в Java, несмотря на описания в этой статье, есть реализации неблокирующего ввода-вывода, доступные для использования в веб-приложениях. Но они не так распространены, как подходы, описанные выше, и при использовании таких подходов необходимо учитывать сопутствующие эксплуатационные расходы на обслуживание серверов. Не говоря уже о том, что ваш код должен быть структурирован таким образом, чтобы работать с такими средами; ваше «обычное» веб-приложение PHP или Java обычно не будет работать без значительных изменений в такой среде.
Для сравнения, если мы рассмотрим несколько важных факторов, влияющих на производительность, а также на простоту использования, мы получим следующее:
Язык | Потоки против процессов | Неблокирующий ввод-вывод | Простота использования |
---|---|---|---|
PHP | Процессы | Нет | |
Джава | Потоки | Доступный | Требуются обратные вызовы |
Node.js | Потоки | да | Требуются обратные вызовы |
Идти | Потоки (Горутины) | да | Обратные вызовы не нужны |
Потоки, как правило, намного эффективнее используют память, чем процессы, поскольку они используют одно и то же пространство памяти, а процессы — нет. Объединив это с факторами, связанными с неблокирующим вводом-выводом, мы можем увидеть, что, по крайней мере, с факторами, рассмотренными выше, по мере продвижения вниз по списку общая настройка, связанная с вводом-выводом, улучшается. Так что, если бы мне пришлось выбирать победителя в вышеупомянутом конкурсе, это, безусловно, был бы Go.
Тем не менее, на практике выбор среды для создания вашего приложения тесно связан со знакомством вашей команды с этой средой и общей производительностью, которую вы можете достичь с ее помощью. Поэтому для каждой команды может не иметь смысла просто погрузиться и начать разработку веб-приложений и сервисов на Node или Go. Действительно, поиск разработчиков или знакомство с вашей внутренней командой часто называют основной причиной отказа от использования другого языка и/или среды. Тем не менее, времена сильно изменились за последние пятнадцать лет или около того.
Надеемся, вышеизложенное поможет нарисовать более ясную картину того, что происходит под капотом, и даст вам некоторые идеи о том, как справиться с реальной масштабируемостью вашего приложения. Удачного ввода и вывода!