оригинальный:Server-side I/O Performance: Node vs. PHP vs. Java vs. Go
автор: БРЭД ПИБОДИ
перевести: гуси испугались
Аннотация: в этой статье сначала кратко представлены основные понятия, связанные с вводом-выводом, а затем горизонтально сравнивается производительность ввода-вывода Node, PHP, Java и Go и даются предложения по выбору. Ниже перевод.
Понимание модели ввода-вывода (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 миллионов циклов, чтобы заблокировать вызов.
Ядро обеспечивает оба блокировки ввода / вывода («чтение данных из сети»), так и неблокируя ввод / вывод («скажите мне, когда есть новые данные о сетевом подключении»), и оба механизма блокируют время процесса вызова Длина полностью отличаются.
Третий очень важный момент — это то, что происходит, когда много потоков или процессов начинают блокироваться.
Для нас нет большой разницы между потоком и процессом. В действительности наиболее заметное различие, связанное с производительностью, состоит в том, что один процесс имеет тенденцию занимать больше памяти, потому что потоки совместно используют одну и ту же память, а каждый процесс имеет собственное пространство памяти. Однако, когда мы говорим о планировании, мы на самом деле говорим о выполнении ряда действий, и каждое из них должно получить определенное время выполнения на доступных ядрах ЦП. Если у вас есть 8 ядер для запуска 300 потоков, то вы должны квант времени, чтобы каждый поток получил свой собственный квант времени, каждое ядро работает в течение короткого времени, прежде чем переключиться на следующий поток. Это делается с помощью «переключателя контекста», который позволяет процессору переключаться с одного потока/процесса на другой.
Это переключение контекста имеет определенную стоимость, то есть занимает определенное количество времени. Это может быть меньше 100 наносекунд в быстром режиме, но обычно требуется 1000 наносекунд или более, если детали реализации, скорость процессора/архитектура, кэш-память ЦП и т. д. Аппаратное и программное обеспечение отличаются.
Чем больше количество потоков (или процессов), тем больше количество переключений контекста. Когда есть тысячи потоков, на переключение каждого из которых уходит сотни наносекунд, система становится очень медленной.
Однако неблокирующий вызов, по сути, говорит ядру: «Вызывайте меня только тогда, когда по этим соединениям поступают новые данные или события». Эти неблокирующие вызовы эффективно обрабатывают большие нагрузки ввода-вывода и сокращают переключение контекста.
Стоит отметить, что, хотя примеры в этой статье небольшие, доступ к базе данных, внешние системы кэширования (memcache и т.п.) и все, что требует ввода-вывода, в конечном итоге будет выполнять вызов ввода-вывода определенного типа, аналогичный приведенному в примере. Принцип тот же.
Есть много факторов, влияющих на выбор языка программирования в проекте, даже если учитывать только производительность. Однако, если вы обеспокоены тем, что ваша программа в основном связана с вводом-выводом, а производительность является важным фактором, определяющим успех или неудачу вашего проекта, то следует сосредоточиться на следующих предложениях.
Еще в 1990-х годах многие люди носилиConverseОбувь написана компьютерной графикой с использованием Perl. Затем появился PHP, который многим понравился и упростил создание динамических веб-страниц.
Модель, используемая PHP, очень проста. Хотя это не может быть точно таким же, общий принцип сервера PHP таков:
Браузер пользователя делает HTTP-запрос, который отправляется на веб-сервер Apache. Apache создает отдельный процесс для каждого запроса и повторно использует эти процессы с некоторыми оптимизациями, чтобы свести к минимуму то, что в противном случае потребовалось бы (создание процесса происходит относительно медленно).
Apache вызывает PHP и говорит ему запустить что-то на диске.php
документ.
Код PHP начинает выполнять и блокирует вызов ввода / вывода. который вы звоните в PHPfile_get_contents()
, который на самом деле называется внизуread()
системный вызов и дождаться возвращенного результата.
- // 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');
- ?>
<?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'); ?>
Принципиальная схема интеграции с системой выглядит следующим образом:
很简单:每个请求一个进程。 I/O调用是阻塞的。那么优点呢?简单而又有效。 Недостатки?如果有20000个客户端并发,服务器将会瘫痪。这种方法扩展起来比较难,因为内核提供的用于处理大量I/O(epoll等)的工具并没有充分利用起来。更糟糕的是,为每个请求运行一个单独的进程往往会占用大量的系统资源,尤其是内存,这通常是第一个耗尽的。
*Примечание: на данный момент ситуация с Ruby очень похожа на ситуацию с PHP.
Так появилась Ява. А в язык Java встроена многопоточность, особенно когда речь идет о создании потоков, и это здорово.
Большинство веб-серверов Java запускают новый поток выполнения для каждого запроса, а затем вызывают в этом потоке функции, написанные разработчиком.
Выполнение ввода-вывода в сервлете Java часто выглядит следующим образом:
Java-код- publicvoiddoGet(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("...");
- }
publicvoiddoGet(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
Метод соответствует запросу и выполняется в собственном потоке, а не в отдельном процессе, которому нужна отдельная память, поэтому мы создадим отдельный поток. Каждый запрос получает новый поток и блокирует различные операции ввода-вывода внутри этого потока, пока запрос не будет обработан. Приложение создает пул потоков, чтобы свести к минимуму затраты на создание и уничтожение потоков, однако тысячи соединений означают тысячи потоков, что не очень хорошо для планировщика.
Стоит отметить, что в Java 1.4 (и повторно обновленном в 1.7) добавлена возможность выполнять неблокирующие вызовы ввода-вывода. Хотя большинство приложений не используют эту функцию, по крайней мере, ее можно использовать. Некоторые веб-серверы Java экспериментируют с этой функцией, но подавляющее большинство развернутых приложений Java по-прежнему работают так, как описано выше.
Java предоставляет множество функций ввода-вывода из коробки, но если вы сталкиваетесь с ситуацией создания большого количества блокирующих потоков для выполнения большого количества операций ввода-вывода, у Java нет хорошего решения.
Сделать неблокирующий ввод/вывод приоритетом: узел
Node.js — это тот, который лучше работает с вводом-выводом и более популярен среди пользователей. Любой, кто хоть немного разбирается в Node, знает, что он «неблокирующий» и эффективно обрабатывает ввод-вывод. Это верно в общем смысле. Но детали и то, как это реализовано, имеют решающее значение.
Когда вам нужно сделать что-то, связанное с вводом-выводом, вам нужно сделать запрос и указать функцию обратного вызова, которую Node вызовет после обработки запроса.
Типичный код для выполнения операции ввода-вывода в запросе выглядит следующим образом:
Код узла- http.createServer(function(request, response) {
- fs.readFile('/path/to/file', 'utf8', function(err, data) {
- response.end(data);
- });
- });
http.createServer(function(request, response) { fs.readFile('/path/to/file', 'utf8', function(err, data) { response.end(data); }); });
Как показано выше, здесь есть две функции обратного вызова. Первая функция вызывается при запуске запроса, а вторая функция вызывается при наличии данных файла.
Таким образом, Node может более эффективно обрабатывать ввод-вывод этих функций обратного вызова. Есть более наглядный пример: вызов операций с базой данных в Node. Сначала ваша программа начинает вызывать операции с базой данных и предоставляет Node функцию обратного вызова, Node будет использовать неблокирующие вызовы для выполнения операций ввода-вывода в одиночку, а затем вызовет вашу функцию обратного вызова, когда запрошенные данные будут доступны. Этот механизм постановки вызовов ввода-вывода в очередь и обработки Node вызовов ввода-вывода и получения обратного вызова называется «циклом событий». Этот механизм очень хорош.
Однако с этой моделью есть проблема. Внизу причина этой проблемы связана с реализацией движка JavaScript V8 (Node использует движок JS Chrome), то есть: весь код 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
- })
- };
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. Я использовал Go во многих проектах.
Давайте посмотрим, как он обрабатывает ввод-вывод. Ключевой особенностью языка Go является наличие собственного планировщика. Он не соответствует потоку операционной системы для каждого потока выполнения, а использует концепцию «горутин». Среда выполнения Go выделяет поток операционной системы горутине и контролирует ее выполнение или приостановку. Каждый запрос к HTTP-серверу 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
- }
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-запроса/ответа.
Я за каждую среду написал какой-то код, чтобы прочитать случайные байты 64K файла, который затем запущен N раз SHA-256 HASH (n, указанный в строке запроса URL, например,.../test.php?n=100
) и вывести результат в шестнадцатеричном формате. Я выбрал это, потому что это упрощает выполнение некоторых устойчивых операций ввода-вывода и увеличивает использование ЦП контролируемым образом.
Во-первых, давайте рассмотрим несколько примеров с низким уровнем параллелизма. Выполнение 2000 итераций с 300 одновременными запросами, хеширование один раз на запрос (N=1) приводит к следующему:
Время — это среднее количество миллисекунд для выполнения всех одновременных запросов. Чем ниже, тем лучше.
Трудно делать выводы только по этому графику, но я лично думаю, что результаты, которые мы видим, больше связаны с выполнением самого языка с большим количеством соединений и вычислений, подобных этому. Обратите внимание, что «язык сценариев» выполняется медленнее всего.
Но что произойдет, если мы увеличим N до 1000, но по-прежнему будем иметь 300 одновременных запросов, т.е. увеличим количество итераций хэша в 1000 раз при той же нагрузке (со значительно более высокой загрузкой процессора):
Время — это среднее количество миллисекунд для выполнения всех одновременных запросов. Чем ниже, тем лучше.
Внезапно производительность Node значительно упала, так как операции с интенсивным использованием ЦП в каждом запросе блокировали друг друга. Интересно, что в этом тесте производительность PHP стала лучше (по сравнению с другими), даже лучше, чем у Java. (Стоит отметить, что в PHP реализация SHA-256 написана на C, но путь выполнения в этом цикле занимает больше времени, потому что на этот раз мы делаем 1000 итераций хеширования).
Теперь попробуем 5000 одновременных соединений (n = 1). К сожалению, для большинства сред, уровень отказов не является значительным. Давайте посмотрим на количество запросов, обработанных в секунду на этом графике,Чем выше, тем лучше:
Количество запросов, обрабатываемых в секунду, чем выше, тем лучше.
Эта картинка выглядит иначе, чем та, что выше. Я предполагаю, что при большем количестве подключений новые процессы и запросы памяти в PHP + Apache кажутся основным фактором, влияющим на производительность PHP.Очевидно, что на этот раз Go является победителем, за ним следуют Java, Node и, наконец, PHP.
Несмотря на то, что на общую пропускную способность влияет множество факторов, и существуют значительные различия от приложения к приложению, чем лучше вы понимаете основные принципы и сопутствующие компромиссы, тем лучше будет работать ваше приложение.
Подводя итог, можно сказать, что по мере развития языков развиваются и решения для обработки больших приложений с большим количеством операций ввода-вывода.
Честно говоря, PHP и Javaвеб приложениеимеютдоступныйизНе блокировка ввода / выводаизвыполнить. Но эти реализации не так широко используются, как методы, описанные выше, и также необходимо учитывать накладные расходы на обслуживание. Не говоря уже о том, что код приложения должен быть структурирован в соответствии с этой средой.
Давайте сравним несколько важных факторов, влияющих на производительность и удобство использования:
Язык Потоки и процессы Неблокирующий ввод-вывод Простота использованияPHP | процесс | нет | - |
Java | нить | эффективный | Требуется обратный вызов |
Node.js | нить | да | Требуется обратный вызов |
Go | Потоки (Горутины) | да | Не требуется обратный вызов |
Поскольку поток использует то же пространство памяти, что и процесс, поток обычно намного выше, чем эффективность памяти процесса. В приведенном выше списке посмотрите сверху вниз, и факторы, связанные с вводом-выводом, лучше, чем один. Так что, если мне придется выбирать победителя в приведенном выше сравнении, то я обязательно выберу Go.
Тем не менее, на практике выбор среды, в которой будет создаваться ваше приложение, тесно связан со знакомством вашей команды со средой и общей производительностью, которой может достичь ваша команда. Таким образом, использование Node или Go для разработки веб-приложений и сервисов может быть не лучшим вариантом для команд.
Надеемся, вышеизложенное поможет вам получить более четкое представление о том, что происходит под капотом, и даст вам несколько советов о том, как справиться с масштабируемостью приложения.