Стало мне как-то интересно, кто из языков 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)


  1. mayorovp
    19.07.2022 11:50
    +8

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

    Но вы же в таком случае сравниваете производительность канала и вектора, а не Go и Rust! Взяли бы хоть mpsc...


    Впрочем, содержание теста отличается от описания — корутина в Rust вообще ничего никуда не записывает, она возвращает значение. В таком виде тест имеет смысл: в Go горутина не может вернуть значение иначе чем через канал, так что аналог справедлив, но меня настораживает тот факт, что канал в Go используется всего 1.


    1. Yoskutik Автор
      19.07.2022 12:21

      Я тоже об этом думал. Однако, мне показалось, что на миллионе каналов программе может стать дурно. К тому же представить реальную задачу, где требовалось бы миллион каналов, мне тяжело. Поэтому я просто выбрал способ, где данные в принципе сохранялись бы, любым доступным способом


      1. funny_falcon
        19.07.2022 16:27

        Так почему тогда в Go не в слайс "писать"?


        1. Yoskutik Автор
          19.07.2022 16:48
          +1

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


      1. QtRoS
        20.07.2022 01:04

        А канал буферизованный? Если нет, то из него могли не успевать читать...


        1. Yoskutik Автор
          20.07.2022 17:44
          +1

          Я обновил статью. Теперь канал в тестах используется буферизованный


    1. Yoskutik Автор
      20.07.2022 17:42
      +1

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


      1. Vadim_Aleks
        20.07.2022 22:41

        Не понял зачем использовать N каналов. Ведь можно сделать один канал с буфером N, чтобы не блочиться :) тогда должно стать ещё быстрее

        Я бы закинул PR, но на маке (подозреваю и на линуксе) скрипт run не запускается из-за множества ошибок в путях. Было бы полезно, если бы его можно было ранить везде


        1. Yoskutik Автор
          20.07.2022 23:07

          Если представить, что каждая корутина - это задача, выделенная под запрос пользователя, то логично ожидать, что и каналов будет много. Так что условие с N каналов мне показалось более реалистичным.

          А по поводу закуска везде... Ну, можно, конечно. Но кода на Python в репозитории и так уже больше всего)

          Хотя, если дополнить репозиторий парой тестов и расширить проверку на большем количестве рутин, можно будет говорить, что бенчмарк можно использовать раз в полгода-год и смотреть динамику. Тогда и на разные ОС можно будет расширить код.


  1. godzie
    19.07.2022 12:02
    +2

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

    В четвертом тесте дело, скорее всего, в cgo. Компилятор GO генерирует значительное кол-во кода вокруг сишных вызовов, в раст zero cost ffi т.к. abi совместимы.

    За пятый тест сказать не могу, но там мы с базой общаемся через сокет, так что ситуация кардинально другая.


    1. Revertis
      19.07.2022 22:12
      -11

      Не "за пятый тест", а "о пятом тесте" или "про пятый тест". Учитесь говорить правильно.


  1. 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()
    


    1. Yoskutik Автор
      19.07.2022 12:13

      Интересное замечание. Я попробую запустить тест с этой правкой


  1. Vadim_Aleks
    19.07.2022 13:14
    +3

    Попробуйте сделать буфер у канала в коде Go. Например:

    ch := make(chan float64, n)

    Должно стать заметно быстрее на больших объемах :)

    В раст реализации попробуйте не ждать в цикле, а использовать join_all


    1. Yoskutik Автор
      19.07.2022 13:14

      Да, спасибо. Обязательно попробую


  1. inklesspen
    19.07.2022 14:30
    +2

    Крайне плохая идея использовать асинхронщину в задачах с упором в процессор. Сами разработчики не рекомендуют (секция "When not to use tokio") использовать в подобных задачах tokio, т.к. он спроектирован для решения проблем, завязанных на вводе-выводе. В качестве альтернативы предлагается крейт rayon (который, к слову, тоже использует модель задач, не порождая бесчисленное количество потоков).


    1. Yoskutik Автор
      19.07.2022 14:55

      Все верно. Первый тест является эмулирующим. И задержка как раз эмулирует ожидание при вводе-выводе. Как и тесты с SQL. Можно представить, что у нас есть веб сервер, где миллион пользователей одновременно пытаются что-то сделать. Так что по мне так tokio здесь очень даже к месту. Если не согласны, распишите, пожалуйста, подробнее почему


      1. inklesspen
        19.07.2022 15:11
        +1

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

        По поводу памяти: асинхронные задачи в расте представляют собой структуру, в которую была преобразована асинхронная функция (или была явно реализована структура с трейтом Future), в Go же корутины имеют свой собственный контекст (~4.4KiB по словам знакомого разработчика на Go).

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