У сервиса онлайн-обучения программированию Хекслет есть собственный Open Source проект Code Basics, на котором выходят бесплатные курсы для начинающих на разных языках. Когда-то их было только два – PHP и JavaScript, сейчас уже более десятка. В компании Dodo Engeneering есть хорошая экспертиза по C#, поэтому мы вместе решили сделать курс с тренажером для тех, кто хочет начать изучение программирования именно с него. Во время написания курса оказалось, что из-за долгого холодного старта и ограничений учебной платформы все решения студентов падают.

В этой статье я, Женя Васильев, техлид в Dodo Engineering, расскажу, как мы решали проблему медленной сборки языка и как в этом помог Mono.

Для начала давайте посмотрим, как всё устроено внутри Code Basics.

Как курс выглядит для учеников

Пример урока на C#:

Каждый урок на Code Basics разбивается на две основные части — теоретическую и практическую. В первой студентам текстом рассказывается теория (слева на картинке), а после этого пользователи выполняют простое задание (справа), написав код решения. Решение проверяется автоматически тестами. При этом, если студент самостоятельно не может выполнить задание, то через 20 минут после старта урока включается функция подсказки, благодаря которой он может подсмотреть решение учителя.

Как устроен внутри

Code Basics — Open Source проект, все исходники которого доступны на GitHub. Курс для каждого языка лежит в своём собственном репозитории, все уроки разбиты по модулям. Название каждого модуля содержит число в начале — это нужно, чтобы модули шли в правильном порядке.

Сам урок лежит в YAML-файле description.<lang>.yaml. Внутри блоков — обычный Markdown с уроком.

Для проверки задач сделан универсальный механизм через Docker, который может работать с любыми языками. Решение студента сохраняется в файл в уникальной для каждого пользователя папке. Затем к этой папке маунтится docker-образ с рантаймом, компилятором и всем остальным, что нужно для запуска. Также в этом образе лежат юнит-тесты, которые и проверяют решение студента.

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

Приступаем к разработке курса

Итак, чтобы сделать новый курс, нужно:

  1. Написать теорию по каждому уроку в YAML-файлах.

  2. Подготовить к каждому уроку задачу и написать тест, проверяющий решение.

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

  4. Запуск должен укладываться в четырёхсекундное ограничение.

Написать теорию и задачи было самой простой частью, так как самому придумывать курс не надо. На Code Basics уже есть курсы по JavaScript и Java —  я подглядывал туда.

С окружением тоже сперва всё кажется простым: установить в docker-образ последний .NET SDK и вызывать команду dotnet run. Она сама сперва ставит все зависимости, затем компилирует проект и потом его исполняет. Так был сделан, например, тренажер для Java.

Для проверки решений студентов используется всего 2 строки кода: первая компилирует решение, вторая — исполняет. В .NET всё работает аналогично — делаем то же самое.

Но есть нюанс.

У интерпретируемых языков обычно быстрое время старта. Для них 4 секунды — более чем достаточное время, чтобы понять, что что-то не так с отправленным решением. У компилируемых нужно учитывать ещё и время компиляции. Оно тоже расходует бюджет четырёх секунд, и из-за слишком долгой компиляции выполнение кода может даже не успеть начаться.

Интерпретируемый C#

Есть особый диалект C#, предназначенный для скриптинга. Его можно попробовать в Visual Studio — инструмент называется C# Interactive. Диалект имеет собственное расширение файлов .csx, чтобы не спутать его с обычным C#-кодом. C# Interactive вшит в Visual Studio, но есть и другие инструменты от комьюнити, которые дублируют этот же функционал, но уже в отрыве от Visual Studio.

CS-Script — достаточно старый проект, но до сих пор живёт и развивается.

Я взял более новый dotnet-script. Во-первых, он устанавливается и запускается как dotnet tool, то есть вызывать его можно красиво через dotnet script-команду. Во-вторых, поддержка .NET Core появилась в нём раньше, чем в CS-script.

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

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

Красота против скорости

В тренажерах многих языков на Code Basics для описания ошибок в решениях используются библиотеки семейства Power Assert. У них минималистичный API, но в то же время они очень детально описывают ошибки. Вот так, например:

После добавления Power Assert через nuget мы начали вылетать за таймаут. Причина, в принципе, понятна: теперь мы должны скачивать этот пакет из nuget-репозитория. Лечение тоже простое: прямо в readme проекта dotnet-script описана его работа с кешем, поэтому, чтобы прогреть кеш зависимостей, при создании образа запускаем скрипт, в котором есть всего одна строчка: 

#load PowerAssert

Пакет скачивается и сохраняется в кеше. При последующих вызовах он уже не будет качаться заново, а возьмётся из него. Удалось выиграть немного времени, но меньше, чем ожидалось. Пришлось внимательно изучать отладочный лог инструмента. Оказывается, что даже несмотря на то, что пакет есть в кеше, dotnet-script делает обращение по сети к nuget-репозиторию и достает метаданные пакета для проверки целостности кеша.

