Мне показалось интересным провести сравнение между Java, Go и Rust. Речь идет не о бенчмарке, а о сравнении таких характеристик, как размер выходного исполняемого файла, использование памяти и CPU, требования к среде выполнения и, конечно, небольшой тест для того, чтобы получить показатели по количеству запросов в секунду и попытаться разобраться в цифрах.
В попытке сравнить яблоки с яблоками (наверно, можно так сказать) я написал веб-сервис на каждом из языков, подлежащих сравнению. Он довольно простой и обслуживает три конечные точки REST.
Конечные точки, обслуживаемые веб-сервисом, в Java, Go и Rust.Репозиторий для трех веб-сервисов располагается на github.
Начнем с информации о том, как создавались двоичные файлы. В случае с Java я создал всё в большом толстом JAR-файле при помощи maven-shade-plugin и выполнил mvn package
для сохранения проекта в целевую папку. Для сборки проекта в Go был использован go build
, а в Rust — cargo build --release
.
Размер скомпилированного артефакта также зависит от выбранных библиотек/зависимостей, поэтому если они раздуты, то в конечном итоге ваша скомпилированная программа будет такой же. В диаграмме выше указан размер скомпилированных программ с учетом выбранных библиотек в нашем конкретном случае.
В отдельном разделе я создам и упакую все три программы в виде docker-образов и перечислю их размеры, чтобы показать потребление ресурсов среды выполнения, необходимое каждому из языков. В дальнейшем материале статьи вас ждет более детальная информация.
Минуточку! А где же столбцы для версий Go и Rust, показывающие объем требуемой памяти во время простоя? Они там тоже есть, но только Java потребляет более 160 МБ, когда JVM запускает программу, и далее сидит без дела, ничего не выполняя. В случае с Go программа использует 0,86 МБ, с Rust — 0,36 МБ. Видите разницу?! В этом примере Java использует гораздо больше памяти, чем Go и Rust, просто ничего не делая. А это огромные затраты ресурсов.
Давайте отправим запрос к API при помощи wrk и понаблюдаем за использованием памяти и CPU, а также за количеством запросов в секунду, выполняемых на моем компьютере для конечной точки каждой из трех версий программы.
wrk -t2 -c400 -d30s http://127.0.0.1:8080/hello
wrk -t2 -c400 -d30s http://127.0.0.1:8080/greeting/Jane
wrk -t2 -c400 -d30s http://127.0.0.1:8080/fibonacci/35
Приведенная выше команда wrk
сообщает следующее: используй 2 потока (для wrk), сохрани 400 открытых соединений в пуле и постоянно вызывай конечную точку GET в течение 30 секунд. Здесь я использую только два потока, так как wrk и тестируемая программа выполняются на одном и том же компьютере, и мне бы не хотелось, чтобы они конкурировали друг с другом по части доступных ресурсов, особенно CPU.
Каждый веб-сервис тестировался отдельно и перезапускался после каждого выполнения.
Ниже представлены лучшие из трех выполнений для каждой версии программы.
/hello
Эта конечная точка возвращает сообщение “Hello, World!” Она определяет место строки “Hello, World!!”, сериализует ее и возвращает в формате JSON.
Использование CPU при достижении конечной точки /hello Использование памяти при достижении конечной точки /hello Количество запросов в секунду при достижении конечной точки /hello/greeting/{name}
Эта конечная точка принимает параметр пути сегмента {name}, затем форматирует строку “Hello, {name}!”, сериализует и возвращает ее в виде приветственного сообщения в формате JSON.
Использование CPU при достижении конечной точки /greeting Использование памяти при достижении конечной точки /greeting Количество запросов в секунду при достижении конечной точки /greeting/fibonacci/{number}
Эта конечная точка принимает параметр пути сегмента {number}, возвращает число Фибоначчи и число ввода, сериализованное в формате JSON.
Для этой конкретной конечной точки была выбрана реализация рекурсивным методом. Без сомнения, мне известно, что итеративный метод реализации дает гораздо лучшие результаты производительности и больше подходит для целей продакшена. Однако встречаются случаи, когда в коде продакшена целесообразнее использовать рекурсию (не обязательно для вычисления n-го числа Фибоначчи). В связи с этим я предпочел, чтобы реализация была активно вовлечена в распределение стека CPU.
Использование CPU при достижении конечной точки /fibonacci Использование памяти при достижении конечной точки /fibonacci Количество запросов в секунду при достижении конечной точки /fibonacciВо время теста конечной точки Фибоначчи реализация Java была единственной, чье время ожидания истекло на 150 запросах, как показано в выводе wrk
.
Чтобы имитировать реальное облачное приложение и избавиться от реплик вроде “Это работает на моем компьютере!”, был создан docker-образ для каждого из трех приложений.
Источник для файлов Doker включен в репозиторий в папке соответствующей программы.
В качестве базового образа среды выполнения для приложения Java был использован openjdk:8-jre-alpine
, являющийся, как известно, одним из самых маленьких образов. В связи с этим стоит внести два уточнения, которые могут повлиять на работу вашего приложения. Во-первых, большей частью образ Alpine не соответствует стандартам POSIX в плане обработки имен переменных среды, поэтому вы не можете использовать знак . (точка) в ENV в файле Docker (в этом нет ничего особенного). Во-вторых, образ Alpine Linux компилируется при помощи musl libc, а не glibc. Это значит, что если ваше приложение зависит от чего-либо, что требует наличия glibc (или аналогов), то оно просто не будет работать. В моем случае Alpine работает прекрасно.
Что касается версий приложения Go и Rust, они были статически скомпилированы. Это значит, что они не требуют наличия libc (glibc, musl и т. д.) в образе среды выполнения, а также им не нужен базовый образ с OS для выполнения. Поэтому я использовал Docker-образ scratch, который не выполняет операций и содержит исполняемый файл с нулевыми затратами ресурсов.
В качестве условного обозначения для Docker-образа был использован {lang}/webservice. Размер образа для каждой из версий приложений Java, Go и Rust соответственно составляет 113 МБ, 8,68 МБ и 4,24 МБ.
Итоговый размер Docker-образовПрежде чем делать какие-либо выводы, мне бы хотелось обратить внимание на взаимосвязи (или их отсутствие) между этими тремя языками. Java и Go — языки с функцией сбора мусора, при этом Java компилируется методом АОТ в байт-код для JVM. При запуске приложения Java инициируется JIT-компилятор, чтобы по мере возможности оптимизировать байт-код путем компиляции его в машинный код для увеличения производительности приложения.
Go и Rust компилируются в машинный код методом АОТ, и в дальнейшем никакой оптимизации в среде выполнения не происходит.
Java и Go — языки с функцией сбора мусора и с побочным эффектом stop-the-world. Это значит, что при своем запуске сборщик мусора прекращает работу приложения, чистит память и по мере готовности возобновляет приложение с места его остановки. Функция stop-the-world необходима для работы большинства сборщиков мусора, но есть и реализации, не требующие ее.
Язык Java был создан в далекие 90-е, и одним из его знаменитых лозунгов стал: “Написано однажды — работает везде”. В то время Java был передовой разработкой, так как рынок не отличался многообразием решений виртуализации. Сейчас же большинство CPU её поддерживают, что сводит на нет соблазн разработки с использованием языка только на том основании, что его код будет работать везде (в любом случае и на любых поддерживаемых платформах). Можно просто использовать Docker или другие решения, которые предлагают выгодную виртуализацию.
В процессе тестирования версия приложения Java потребляла намного больше памяти, чем аналогичные версии Go и Rust. Результаты первых двух тестов показали, что Java использовал на 8000% больше памяти. Если бы речь шла о реальном приложении, то операционные расходы на приложение Java были бы выше.
Первые два теста показали, что приложение Go использовало на 20% меньше CPU, чем Java, при этом обслуживая на 38% больше запросов. С другой стороны, версия Rust использовала на 57% меньше CPU, чем Go, обслуживая на 13% больше запросов.
Третий тест намеренно активно задействовал CPU, и я был настроен выжать из него каждый бит. Go и Rust использовали на 1% больше CPU, чем Java. Если бы команды wrk
не выполнялись на одном компьютере, то все три версии задействовали бы CPU на 100%. По показателям памяти Java потреблял на 2000% больше, чем Go и Rust. Java обслужил на 20% больше запросов, чем Go, в то время как Rust — на 15% больше запросов, чем Java.
К моменту написания статьи язык программирования Java существует уже около 30 лет, в связи с чем найти на рынке разработчиков Java не составляет труда. С другой стороны, Go и Rust — относительно новые языки, и, естественно, что число их разработчиков меньше по сравнению с Java. Но надо сказать, что они стремительно набирают обороты и все чаще используются для новых проектов. Кроме того, существует много Go и Rust проектов, выполняемых в продакшене, так как они превосходят Java в эффективности с точки зрения потребляемых ресурсов.
Я выучил Go и Rust, пока писал программы для этой статьи. Изучение Go заняло совсем немного времени, так как он оказался довольно простым языком с небольшим объёмом синтаксиса. Потребовалась лишь пара дней для написания на нем программы. Отдельно стоит отметить скорость его компиляции, которая гораздо быстрее, чем у других языков, таких как Java/C/C++/Rust. На написание программы версии Rust ушло около недели, и большую часть времени я потратил на выяснение, что же от меня хочет borrow checker. Дело в том, что у Rust строгие правила владения, но если вы разберетесь в концепции владения и заимствования этого языка, то сообщения об ошибках компиляции начнут обретать для вас смысл. Причина, по которой компилятор Rust на вас ругается, когда вы нарушает правила проверки заимствования, состоит в том, что при компиляции он хочет подтвердить время жизни и принадлежность выделенной памяти. Так компилятор стоит на страже безопасности программы (никаких висячих ссылок, если только не использовались небезопасные выходы кода). Освобождение памяти происходит во время компиляции, что устраняет потребность в сборщике мусора и помогает избежать затрат среды выполнения. Но всем этим вы сможете воспользоваться, только освоив систему владения Rust.
С точки зрения конкурентоспособности, на мой взгляд, Go прямой соперник для Java (и в целом языков JVM), но не для Rust. С другой стороны, Rust серьёзный конкурент для Java, Go, C, and C++.
Учитывая их производительность, продолжу писать программы и в Go, и в Rust, но с большей долей вероятности — в Rust. Оба этих языка прекрасно подходят для веб-сервисов, CLI, разработки системных программ и т. д. Rust обладает фундаментальным преимуществом над Go. Он не является языком с функцией сборки мусора и спроектирован для безопасного написания кода, в отличие от C и C++. Go не совсем подходит для написания ядра ОС, тогда как Rust справляется с этим великолепно и может посоревноваться с C/C++, являющимися классическими и фактическими языками для написания ОС. Еще одна область, в которой Rust может составить конкуренцию C/C++, касается сферы встроенного ПО, но об этом мы поговорим в другой раз.
Благодарю за внимание!
Перевод статьи Dexter Darwich: Comparison between Java, Go, and Rust
Комментарии