Стало мне как-то интересно, кто из языков 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 находятся результаты теста.
Послесловие
В комментариях было описано несколько неплохих советов по оптимизации тестов. Я обязательно опробую все советы и либо обновлю эту статью, либо напишу вторую часть.
Комментарии (18)
godzie
19.07.2022 12:02+2Интересно себя повели языки при работе с базами данных. Мне сложно говорить по какой причине наблюдается настолько огромный отрыв в одном случае, и обратная картина во втором.
В четвертом тесте дело, скорее всего, в cgo. Компилятор GO генерирует значительное кол-во кода вокруг сишных вызовов, в раст zero cost ffi т.к. abi совместимы.
За пятый тест сказать не могу, но там мы с базой общаемся через сокет, так что ситуация кардинально другая.
Revertis
19.07.2022 22:12-11Не "за пятый тест", а "о пятом тесте" или "про пятый тест". Учитесь говорить правильно.
falconandy
19.07.2022 12:10В четвертом тесте основные тормоза — это вроде как update. Если не делать одновременный update (в случае sqlite вроде бы так и рекомендуется, по крайне мере с настройками по-умолчанию), то работает в разы быстрее. Что-то типа:
var mu sync.Mutex func routine(db *sql.DB, age, id int, ch chan<- string) { if age%2 == 0 { queryUpdate := fmt.Sprintf("UPDATE users SET salary = salary + 1 WHERE id = %d", id) mu.Lock() db.Exec(queryUpdate) mu.Unlock()
Vadim_Aleks
19.07.2022 13:14+3Попробуйте сделать буфер у канала в коде Go. Например:
ch := make(chan float64, n)
Должно стать заметно быстрее на больших объемах :)
В раст реализации попробуйте не ждать в цикле, а использовать join_all
inklesspen
19.07.2022 14:30+2Крайне плохая идея использовать асинхронщину в задачах с упором в процессор. Сами разработчики не рекомендуют (секция "When not to use tokio") использовать в подобных задачах tokio, т.к. он спроектирован для решения проблем, завязанных на вводе-выводе. В качестве альтернативы предлагается крейт rayon (который, к слову, тоже использует модель задач, не порождая бесчисленное количество потоков).
Yoskutik Автор
19.07.2022 14:55Все верно. Первый тест является эмулирующим. И задержка как раз эмулирует ожидание при вводе-выводе. Как и тесты с SQL. Можно представить, что у нас есть веб сервер, где миллион пользователей одновременно пытаются что-то сделать. Так что по мне так tokio здесь очень даже к месту. Если не согласны, распишите, пожалуйста, подробнее почему
inklesspen
19.07.2022 15:11+1Действительно, мне стоило сначала статью прочитать, прежде чем что-то писать. Здесь нет упора в процессор (о которой я думал, читая про конкурентность), а как раз IO-нагрузка. Моя ошибка, прошу прощения.
По поводу памяти: асинхронные задачи в расте представляют собой структуру, в которую была преобразована асинхронная функция (или была явно реализована структура с трейтом Future), в Go же корутины имеют свой собственный контекст (~4.4KiB по словам знакомого разработчика на Go).
Но что случилось в пятом тесте - мне не ясно. Я бы предположил, что в предыдущих тестах выполняются блокирующие сишные вызовы, из-за чего создается больше потоков, чем обычно. Но "спать"-то go должен уметь, а значит в первом тесте такой проблемы нет.
mayorovp
Но вы же в таком случае сравниваете производительность канала и вектора, а не Go и Rust! Взяли бы хоть mpsc...
Впрочем, содержание теста отличается от описания — корутина в Rust вообще ничего никуда не записывает, она возвращает значение. В таком виде тест имеет смысл: в Go горутина не может вернуть значение иначе чем через канал, так что аналог справедлив, но меня настораживает тот факт, что канал в Go используется всего 1.
Yoskutik Автор
Я тоже об этом думал. Однако, мне показалось, что на миллионе каналов программе может стать дурно. К тому же представить реальную задачу, где требовалось бы миллион каналов, мне тяжело. Поэтому я просто выбрал способ, где данные в принципе сохранялись бы, любым доступным способом
funny_falcon
Так почему тогда в Go не в слайс "писать"?
Yoskutik Автор
По всей видимости мое описание тестов вводит людей в заблуждение. Имелось в виду, что канал использовался для передачи информации от одной горутины к другой, а в Rust асинхронные функции просто возвращали значение. В конечном итоге значения и в Rust и в Go записывались в вектор и массив. Я дополню свою статью
QtRoS
А канал буферизованный? Если нет, то из него могли не успевать читать...
Yoskutik Автор
Я обновил статью. Теперь канал в тестах используется буферизованный
Yoskutik Автор
Знаете, ваши опасения были не напрасны. Результаты изменились не катастрофически но значимо. Rust начал выглядеть не настолько крутым языком, а скорее как примерно такой же.
Vadim_Aleks
Не понял зачем использовать N каналов. Ведь можно сделать один канал с буфером N, чтобы не блочиться :) тогда должно стать ещё быстрее
Я бы закинул PR, но на маке (подозреваю и на линуксе) скрипт run не запускается из-за множества ошибок в путях. Было бы полезно, если бы его можно было ранить везде
Yoskutik Автор
Если представить, что каждая корутина - это задача, выделенная под запрос пользователя, то логично ожидать, что и каналов будет много. Так что условие с N каналов мне показалось более реалистичным.
А по поводу закуска везде... Ну, можно, конечно. Но кода на Python в репозитории и так уже больше всего)
Хотя, если дополнить репозиторий парой тестов и расширить проверку на большем количестве рутин, можно будет говорить, что бенчмарк можно использовать раз в полгода-год и смотреть динамику. Тогда и на разные ОС можно будет расширить код.