Наблюдение за выполнением конкурирующих задач в Go и Rust
Предисловие
Эта статья представляет собой что-то вроде курсовой работы, которую автор не поленился сделать, изучая одновременно Go и Rust. Сильной стороной обоих языков программирования считается удачно реализованная поддержка конкурентности, во всяком случае, редкий обозреватель обходит эту возможность вниманием. Прочитав несколько довольно подробных теоретических описаний и руководств по разработке приложений с конкурентностью на языках Go и Rust, я решил дополнить их несложным количественным экспериментом и поделиться его результатами.
Все обсуждаемые здесь измерения проведены на единственной системе с более или менее случайными характеристиками. Хотя она довольно типична, то есть, не слишком хороша и не слишком плоха, выполненное в таком объеме исследование заведомо не претендует на полноту. Заинтересованный читатель может повторить его в любой подходящей среде, загрузив исходный код с GitHub (ссылка на репозиторий приведена в конце).
Наконец, не открыв, по-видимому, ничего сенсационного, автор все же надеется, что его статья принесет пользу начинающим разработчикам, а также инженерам и ученым, которые пишут программы для собственных нужд.
Go и Rust: кто более горазд?
Go и Rust — два относительно молодых языка программирования, хлебных, модных и продолжающих набирать популярность. Их часто сравнивают между собой, видя в каждом из них более современную и комфортную для программиста альтернативу компилируемым языкам предыдущих поколений. У этих языков и правда много общего: похожий синтаксис, отказ от классических средств объектно-ориентированного программирования в пользу других путей борьбы со сложностью, развитая экосистема библиотек, возможность кроссплатформенной компиляции. Получается, что если разработчик не хочет зависеть от исполнительных сред, но пасует перед Cи с плюсами или без, то Go или Rust — его выбор.
Спору нет, не так все жестко. По-прежнему доступны разные реализации языка Паскаль, наверняка найдутся люди, которые, как автор этих строк, его помнят и любят. Допускающие компиляцию языки Lua, Scala, Erlang и некоторые другие из числа более или менее известных имеют преданных поклонников, иначе мы бы о них сейчас не вспомнили. Однако многие люди тянутся к популярному, и не без оснований, а Go и Rust сейчас оказались в центре внимания. Вдобавок владение этими языками повышает рыночную стоимость разработчика, а что вы будете иметь с того Паскаля?
Все эти соображения, скорее, жизненные, чем технические, приводят Go и Rust к соперничеству, даже если их создатели не имели в виду ничего подобного. Погуглите фразу “Go vs. Rust”, улов будет обильным. Ну, и мы туда же: в этой статье приведены результаты попытки сопоставить порядок выполнения конкурентного кода программами, написанным на каждом из этих языков.
Конкурентное выполнение кода
Конкурентность и параллельность
Предположим, у нас есть два вычислительных устройства и две задачи, которые могут быть решены независимо друг от друга. Если мы запустим каждую задачу на своем вычислительном устройстве, то сэкономим время: вместо суммарной длительности отделаемся наибольшей. Такое выполнение задач обычно называют параллельным.
Теперь представим себе язык программирования, который позволяет на уровне логики исходного кода описывать задачи как выполняемые параллельно. Такой исходный код будем называть конкурентным, а задачи — конкурирующими.
Средства разработки, поддерживающие конкурентный код, позволяют программисту абстрагироваться от конфигурации конкретных вычислительных систем. На одном компьютере окажется восемь процессорных ядер, а на другом четыре, как распределить по ним сорок конкурирующих задач? Переложим эту заботу на плечи транслятора и среды времени выполнения, программиста же поместим в идеальный мир, где ресурсы плодятся, как кролики, и каждая конкурирующая задача получает свое ядро. В чистом виде столь наивный взгляд на конкурентность приводил бы ко многим неожиданностям в поведении программ, поэтому, работая с конкурентностью, мы должны хорошо понимать, как именно она превращается в параллельность. Ответ на этот вопрос зависит от технических характеристик вычислительной системы и особенностей используемых нами средств разработки. Конкретнее, здесь в отношении языков Go и Rust нас будет интересовать следующее.
Сколько времени экономит конкурентность?
Каким перерасходом ресурсов мы за нее платим?
В каком порядке выполняются конкурирующие задачи?
Расшифруем последний вопрос. Если количество вычислительных устройств в системе меньше количества задач, то каким-то задачам придется ждать. Ожидание может быть организовано по-разному, что хорошо нам знакомо по разным бытовым ситуациям. Например, если в парикмахерской работает три мастера, а пришло десять клиентов, то сначала постригут первых трех, а потом четвертый клиент займет первое освободившееся кресло. Если в ресторане есть три официанта, и занято десять столиков, то всех гостей будут обслуживать как бы одновременно: сначала выдадут каждому меню, потом у всех примут заказы и т. д. Что именно происходит с конкурирующими задачами при их реализации с помощью Go и Rust?
Дальше мы сформулируем эти вопросы более формально.
Способы использования конкурентности
Здесь мы займемся изучением самого простого случая конкурентности, в котором независимые друг от друга конкурирующие задачи выполняются настолько параллельно, насколько позволяет система. Сначала мы их все запускаем, а потом ждем, когда они закончат работу. В реальности похожим образом могут работать вычислительные задачи, индексирование разных источников, обслуживание поступающих извне запросов.
Мы не рассматриваем более сложно организованные программы, например такие, в которых конкурирующие задачи образуют конвейер: первая загружает исходные файлы, вторая выполняет препроцессинг, третья — лексический анализ и т. д.
Мы оставляем в стороне ситуацию, когда конкурирующие задачи обеспечивают существенно разную функциональность и поэтому создают разную по профилю нагрузку на систему: одна играет с пользователем в покер, вторая показывает ему рекламу, а третья майнит биткоины.
Мы также не обсуждаем зависимость конкурирующих задач от сторонних ресурсов и разного рода узких мест: все задачи одновременно ринулись скачивать большие файлы по плохому каналу.
Реальность наполнена всем перечисленным, но сейчас мы проводим лабораторный опыт, касающийся лишь времени и расписания выполнения конкурирующих задач. Конечно, водитель должен уметь перестроиться в пробке по гололеду между камазом и майбахом, но теперь мы выезжаем ночью на пустую дорогу «и газ до отказа, а там поглядим».
Средства запуска конкурентного кода в Go и Rust
Приведем необходимый минимум сведений на эту тему, чтобы помочь начинающим пользователям Go и Rust быстрее сориентироваться. Если вам уже приходилось писать конкурентный код на этих языках, то можно пропустить этот раздел.
Уровни реализации конкурентности
Нам придется иметь дело с двумя уровнями реализации конкурентности: как она сделана на уровне языка, и как она сделана на уровне скомпилированного кода и среды времени выполнения. Первое — субъективная сторона дела, второе — объективная. Какие бы волнующие абстракции и фирменные названия ни преподносили нам разработчики языка, чуда не произойдет. В любом случае все сведется к решению одних и тех же проблем выполнения программного кода при ресурсных ограничениях, накладываемых конкретной системой.
Конкурентность в Go
Горутина1 (goroutine) — основное понятие, которым оперирует программист, работающий с конкурентным кодом на языке Go. Все действия, выполняемые программой, происходят в рамках определенной горутины. Даже если мы написали код, в котором нет никакой конкурентности, а есть только функция main и, возможно, другие функции, которые она использует, выполняться все они будут в горутине. Хотя мы можем об этом не задумываться.
Если мы хотим запустить какой-то код на выполнение в конкурентном режиме и пойти дальше, не дожидаясь его завершения, мы помещаем его в другую горутину явным образом. Для запуска конкурирующих горутин в языке Go предусмотрен специальный оператор, который так и называется: go. Запуск двух конкурирующих горутин может выглядеть, например, так.
// Запускаем конкурирующую горутину
go sing_the_song(song_id)
// Запустили еще одну конкурирующую горутину
go tell_the_story(story_id)
Что произойдет, если в приведенном примере вызвавшая горутина завершится раньше вызванных? Будет плохо, если песня и сказка оборвутся на полуслове. Go позволяет защититься от такого, поставив ждуна. Точнее, целую команду ждунов.
var zhdun sync.WaitGroup
zhdun.Add(2) // добавляем двух ждунов
go func() {
sing_the_song(song_id)
zhdun.Done() // первый дождался
}()
go func() {
tell_the_story(story_id)
zhdun.Done() // второй дождался
}()
zhdun.Wait() // ждут и не пускают горутину дальше
Выделит ли компилятор Go каждой горутине отдельный поток? Нет, он распределит все горутины по тому количеству потоков, которое сочтет нужным использовать. Дальше мы увидим, к чему такой подход приводит.
Конкурентность в Rust
На Rust конкурентный код организуют с помощью предназначенных для этого крейтов, так в этом языке называются библиотеки. Таким образом, программист не приговорен к единственному способу распараллеливать работу своего приложения. Он волен выбрать наиболее подходящий крейт по своей ситуации, сочетать несколько крейтов или даже написать собственный. Эти крейты могут показывать разную эффективность (что бы это слово ни значило) при конкурентном выполнении одного и того же кода, следовательно, подбор крейтов для конкретного приложения — отдельная тема. Здесь мы воспользуемся наиболее общими приемами, подробно описанными в документации, в книгах и, конечно, на StackOverflow. Вызовы, аналогичные показанным в первом примере, на языке Rust будут выглядеть так.
// Запускаем конкурирующую задачу
spawn(|| {sing_the_song(song_id)};
// Запустили еще одну конкурирующую горутину
spawn(|| {tell_the_story(story_id)};
Функция spawn запускает переданный ей код конкурентно вызывающему. Ее аргумент — замыкание, которое и будет вызвано в конкурентном режиме. Между двумя вертикальными чертами находится список аргументов замыкания. В данном случае этот список пуст.
Теперь научимся дожидаться завершения всех конкурирующих задач, и только потом завершать ту задачу, которая их запустила.
crossbeam::scope(|spawner| {
spawner.spawn(|| {sing_the_song(song_id)};
spawner.spawn(|| {tell_the_story(story_id)};
});
Выполнение функции crossbeam::scope
закончится только и сразу после того, как закончится выполнение всех конкурирующих задач.
В отличие от оператора go функция spawn запускает переданный ей код в отдельном потоке операционной системы.
Папа тоже человек
Могло сложиться впечатление, что дочерние задачи выполняются конкурентно, тогда как основная программа действует в каком-то особом порядке. На самом деле она представляет собой такую же горутину или такой же поток, как дочерние, и конкурирует с ними за ресурсы системы. Поэтому выполняющиеся конкурирующие задачи могут тормозить запуск следующих конкурирующих задач.
Экспериментальная часть
Постановка задачи
В экспериментальной части работы мы постараемся ответить на следующие вопросы.
Насколько конкурентность сокращает время выполнения кода?
Во что обходится системе конкурентное выполнение кода?
Каким образом конкурирующие задачи делят ресурс системы?
Раскроем эти формулировки и заодно начнем обсуждать методику исследования.
Длительность элементарной задачи. За единицу примем время однократного решения некоторой типовой задачи в условиях минимума помех. Говоря о минимуме помех, будем иметь в виду, прежде всего, отсутствие конкурирующих задач в том же процессе. Понятно, что рабочая система никогда не стерильна, в ней могут происходить разные процессы, в том числе, ресурсоемкие: отрисовка реалистичных трехмерных сцен, восстановление облика ископаемого чудища по фрагменту генома, регистрация предприятия на портале госзакупок и т. п. Не будем бороться за лабораторную чистоту, просто воздержимся от подобных занятий во время нашего опыта. Выполнение выбранной задачи в таких условно идеальных условиях будем называть элементарной задачей.
Длительность последовательного выполнения. Выполним элементарную задачу последовательно N раз. Такое выполнение далее будем называть последовательным, а затраченное время — от старта первой задачи до завершения последней — последовательной длительностью. Согласимся с тем, что для измерения последовательной длительности достаточно выполнить элементарную задачу однократно, а затем умножить длительность ее выполнения на N. Иначе нам придется проводить такое измерение на самом деле, а это долго.
Выигрыш по времени. Выполним элементарную задачу конкурентно N раз в системе с M вычислительными устройствами, причем N = kM, где k — натуральное число. Потребует ли это в M раз меньше времени, чем последовательное выполнение? Если нет (сложно рассчитывать на идеальный случай), то сколько времени будет потеряно? Какой при этом получится средняя длительность выполнения отдельной задачи?
Вычтем измеренную длительность конкурентного выполнения N задач из времени последовательного выполнения N задач. Вычислим отношение этой разности к длительности последовательного выполнения, выразим его в процентах и обозначим буквой P (от слова profit). Полученный результат будет означать, конкурентность помогла нам справиться с N задачами на P процентов быстрее, чем мы справились бы без нее. Будем называть это значение нашим выигрышем.
Цена конкурентности. Вычислим суммарную длительность выполнения всех N задач при их конкурентном выполнении. Вычтем из этого значения длительность последовательного выполнения N элементарных задач. Полученную величину назовем ценой конкурентности. Цену конкурентности будем также выражать в процентах по отношению к фактической суммарной длительности. Образно говоря, цена конкурентности — это количество суеты, которую мы устраиваем ради параллелизма.
Расписание задач. Выше мы уже касались этой темы. N задач на M устройствах можно обслуживать как гостей в ресторане, как клиентов в парикмахерской или как-нибудь еще. Что происходит в действительности? Для ответа на этот вопрос у нас не будет количественных показателей, ограничимся качественным описанием. Для каждой конкурирующей задачи засечем время старта и время финиша. По этим данным построим диаграмму Ганта.
Методика
Средства проведения измерений
На каждом из языков, Go и Rust, напишем программу со следующими функциональными возможностями:
замер количества доступных программе процессорных ядер;
замер количества итераций элементарной задачи в минуту;
выполнение эксперимента с заданным количеством итераций.
Реализуем в обеих программах возможность выполнения конкурирующих задач сериями заданной длины. Например, если программа должна выполнить 40 задач сериями по 8, то сначала она выполняет первые 8, после их завершения вторые 8, и так все пять серий. Запуск конкурирующих задач сериями позволит посмотреть, что произойдет, если мы начнем искусственно регулировать расписание запуска конкурирующих задач, не позволяя им отнимать друг у друга ресурс. Вдруг получится быстрее, чем при стихийной конкуренции?
Элементарная задача
В качестве элементарной выберем задачу, решение которой занимает ощутимое время, не требуя обращений ко внешним ресурсам, способным непредсказуемо тормозить выполнение кода. Подопытный кролик должен быть по возможности «аутичным», например, каким-нибудь трудоемким вычислением.
Слишком простая задача с заведомо предсказуемым результатом может быть выброшена оптимизирующим компилятором из машинного кода. Получив подозрительно высокую скорость вычислений, сложно будет как отделаться от ощущения, что это случилось, так и надежно проверить такую гипотезу. Ради нашего спокойствия пусть результат вычислений будет плохо или вовсе непредсказуем. Мы займемся расчетом значения n-го члена некоторой возвратной последовательности.
Оптимизирующий компилятор вправе игнорировать код, результаты работы которого заведомо не используются. Поэтому, пытаясь нагрузить систему вычислениями, мы вынуждены как-то обрабатывать их результаты. Добавим же к нашему основному еще небольшой математический эксперимент: поищем, сходится ли наша возвратная последовательность при каком-нибудь наборе начальных членов. Если таковые найдутся, то выведем их на экран вместе с приближенно вычисленным пределом и номером шага, на котором мы подошли к нему достаточно близко.
Выберем возвратную последовательность, которая заведомо не ввергнет нашу программу в ошибку переполнения. Числа Фибоначчи не годятся, потому что переполнят переменную типа float64 чуть менее чем на полуторатысячном шаге. Нежелательны и последовательности, стремящиеся к нулю: рано или поздно они стабилизируются, а переход программы к обмолоту сплошных нулей может исказить результаты измерений.
Нужна последовательность, которая то возрастает, то убывает, не достигая слишком больших или малых абсолютных значений. Возьмем такую последовательность:
An = An-1 + An-2 – An-3 , если |An-1 + An-2 – An-3| < 1,
а иначе 1/(An-1+An-2 – An-3).
В качестве начальных членов будем получать три случайных действительных числа из открытого интервала (0; 1). Читатель сам легко убедится, что эта последовательность защищает нас от переполнения на любом шаге.
Учитывая, что код, порожденный разными компиляторами, может развивать разную скорость вычислений, не будем требовать, чтобы для Go и Rust элементарная задача совершала одинаковое количество итераций. Вместо этого условимся, что выполнение элементарной задачи должно длиться примерно одну секунду. Перед экспериментом будем замерять примерное количество членов возвратной последовательности которые можно получить за секунду каждым вариантом программы. Это количество будем передавать программе в качестве одного из аргументов.
Порядок проведения эксперимента
Каждый эксперимент будет состоять из T наблюдений, где T — натуральное число.
В каждом наблюдении элементарная задача выполняется t раз. За весь эксперимент параметр t пробегает все натуральные значения от 1 до T. Таким образом, в первом наблюдении программа выполняет задачу однократно, во втором — дважды и так далее, а в последнем наблюдении — T раз.
Для каждой задачи в наблюдении будем измерять или вычислять значения приведенных ниже показателей.
Показатель | Ед. изм. | Способ вычисления |
---|---|---|
Номер задачи | — | Порядковый номер задачи при запуске |
Время старта | мс | Запрос таймстемпа в миллисекундах непосредственно перед запуском задачи. Время старта перерассчитывается относительно времени старта задачи, которая стартовала первой (у нее самой время старта всегда равно нулю) |
Время завершения | мс | Запрос таймстемпа в миллисекундах непосредственно после завершения задачи. Время завершения перерассчитывается относительно времени старта задачи, которая стартовала первой |
Длительность | мс | Разность времени завершения и времени старта задачи |
В каждом наблюдении будем измерять (или вычислять на основе непосредственных измерений) значения перечисленных ниже сводных показателей.
Показатель | Ед. изм. | Способ вычисления |
---|---|---|
Средняя длительность выполнения задачи | мс | Сумма(di)/t, i от 1 до t, |
Время время выполнения всех задач в конкурентном режиме | мс | mпф – mпс, |
Цена конкурентности | % | 1 – tdэл./Сумма(di), i от 1 до t, |
Выгода конкурентности | % | 1 – (mпф – mпс)/tdэл., |
Результаты измерений
Характеристики системы
Здесь приведены результаты эксперимента, выполненного в системе со следующими техническими характеристиками:
процессор: AMD Ryzen 7 PRO 2,30 ГГц;
количество ядер: 8;
оперативная память: 16 ГБ;
операционная система: Windows 10 Prof.
Результаты измерений для программы на Go
Версия компилятора Go: go1.14.2.
Количество итераций элементарной задачи в секунду: 44 млн.
Зависимость измеренных значений показателей от количества конкурирующих задач показана в табл. 1.
Таблица 1. Данные измерений для программы на Go
Кол-во задач | Средняя | Общая длит., мс | Стоимость конкурентности | Выгода конкурентности |
---|---|---|---|---|
1 | 1208 | 1208 | 0% | 0% |
2 | 1335 | 1476 | 10% | 39% |
3 | 1249 | 1339 | 3% | 63% |
4 | 1251 | 1262 | 3% | 74% |
5 | 1115 | 1125 | -8% | 81% |
6 | 1105 | 1115 | -9% | 85% |
7 | 1123 | 1134 | -8% | 87% |
8 | 1224 | 1306 | 1% | 86% |
9 | 1311 | 1403 | 8% | 87% |
10 | 1445 | 1584 | 16% | 87% |
11 | 1608 | 1711 | 25% | 87% |
12 | 1711 | 1844 | 29% | 87% |
13 | 1862 | 1969 | 35% | 87% |
14 | 2014 | 2116 | 40% | 87% |
15 | 2136 | 2398 | 43% | 87% |
16 | 2347 | 2531 | 49% | 87% |
17 | 2355 | 2633 | 49% | 87% |
18 | 2401 | 2787 | 50% | 87% |
19 | 2712 | 2910 | 55% | 87% |
20 | 2923 | 3091 | 59% | 87% |
21 | 2929 | 3187 | 59% | 87% |
22 | 3120 | 3331 | 61% | 87% |
23 | 3146 | 3504 | 62% | 87% |
24 | 3315 | 3724 | 64% | 87% |
25 | 3412 | 3908 | 65% | 87% |
26 | 3756 | 4052 | 68% | 87% |
27 | 3793 | 4225 | 68% | 87% |
28 | 3833 | 4298 | 68% | 87% |
29 | 4132 | 4427 | 71% | 87% |
30 | 4224 | 4558 | 71% | 87% |
31 | 4308 | 4700 | 72% | 87% |
32 | 4454 | 4902 | 73% | 87% |
33 | 4654 | 4991 | 74% | 87% |
34 | 4686 | 5198 | 74% | 87% |
35 | 4874 | 5316 | 75% | 87% |
36 | 4914 | 5541 | 75% | 87% |
37 | 5037 | 5660 | 76% | 87% |
38 | 3736 | 6046 | 68% | 87% |
39 | 5231 | 6932 | 77% | 85% |
40 | 5015 | 7028 | 76% | 85% |
Зависимость средней длительности выполнения одной задачи и общей длительности наблюдения от количества конкурирующих задач показана на рис. 1.
Расписание выполнения для 40 конкурирующих задач показано на рис. 2.
Основную часть времени выполнения программа на Go использовала для работы 9–11 потоков вне зависимости от количества конкурирующих задач.
Результаты измерений для программы на Rust
Версия компилятора Rust: 1.57.0.
Количество итераций элементарной задачи в секунду: 367 млн.
Зависимость измеренных значений показателей от количества конкурирующих задач показана в табл. 2.
Таблица 2. Данные измерений для программы на Rust
Кол-во задач | Средняя | Общая длит., мс | Стоимость конкурентности | Выгода конкурентности |
---|---|---|---|---|
1 | 835 | 835 | 0% | 0% |
2 | 845 | 888 | 1% | 47% |
3 | 968 | 1034 | 14% | 59% |
4 | 937 | 971 | 11% | 71% |
5 | 918 | 952 | 9% | 77% |
6 | 979 | 1008 | 15% | 80% |
7 | 1097 | 1122 | 24% | 81% |
8 | 1233 | 1256 | 32% | 81% |
9 | 1335 | 1426 | 37% | 81% |
10 | 1438 | 1594 | 42% | 81% |
11 | 1594 | 1752 | 48% | 81% |
12 | 1804 | 1894 | 54% | 81% |
13 | 1942 | 2124 | 57% | 80% |
14 | 2055 | 2257 | 59% | 81% |
15 | 2213 | 2370 | 62% | 81% |
16 | 2308 | 2569 | 64% | 81% |
17 | 2413 | 2688 | 65% | 81% |
18 | 2492 | 2869 | 66% | 81% |
19 | 2678 | 3096 | 69% | 80% |
20 | 3042 | 3123 | 73% | 81% |
21 | 2808 | 3383 | 70% | 81% |
22 | 3061 | 3530 | 73% | 81% |
23 | 3071 | 3760 | 73% | 80% |
24 | 3593 | 3859 | 77% | 81% |
25 | 3104 | 4043 | 73% | 81% |
26 | 3226 | 4227 | 74% | 81% |
27 | 3348 | 4360 | 75% | 81% |
28 | 3285 | 4518 | 75% | 81% |
29 | 3295 | 4655 | 75% | 81% |
30 | 4478 | 4783 | 81% | 81% |
31 | 3709 | 4984 | 77% | 81% |
32 | 3781 | 5202 | 78% | 81% |
33 | 3850 | 5683 | 78% | 79% |
34 | 3954 | 5484 | 79% | 81% |
35 | 3362 | 5716 | 75% | 80% |
36 | 3714 | 5772 | 78% | 81% |
37 | 3769 | 6021 | 78% | 81% |
38 | 3863 | 6126 | 78% | 81% |
39 | 3764 | 6287 | 78% | 81% |
40 | 3718 | 6479 | 78% | 81% |
Зависимость средней длительности выполнения одной задачи и общей длительности наблюдения от количества конкурирующих задач показана на рис. 3.
Расписание выполнения для 40 конкурирующих задач показано на рис. 4.
Количество потоков увеличивалось с увеличением количества конкурирующих задач и на пике достигало t+1, где t — количество конкурирующих задач в наблюдении.
Глядя на расписание выполнения конкурирующих задач, можно предположить, что при достаточно большом количестве они начинают задерживать друг друга, примерно как автомобили в пробке. Попробуем сократить время наблюдения, запуская их сериями по восемь (рис. 4a).
Интересно, что эта попытка не принесла никакого выигрыша. Наблюдение требует столько же времени, сколько при стихийном запуске задач.
Сравнение
Программа на Rust показала намного большую производительность при вычислении членов возвратной последовательности, чем программа на Go: 367 млн. итераций в секунду против 44 млн. Обращаем внимание на этот факт, но не беремся делать из него глубокие выводы, поскольку сравнение производительности программ, написанных на этих языках, не входило в задачи исследования.
По-видимому, выгода от использования конкурентности у программ на Go и Rust получается примерно одинаковой. Обе программы сэкономили на конкурентности примерно 80% времени по сравнению с последовательным выполнение задач.
По-видимому, совпадают и издержки на обеспечение конкурентности. При наиболее высокой (в рамках эксперимента) нагрузке примерно 25% времени ушло на дело, а примерно 75% на суету. Возможно, я неверно толкую измеренные значения этого показателя.
Существенно разным получается расписание выполнения конкурирующих задач на Go и Rust. Можно предположить, что программа на Go индивидуально определяет время запуска каждой конкурирующей задачи (последняя по порядку задача стартовала одновременно с первой), тогда как программа на Rust запускает их последовательно, так сказать, в естественном порядке.
В обоих случаях общая длительность наблюдения возрастала линейно, но лишь после того, как исчерпались свободные процессорные ядра. Можно было бы ожидать, что длительность наблюдения и дальше будет возрастать «ступенями», в данном случае каждые восемь задач могли бы давать скачок, но нет (рис. 5).
Средняя длительность выполнения задачи в программе на Go росла линейно по отношению к количеству конкурирующих задач. Рост длительности выполнения задачи в программе на Rust замедлился примерно после 20-й задачи в наблюдении (рис. 6).
Выводы
Поддержка конкурентности — возможность языка программирования, которая позволяет программисту воображать и описывать задачи как выполняемые одновременно. Если количество конкурирующих задач не превосходит количество вычислительных устройств, примерно так и получается. При дальнейшем росте количества конкурирующих задач расписание их выполнения становится плохо предсказуемым, по крайней мере, элементарные арифметические и соображения и бытовые аналогии перестают работать. Так, на восьми процессорных ядрах программа на Go выполнила 40 конкурирующих задач примерно в 5,8 раза быстрее, чем выполнила бы одну задачу. Для программы на Rust этот показатель составил 7,7 раза. Вместе с тем, при меньшем выигрыше по совокупному времени конкурирующие задачи на Go выглядят «более одновременными», чем на Rust.
Сравнивая полученные расписания, можно предположить, что программа на Go стремится запустить все конкурирующие задачи как можно раньше. Это кажется оправданным, если каждая задача действует примерно так: отправить запрос по сети, дождаться ответа, обработать ответ, отправить следующий запрос и т. д. Чем раньше мы отправим запрос, тем быстрее получим ответ и продолжим работу, а ожидание как таковое не требует высокой производительности. Возвращаясь к бытовым аналогиям, программа на Go действует, как гражданин при общении с бюрократами: торопить их бесполезно, но надо пораньше подать документы.
Программа на Rust, по-видимому, напротив, стремится сделать работу как можно быстрее собственными силами.
Еще одно важное различие между Go и Rust, на которое мне указал внимательный читатель2 первой версии этой статьи, заключается в том, что разработчики языка Rust в принципе отказались от использования сборщика мусора. Можно предположить, что это делает расписание запуска конкурирующих задач в Rust более предсказуемым, потому что сборщик мусора не может в него вклиниваться. Это же качество позволяет рассматривать Rust в качестве языка разработки систем реального времени.
Отмеченные различия хорошо согласуются с разным назначением этих языков: Go в основном предназначен для разработки скриптов и сетевых утилит, а Rust — для системного программирования и вычислений. Если так, то сходство их оказывается поверхностным, а выбор между ними очевидным. Но это, конечно, не очень строгие и довольно субъективные соображения.
Исходные тексты программ доступны по адресу https://github.com/aka-author/conctest.
(1) В некоторых переводах англоязычной литературы по языку Go пишут go-подпрограмма.
(2) Автор благодарит Николая Демидова (компания «Обоз») за ценное замечание.