Кто на свете всех сильнее - Java, Go и Rust в сравнении

Мне показалось интересным провести сравнение между 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, просто ничего не делая. А это огромные затраты ресурсов. 

Обслуживание REST-запросов  

Давайте отправим запрос к 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.

Время ожидания
Задержка для конечной точки /fibonacci

Размер среды выполнения 

Чтобы имитировать реальное облачное приложение и избавиться от реплик вроде “Это работает на моем компьютере!”, был создан 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