Go vs Rust. Что же лучше в конкуретности?
Стало мне как-то интересно, кто из языков Go или Rust лучше работает с конкурентными задачами. С одной стороны, особый механизм конкурентности в Go является чуть ли основополагающей фичей. С другой стороны сам по себе Rust является более производительным языком, и в глазах некоторых программистов даже является «убийцей» C и C++. Поэтому я решил провести небольшое тестирование и написать собственный бенчмарк для этого.
Для упрощения я буду горутины в Go и асинхронные задачи в Rust называть корутинами. Написанные тесты запускались на количестве корутин 101, 102, …, 106. Смысл тестирования заключается в том, чтобы определить, какой из языков решит задачу наиболее быстро. По затраченному времени на выполнение задачи можно судить не только о скорости работы языка, но и том, насколько он страдает от большого количества конкуретных задач. Также в каждом тесте записывалась потребляемая память.
Небольшой дисклеймер. Реального опыта работы ни с Go, ни с Rust у меня не было. Если вы являетесь опытным разработчиком, можете оценить качество моих тестов и подсказать можно ли и, если можно то как, улучшить мои тесты.
Для не знакомых с Rust стоит оговориться, что сам по себе язык не предоставляет из коробки возможности запускать конкурентные задачи. Для этого нужно дополнительно устанавливать внешние пакеты или писать свой код. В моих же тестах была использована библиотека Tokio, как самая популярная библиотека для написания асинхронного кода на Rust.
Код тестов и результаты в формате CSV приложены в конце статьи. Тест проводился на версиях языков:
Описание тестов
Я постараюсь подробно расписать как именно проводились тесты, однако, чтобы не заострять на них внимание и не раздувать объем статьи, я попрятаю информацию по разворачиваемым блокам.
Каждый из тестов запускается 5 раз. В качестве метрики скорости работы используется минимальное затраченное время за полный прогон одного теста. В качестве метрики потребления памяти используется максимальное количество потребляемой памяти в течение всех 5 прогонов.
Почему время записывается минимальное, а потребляемая память максимальная?
Написанные тесты не являются «чистыми», так как запускались на машине, на которой помимо тестов работали дополнительные программы — например, служебные сервисы Windows, демоны NVIDIA т.п. Если считать, что в тестах выполняется идентичная функциональность, то время выполнения будет тогда минимальным, когда в системе будет наименьшее влияние от других процессов.
Замеры же памяти проводились напрямую, считывая потребление у запускаемого процесса и всех его дочерних процессов. На потребление памяти не должно оказывать влияние наличие работающих других программ. Максимальное же количество берется исключительно на случай, если в каком-то из тестов памяти будет потребляться больше, чем в других.
А ещё между тестами были добавлены задержки, чтобы уменьшить влияние предыдущих тестов на последующие. Размер задержки зависит от результата прошлого теста — если предыдущий тест длился 0.5 секунд, то следующий начнется через 0.5 секунд. Такой динамический расчет задержки был добавлен из-за того, что разница времени выполнения программы на разном количестве корутин может значимо отличаться.
По причине необходимости расчета задержки и для того, чтобы иметь возможность измерять количество памяти, все тесты запускались в скрипте Python.
А далее представлено описание самих тестов
Описание теста 1. «Sleep»
Первый тест является «эмулирующим». Каждая корутина выполняет некоторый расчет числа в цикле от 1 до 100 тысяч, а затем засыпает на случайное количество миллисекунд.
Для того, чтобы тесты были повторяемы генерация миллисекунд происходит при помощи линейного когурентного генератора (далее ЛКГ). Таким образом у каждой корутины будет вызываться разная случайная задержка (до 300 мс), однако в разных тестах задержки будут использованы одни и те же.
Описание теста 2. «Files R»
Для второго теста было сгенерировано 20 файлов. В каждом из файлов находится по 3, 6, 9, 12 и т.д. строчек длинной 64 символа (включая символ переноса строки). При этом в работе теста также использовался ЛКГ, для того, чтобы файлы читались в случайной последовательности.
Сам тест заключается в том, что каждая корутина читает информацию из файла и записывает её в канал (в случае Go) или в вектор (в случае Rust).
Описание теста 3. «Files RW»
Для третьего теста также использовались текстовые данные и ЛКГ, однако в нем в половине случаев в файл записывается дополнительная информация, после чего файл считывается во всех случаях.
«Files RW» запускается при количестве корутин до 105 корутин. На 106 моего железа не хватило.
Описание теста 4. «SQLite»
Для четвертого теста была подготовлена база данных SQLite, содержащая одну таблицу с 4 столбцами и 10 тыс. записями. В тесте снова используется ЛКГ. В зависимости от случайного значения корутина либо обновляет одну запись в таблице и записывает её обновленное значение, либо получает все записи с определенным фильтром и сортировкой.
Так как база данных SQLite не предназначена для высоконагруженных систем, тестирование проводилось на 10, 100 и 1000 корутин.
Описание теста 5. «MySQL»
Принципы работы MySQL и SQLite довольно сильно отличаются. Поэтому было решено провести дополнительно тест с MySQL. База данных в пятом тесте дублирует структуру базы данных SQLite. Функциональность также скопирована с 4 го теста.
Единственное отличие тестов в том, что 5й тест дополнительно проводится на 10 и 100 тысячах корутин. Тест 1 млн. корутин не запускается так же из-за того, что железо мое слабовато.
Результаты
Я хотел приложить графики, но выглядят они так себе. На большом масштабе результаты мало различимы. А дополнительные ухищрения по типу нормализации данных для красивого отображения графиков только лишь усложнят восприятие статьи.
Поэтому я приложу логи, записанные во время тестов.
Benchmarking Rust
Test 1. Sleep
10 : 0.29s 3.58Mb
100 : 0.31s 3.65Mb
1,000 : 0.35s 5.10Mb
10,000 : 0.69s 17.50Mb
100,000 : 4.21s 142.20Mb
1,000,000: 41.33s 1,388.85Mb
Test 2. Files R
10 : 0.00s 3.59Mb
100 : 0.01s 3.83Mb
1,000 : 0.03s 3.48Mb
10,000 : 0.15s 39.49Mb
100,000 : 1.46s 155.49Mb
1,000,000: 14.11s 1,120.61Mb
Test 3. Files RW
10 : 0.00s 3.53Mb
100 : 0.02s 3.83Mb
1,000 : 0.08s 5.02Mb
10,000 : 0.74s 70.14Mb
100,000 : 18.46s 2,755.55Mb
Test 4. SQLite
10 : 0.02s 3.54Mb
100 : 0.21s 7.71Mb
1,000 : 1.15s 14.44Mb
Test 5. MySQL
10 : 0.02s 3.83Mb
100 : 0.12s 16.76Mb
1,000 : 0.66s 32.57Mb
10,000 : 5.75s 164.46Mb
100,000 : 54.46s 1,439.34Mb
Benchmarking Go
Test 1. Sleep
10 : 0.29s 5.43Mb
100 : 0.30s 6.38Mb
1,000 : 0.33s 14.19Mb
10,000 : 0.70s 92.12Mb
100,000 : 4.46s 870.62Mb
1,000,000: 42.73s 8,668.29Mb
Test 2. Files R
10 : 0.00s 4.39Mb
100 : 0.01s 3.54Mb
1,000 : 0.02s 3.54Mb
10,000 : 0.16s 99.98Mb
100,000 : 1.73s 1,249.56Mb
1,000,000: 18.28s 12,169.90Mb
Test 3. Files RW
10 : 0.00s 4.39Mb
100 : 0.02s 3.54Mb
1,000 : 0.16s 22.89Mb
10,000 : 1.72s 214.72Mb
100,000 : 27.36s 5,119.96Mb
Test 4. SQLite
10 : 0.09s 9.32Mb
100 : 1.01s 24.50Mb
1,000 : 7.39s 172.67Mb
Test 5. MySQL
10 : 0.01s 4.39Mb
100 : 0.07s 11.30Mb
1,000 : 0.47s 24.74Mb
10,000 : 4.69s 141.66Mb
100,000 : 45.74s 1,249.59Mb
Ну, а для тех, кому лень читать логи, я кратко опишу результаты ниже.
Скорость работы
В первом тесте Rust был немного быстрее Go — в среднем на 5%. Что интересно, с увеличением количества корутин разница по скорости работы только растет — на 1 миллионе задач разница составляет почти 15%.
Во втором тесте все не так однозначно. При небольшом количестве корутин Go показывает более быстрые результаты — даже на 103 задач разница составляет 40% в сторону Go. Однако уже на 104 корутин Rust выбивается вперед и далее только увеличивает отрыв, а на миллионе задач разница составляет уже 22% в сторону Rust.
Третий тест имеет схожую тенденцию. При небольшом количестве рутин Go опережает Rust. Однако, уже при количестве от 1000 Go сильно замедляется и отрыв по производительности становится двукратным. На миллионе корутин Go немного отбивается, но отрыв все равно составляет 33%.
Четвертый тест меня, если честно, сильно удивил — Rust отработал в четыре раза быстрее Go, а на тысяче корутин даже ещё лучше. При этом код написанный на Rust и Go почти не отличается — в обоих случаях использовались одинаковые параметры ЛКГ, одинаковые условия и одинаковые запросы.
Но что меня ещё больше поразило после четвертого теста, так это то, что в пятом тесте Go показывает свое явное доминирование над Rust. У него нет такого сильного отрыва, как в прошлом тесте, но на любом количестве корутин Go отработал быстрее, чем Rust. Если в цифрах, то примерно в 1.2 — 1.7 раз быстрее.
И какой из этого можно сделать вывод? Ну, для начала стоит оговориться, что разницу в 3, 5, даже 10%, учитывая, среду в которой они запускались, вполне можно было бы списать на погрешность. Однако, даже такая небольшая разница в тестах воспроизводится, если запускать его несколько раз, хоть и с небольшим отклонением.
Интересно себя повели языки при работе с базами данных. Мне сложно говорить по какой причине наблюдается настолько огромный отрыв в одном случае, и обратная картина во втором. В обоих случаях для взаимодействия с БД я использовал сторонние библиотеки, и у меня есть подозрение, что они могут быть сделаны не идеально. Ну или я где-то ошибся в своих тестах.
Поэтому вывод я, пожалуй, сделаю такой. Оба языка себя показали хорошо — в некоторых местах Rust был быстрее, в некоторых Go. Вместе с этим в 4 из 5 тестов виден явный тренд, говорящий о том, что при большем количестве задач Rust работает все быстрее и быстрее чем Go. Вполне вероятно, что выбранные задачи просто подобраны так, что показывают Rust с лучшей стороны. Однако однозначно точно можно говорить о том, что Rust работает с конкуретными задачами не хуже, чем Go.
Потребление памяти
А вот с памятью результаты получились весьма однозначные. Почти везде Rust потреблял меньше памяти — на 30, 60%, в одном из тестов разница составляла целых 92%. С увеличением количества корутин разница в паре тестов растет, в некоторых тестах, наоборот, снижается. Однако, в любом случае памяти Rust затратил сильно меньше Go.
Во всем. Кроме. MySQL. В среднем в этом тесте Go затратил памяти в 1.14 раз меньше, однако, если рассматривать наиболее загруженный тест в миллион корутин, там разница составила всего 5%. То есть опять же, с увеличением количества задач отклонение движется в сторону Rust, однако именно в этом тесте так и не доходит до меньших показателей.
Резюме
Для полноты картины, можно было ещё рассмотреть работу с PostreSQL или придумать другие задачами (что вы можете сделать в комментариях). Я же расписал результаты только 5 тестов, так как статья и так уже сильно раздулась.
Однако, на тех пяти тестах, что были проведены сейчас, можно с уверенностью заявить, что Rust работает с конкурентными задачами не хуже Go. Можно сделать предположение, что Rust даже более эффективен на большом количестве задач. И абсолютно точно заявить, что Rust потребляет меньше памяти.
Опять же повторюсь. У меня мало опыта в этих языках. Я написал статью просто потому что мне было интересно их сравнить. Люди с большим опытом, посмотрите на код в репозитории. Может я чего-то не учел, и Go способен показать более лучшие результаты. А может и наоборот — Rust способен в клочья рвать Go, а я ему не дал такой возможности.
Ещё хотелось бы небольшой тизер оставить. Если у этой статьи будет хороший отклик, я постараюсь написать статьи-сравнения ORM-фреймворков (GORM, Diesel), ведь в Web-разработке они наверняка будут применяться более часто нежели обычные SQL фреймворки, и Web-фреймворков для построения REST API (Gin, Begoo / Rocket, Actix).
Ссылка на код и данные
https://github.com/Yoskutik/go-vs-rust
В этом же репозитории в папке results находятся результаты теста.
Послесловие
В комментариях было описано несколько неплохих советов по оптимизации тестов. Я обязательно опробую все советы и либо обновлю эту статью, либо напишу вторую часть.