Pull to refresh

Go vs Rust. Что же лучше в конкурентности?

Reading time10 min
Views18K

Стало мне как-то интересно, кто из языков Go или Rust лучше работает с конкурентными задачами. С одной стороны, особый механизм конкурентности в Go является чуть ли основополагающей фичей. С другой стороны сам по себе Rust является более производительным языком, и в глазах некоторых программистов даже является "убийцей" C и C++. Поэтому я решил провести небольшое тестирование и написать собственный бенчмарк для этого.

Для упрощения я буду горутины в Go и асинхронные задачи в Rust называть корутинами. Написанные тесты запускались на количестве корутин 101, 102, ..., 106. Смысл тестирования заключается в том, чтобы определить, какой из языков решит задачу наиболее быстро. По затраченному времени на выполнение задачи можно судить не только о скорости работы языка, но и том, насколько он страдает от большого количества конкурентных задач. Также в каждом тесте записывалась потребляемая память.

Небольшой дисклеймер. Реального опыта работы ни с Go, ни с Rust у меня не было. Если вы являетесь опытным разработчиком, можете оценить качество моих тестов и подсказать можно ли и, если можно то как, улучшить мои тесты.

И ещё один дисклеймер. Эта статья уже претерпевала изменения, так как умные люди в комментариях подсказали автору, как лучше писать тесты.

Для не знакомых с Rust стоит оговориться, что сам по себе язык не предоставляет из коробки возможности запускать конкурентные задачи. Для этого нужно дополнительно устанавливать внешние пакеты или писать свой код. В моих же тестах была использована библиотека Tokio, как самая популярная библиотека для написания асинхронного кода на Rust.

Код тестов и результаты в формате CSV приложены в конце статьи. Тест проводился на версиях языков:

  • Go - go1.18.3 windows/amd64

  • Rust - rustc 1.62.0 (a8314ef7d 2022-06-27)

Описание тестов

Я постараюсь подробно расписать как именно проводились тесты, однако, чтобы не заострять на них внимание и не раздувать объем статьи, я попрячу информацию по разворачиваемым блокам.

Каждый из тестов запускается 5 раз. В качестве метрики скорости работы используется минимальное затраченное время за полный прогон одного теста. В качестве метрики потребления памяти используется максимальное количество потребляемой памяти в течение всех 5 прогонов.

Почему время записывается минимальное, а потребляемая память максимальная?

Написанные тесты не являются "чистыми", так как запускались на машине, на которой помимо тестов работали дополнительные программы - например, служебные сервисы Windows, демоны NVIDIA т.п. Если считать, что в тестах выполняется идентичная функциональность, то время выполнения будет тогда минимальным, когда в системе будет наименьшее влияние от других процессов.

Замеры же памяти проводились напрямую, считывая потребление у запускаемого процесса и всех его дочерних процессов. На потребление памяти не должно оказывать влияние наличие работающих других программ. Максимальное же количество берется исключительно на случай, если в каком-то из тестов памяти будет потребляться больше, чем в других.

А ещё между тестами были добавлены задержки, чтобы уменьшить влияние предыдущих тестов на последующие. Размер задержки зависит от результата прошлого теста - если предыдущий тест длился 0.5 секунд, то следующий начнется через 0.5 секунд. Такой динамический расчет задержки был добавлен из-за того, что разница времени выполнения программы на разном количестве корутин может значимо отличаться.

По причине необходимости расчета задержки и для того, чтобы иметь возможность измерять количество памяти, все тесты запускались в скрипте Python.

В каждом тесте несколько раз вызывается корутина, а затем её результат записывается. В случае Go передавать результат от корутины можно только через канал, поэтому он для Go и использовался. В случае Rust значения можно просто возвращать из функции.

А далее представлено описание самих тестов

Описание теста 1. "Sleep"

Первый тест является "эмулирующим". Каждая корутина выполняет некоторый расчет числа в цикле от 1 до 100 тысяч, а затем засыпает на случайное количество миллисекунд.

Для того, чтобы тесты были повторяемы генерация миллисекунд происходит при помощи линейного когурентного генератора (далее ЛКГ). Таким образом у каждой корутины будет вызываться разная случайная задержка (до 300 мс), однако в разных тестах задержки будут использованы одни и те же.

Описание теста 2. "Files R"

Для второго теста было сгенерировано 20 файлов. В каждом из файлов находится по 3, 6, 9, 12 и т.д. строчек длиной 64 символа (включая символ переноса строки). При этом в работе теста также использовался ЛКГ, для того, чтобы файлы читались в случайной последовательности.

Сам тест заключается в том, что каждая корутина читает информацию из файла и записывает её.

Описание теста 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 млн. корутин не запускается так же из-за того, что железо мое слабовато.

Важная оговорка

Эта секция дополняется уже после написания статьи. В комментариях мне ясно дали понять, что в Go логично использовать буферизованные каналы. Вместе с этим немалое количество пользователей смутило, что канал для Go использовался всего 1. Если честно я ожидал, что 1 канал будет преимуществом Go, и даже не подумал запускать тесты при условии 1 канал на одну корутину. Однако, это было неверное решение с моей стороны. Результаты меняются довольно сильно.