Отказываться от такой полезной библиотеки не хотелось, поэтому я сделал простое и грязное решение: ссылаться на сборку напрямую как на .dll, а не как на пакет. Это позволило вырвать украденные PowerAssert’ом секунды и вернуться с статусу «на грани».

От компиляции никуда не деться

После того, как контент курса был почти готов, пришлось вернуться к вопросу «жизни на грани». Большая часть запросов укладывалась во временные рамки, но некоторые падали, вылетая за таймаут. Для пользователей это был бы ужасный UX: часть попыток отправить ответ падает с непонятной ошибкой про бесконечный цикл и нужно отправить решение заново, чтобы увидеть результат.

Чтобы разобраться, как можно ускорить выполнение, пришлось лезть в исходники проекта. Тут хочется отметить, что проект dotnet-script сделан очень хорошо. Его запустили не так давно, код богат тестами — его легко читать, поэтому я быстро обнаружил, что все скрипты на самом деле неявно компилируются и после этого складываются в специальную временную папку.

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

Вот только такое решение работает классно, если студент вводит код один в один, как в решении учителя. Если же в коде есть хотя бы один лишний пробел — хеш файлов не совпадает и всё заново компилируется. И мы опять возвращаемся к состоянию «на грани».

Напишем свой dotnet-script с блэкджеком

Dotnet-script, как и большинство подобных ему инструментов, под капотом использует пакет Microsoft.CodeAnalysis.CSharp.Scripting. Возможно, если взять этот пакет и использовать его напрямую, то удастся тоже получить какой-то выигрыш по времени за счёт выкидывания «ненужной шелухи». 

На Хабре уже были статьи о том, как написать свое собственное приложение для динамического выполнения C#-кода. И простой прототип удалось написать и опробовать меньше чем за час. Но результат оказался практически один в один по времени, как и вариант с dotnet-script. Опять неудача.

Как другие решают такую проблему

Есть немало сайтов которые умеют интерактивно запускать C# — те же LeetCode или Codewars. Исходники у большинства таких закрыты, но есть и очень классные проекты с открытым кодом. Например, SharpLab. Он предназначен в первую очередь для анализа внутренней структуры C# и VB-кода, но с недавних пор умеет также и исполнять этот код. Причем делает он это почти молниеносно – меньше чем за секунду происходит запуск, выполнение и возвращение результата.

Исходники открыты, но их чтение мне ничего не дало. На самом деле, там какой-то Rocket Science, ничего не понятно. Недаром автор премирован Microsoft как Most Valuable Professional – все на каком-то сверхпрофессиональном уровне. Удалось понять только то, что автор использует пакет Microsoft.CodeAnalysis.CSharp (родительский по отношению к Microsoft.CodeAnalysis.CSharp.Scripting) и что у него этап создания IL-кода и его выполнение разделены по разным машинам.

Не хотелось слишком глубоко погружаться в чужой проект, тем более такой мощный. И я решил сперва попробовать подергать другие «низковисящие фрукты». Например, почему бы не попробовать сменить .NET Core на что-нибудь ещё?

Переход на Mono

С отказом от .NET Core пришлось перейти и на CS-Script. В основном потому, что у CS-Script была готовая инструкция запуска на Mono. Основную работу по переводу сделал Станислав Дзисяк из Хекслета, за что ему респект.

Переход был почти бесшовным. Вот, оцените коммит. Ещё пришлось сделать пару мелких изменений в упражнениях. Для .NET Core сейчас актуальным является С#10. Mono пока поддерживает только 7.3, да и то не полностью. Но для образовательных целей этого более чем достаточно.

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

Даже Java обогнали немного, хе-хе
Даже Java обогнали немного, хе-хе

После того, как мы переехали на Mono, никаких серьёзных проблем уже не было.

Финал

Microsoft проделал отличную работу по развитию .NET Core, но для нашей нестандартной задачи это решение не подошло. Если честно, мы сами не до конца понимаем, за счёт чего компиляция у Mono быстрее. Тем не менее, он работает, студенты не жалуются и, судя по метрикам, их решения проходят.

Комментарии (4)


  1. Indermove
    22.02.2022 15:58
    +2

    Топовая статья получилась!

    Как-то тоже игрался с онлайн-компиляцией, но я что-то гораздо более громоздкое видимо использовал, получилось вот так: https://gist.github.com/Undermove/719cef1796bc0e6a5aab911e3f5bdab7#file-gistfile1-txt

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


    1. S__vet
      22.02.2022 17:18
      +2

      про архитектуру самого Хекслета попозже расскажем еще отдельно тоже


  1. johnfound
    23.02.2022 10:50
    -2

    В компании Dodo Engeneering есть хорошая экспертиза по C#

    Я вот, решил минусовать все статьи которые используют слово "экспертиза" в этом смысле.


    Мог бы и молча, но пишу, чтобы призвать всех присоединится! С этими «экспертами» пора заканчивать.


    Автор, отредактируй статью и заслужишь мое одобрение и плюс в карме!


  1. Bringoff
    23.02.2022 14:18

    Даже Java обогнали немного, хе-хе

    Это как со светофорными гонками: легко обогнать, если оппоненты не догадываются о заезде ????