При большом количестве буферизованных каналов Go справляется лучше, чем с одним небуферизованным. Поэтому в секции результатов будут описаны тесты с множеством каналов.

Результаты

Я хотел приложить графики, но выглядят они так себе. На большом масштабе результаты мало различимы. А дополнительные ухищрения по типу нормализации данных для красивого отображения графиков только лишь усложнят восприятие статьи. Вместо этого я приложу логи, записанные во время теста. И приложу я 2 варианта - для Go с одним каналом и для Go c 1 каналом на 1 корутину. И так как вторые логи были записаны позже я их немного модернизировал для упрощения анализа.

Логи с 1 каналом для Go (Вдруг кому интересно будет)
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

Логи с 1 каналом на 1 корутину для Go
1. Sleep results:
+-----------+--------------------+--------------------+-------------------+
| amount    | Rust               | Go                 | Diff              |
+-----------+---------+----------+---------+----------+---------+---------+
| 10        | 0.295s  | 3.59Mb   | 0.290s  | 5.40Mb   | +1.6%   | -33.6%  |
| 100       | 0.312s  | 3.66Mb   | 0.305s  | 6.44Mb   | +2.0%   | -43.2%  |
| 1,000     | 0.340s  | 5.14Mb   | 0.337s  | 14.37Mb  | +0.8%   | -64.2%  |
| 10,000    | 0.689s  | 17.48Mb  | 0.696s  | 80.27Mb  | -1.0%   | -78.2%  |
| 100,000   | 4.230s  | 142.23Mb | 4.406s  | 323.24Mb | -4.0%   | -56.0%  |
| 1,000,000 | 41.265s | 1.36Gb   | 41.966s | 816.23Mb | -1.7%   | +70.2%  |
+-----------+---------+----------+---------+----------+---------+---------+

2. Files R results:
+-----------+--------------------+--------------------+-------------------+
| amount    | Rust               | Go                 | Diff              |
+-----------+---------+----------+---------+----------+---------+---------+
| 10        | 0.002s  | 3.82Mb   | 0.001s  | 3.57Mb   | +109.4% | +7.2%   |
| 100       | 0.007s  | 3.89Mb   | 0.004s  | 3.52Mb   | +51.6%  | +10.5%  |
| 1,000     | 0.021s  | 3.57Mb   | 0.019s  | 3.54Mb   | +13.0%  | +1.1%   |
| 10,000    | 0.155s  | 48.39Mb  | 0.152s  | 79.34Mb  | +1.5%   | -39.0%  |
| 100,000   | 1.395s  | 277.41Mb | 1.604s  | 483.78Mb | -13.0%  | -42.7%  |
| 1,000,000 | 14.409s | 2.14Gb   | 15.899s | 3.29Gb   | -9.4%   | -35.0%  |
+-----------+---------+----------+---------+----------+---------+---------+

3. Files RW results:
+-----------+--------------------+--------------------+-------------------+
| amount    | Rust               | Go                 | Diff              |
+-----------+---------+----------+---------+----------+---------+---------+
| 10        | 0.002s  | 3.82Mb   | 0.005s  | 3.66Mb   | -52.3%  | +4.4%   |
| 100       | 0.018s  | 3.57Mb   | 0.016s  | 3.55Mb   | +15.8%  | +0.4%   |
| 1,000     | 0.091s  | 14.82Mb  | 0.157s  | 24.28Mb  | -41.8%  | -38.9%  |
| 10,000    | 0.849s  | 78.18Mb  | 1.892s  | 158.56Mb | -55.1%  | -50.7%  |
| 100,000   | 15.670s | 2.68Gb   | 27.959s | 4.01Gb   | -44.0%  | -33.2%  |
+-----------+---------+----------+---------+----------+---------+---------+

4. SQLite results:
+-----------+--------------------+--------------------+-------------------+
| amount    | Rust               | Go                 | Diff              |
+-----------+---------+----------+---------+----------+---------+---------+
| 10        | 0.020s  | 3.55Mb   | 0.083s  | 9.00Mb   | -75.2%  | -60.6%  |
| 100       | 0.208s  | 8.18Mb   | 1.094s  | 27.80Mb  | -81.0%  | -70.6%  |
| 1,000     | 1.039s  | 15.09Mb  | 7.307s  | 176.11Mb | -85.8%  | -91.4%  |
+-----------+---------+----------+---------+----------+---------+---------+

5. MySQL results:
+-----------+--------------------+--------------------+-------------------+
| amount    | Rust               | Go                 | Diff              |
+-----------+---------+----------+---------+----------+---------+---------+
| 10        | 0.014s  | 3.54Mb   | 0.009s  | 3.83Mb   | +60.9%  | -7.7%   |
| 100       | 0.121s  | 16.70Mb  | 0.065s  | 3.54Mb   | +87.9%  | +371.9% |
| 1,000     | 0.646s  | 33.29Mb  | 0.480s  | 24.73Mb  | +34.7%  | +34.6%  |
| 10,000    | 5.800s  | 163.05Mb | 4.722s  | 143.28Mb | +22.8%  | +13.8%  |
| 100,000   | 57.325s | 1.41Gb   | 45.969s | 1.29Gb   | +24.7%  | +8.9%   |
+-----------+---------+----------+---------+----------+---------+---------+

А далее я распишу результаты человеко-читаемым текстом.

Скорость работы

Первый тест прошел весьма гладко. На начальных этапах Go показывал небольшое преимущество, на количестве корутин от 104 лучше был уже Rust. В обоих случаях разница не превышала 4%. Но, что интересно до 105 корутин была видна четкая тенденция по увеличению отрыва в сторону Rust, однако на количестве в 106 тендеция резко пропала. Хотя Rust при этом все равно быстрее отработал.

Во втором тесте все не так однозначно. При небольшом количестве корутин (по 104) Go показывает более быстрые результаты. На 10 задачах разница даже превышает 109%. С увеличением количества задач эта разница спадает и уже на 105 корутин Rust выбивается вперед. Если во втором тесте можно было говорить, что оба языка прошли более менее одинаково, то здесь видна очевидная разница, зависящая от количества параллельных задач.

А вот при чтении + записи результаты более однозначные. Почти в каждом тесте Rust показал себя быстрее на 40-50%. Кроме 100 корутин, там Go был немного быстрее.

Четвертый тест меня, если честно, сильно удивил - Rust отработал в четыре раза быстрее Go, а на тысяче корутин даже ещё лучше. При этом код написанный на Rust и Go почти не отличается - в обоих случаях использовались одинаковые параметры ЛКГ, одинаковые условия и одинаковые запросы.

Но что меня ещё больше поразило после четвертого теста, так это то, что в пятом тесте Go показывает свое явное доминирование над Rust. У него нет такого сильного отрыва, как в прошлом тесте, но на любом количестве корутин Go отработал быстрее, чем Rust. На 100 корутин разница составляла целых 88%, но с увеличением количества тестов она постепенно снижалась. На 105 задач разница составила 24.7% в сторону Go.

И какой из этого можно сделать вывод? Ну, для начала стоит оговориться, что разницу в 3, 5, даже 10%, учитывая, среду в которой они запускались, вполне можно было бы списать на погрешность. Однако, даже такая небольшая разница в тестах воспроизводится, если запускать его несколько раз, хоть и с небольшим отклонением.

Интересно себя повели языки при работе с базами данных. Мне сложно говорить по какой причине наблюдается настолько огромный отрыв в одном случае, и обратная картина во втором. В обоих случаях для взаимодействия с БД я использовал сторонние библиотеки, и у меня есть подозрение, что они могут быть сделаны не идеально. Ну или я где-то ошибся в своих тестах.

Поэтому вывод я, пожалуй, сделаю такой. Оба языка себя показали хорошо - в некоторых местах Rust был быстрее, в некоторых Go. Вместе с этим в 4 из 5 тестов виден явный тренд, говорящий о том, что при большем количестве задач Rust работает все быстрее и быстрее чем Go. Правда, в наличии такого тренда можно будет убедиться только если запустить тесты на ещё большем количестве корутин - 107, 108 и т.п.

Однако можно говорить о том, что Rust работает с конкурентными задачами не хуже, чем Go. Или по крайней мере не сильно хуже. Оказалось, что все-таки выбранных тестов пока что недостаточно для однозначного ответа. Поэтому я продолжу свое сравнение в следующих статьях.

Потребление памяти

В первом тесте Rust почти везде потреблял сильно меньше памяти. Однако, на 106 задач Go резко выбился вперед и от -56% пришел к +70%. Довольно крутой скачок.

Во втором и третьем тесте результаты схожи чем-то на результаты по скорости. Сначала Go чуть-чуть был получше, но затем Rust выбивается вперед. И в отличии от замеров скорости, в памяти разница значимая.

По поводу SQLite все так же однозначно, как и в замерах скорости - Rust потребляет памяти меньше и с количеством корутин разница только увеличивается.

А MySQL снова показывает Go в лучшем свете. Однако, с увеличением количества корутин, разница снижается от 374% при 100 задачах она приходит к 9% на 105 задачах.

Резюме

Для полноты картины, можно было ещё рассмотреть работу с 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 находятся результаты теста.

Послесловие

В комментариях было описано несколько неплохих советов по оптимизации тестов. Я обязательно опробую все советы и либо обновлю эту статью, либо напишу вторую часть.

Only registered users can participate in poll. Log in, please.
Интересно ли будет посмотреть на сравнение фреймворков?
50.3% Интересно сравнение ORM-фреймворков85
73.96% Интересно сравнение Web-фреймворков125
15.98% Не интересно27
169 users voted. 36 users abstained.
Tags:
Hubs:
Total votes 24: ↑21 and ↓3+20
Comments20

Articles