Этот пост будет довольно сумбурным. Несколько месяцев назад я написал Hard Mode Rust, исследуя стиль программирования allocation-conscious. В последовавшей дискуссии @jamii упомянул TigerBeetle — распределённую быструю и маленькую базу данных, написанную на Zig в схожем стиле. И теперь я после шести лет работы с Rust пишу на основной работе на Zig. В этом посте я вкратце объясню, почему так получилось. Подчеркну, что это не уравновешенное и тщательное сравнение двух языков. Для этого я ещё не написал свои 100 тысяч строк на Zig. (Если вы ищете ответ на более общий вопрос «что же такое Zig?», то рекомендую пост @jamii).

На самом деле, этот пост будет в основном посвящён не языкам, а стилям написания ПО (однако вам очень поможет знание Rust и Zig). Итак, давайте приступим.

Надёжное ПО


В первом приближении все мы стремимся писать программы без багов. Но я считаю, что приглядевшись, мы поймём, что на самом деле нас не волнует, чтобы программы были верны в 100% случаев, по крайней мере, в большинстве областей. Из опыта мы знаем, что почти в каждой программе есть баги, тем не менее, она каким-то образом неплохо работает. Для примера, большинство программ использует стек, но почти ни одна программа не понимает, каким образом она конкретно его применяет, и насколько глубоко это может заходить. Когда мы вызываем malloc, мы просто надеемся, что для неё будет достаточно пространства в стеке, и почти никогда этого не проверяем. Аналогично, все программы на Rust аварийно завершаются при OOM и не могут заранее выставлять свои требования к памяти. Это, конечно, неплохо, но не идеально.

Во втором приближении мы стремимся уравновесить полезность программы с усилиями по её разработке. Баги сильно снижают полезность, и существует два стиля проектирования ПО для борьбы с ними:

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

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

rust-analyzer и TigerBeetle — идеальные примеры этих двух подходов, так что позвольте мне о них рассказать.

rust-analyzer


rust-analyzer — это LSP-сервер для языка программирования Rust. По своей природе он экспансивен. Качественные инструменты разработчика обычно имеют фичи для каждого нишевого способа применения. Кроме того, это стремительно развивающийся опенсорсный проект, играющий в догонялки с компилятором rustc. Наконец, сама природа инструментария разработчика IDE делает доступность существенно более важной, чем корректность. Опция ошибочного завершения может вызывать ухмылку (или вовсе остаться незамеченной), а вылет сервера и полное отключение подсветки синтаксиса будет замечено мгновенно.

Из-за всех этих причин rust-analyzer смещён по спектру далеко в сторону принятия несовершенства ПО. rust-analyzer проектируется на принципах того, что баги будут существовать. Всевозможные фичи тщательно разделяются в среде исполнения, чтобы паникующий код в одной фиче не привёл к вылету всего процесса. Критически важно то, что почти никакой код не имеет доступа к изменяемым состояниям, поэтому использование catch_unwind не может привести к испорченному состоянию.

Сам процесс разработки образуется по этой формуле. Например, пул-реквесты с новыми фичами применяются, когда есть оправданная уверенность в том, что наиболее благоприятный случай корректно работает. Если какой-то странный незавершённый код приведёт к вылету фичи, то это нормально. Это даже может стать преимуществом: устранение хорошо воспроизводимого бага в изолированной фиче становится началом пути активного контрибьютора rust-analyzer. Наш плотный график еженедельных релизов (и nightly-релиз) помогает быстрее устранять баги.

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

TigerBeetle


TigerBeetle построена по совершенно иному принципу.

Это база данных, модель предметной области которой фиксируется во время компиляции (на данный момент мы ведём систему двойной записи). База данных является распределённой: существует шесть реплик TigerBeetle, работающих на разных географически и функционально изолированных машинах, которые совместно реализуют распределённый автомат. То есть реплики TigerBeetle обмениваются сообщениями, чтобы гарантировать, что каждая реплика обрабатывает одинаковый набор транзакций в одинаковом порядке. Это на удивление сложная задача, если позволить машинам сбоить (весь смысл в использовании нескольких машин для избыточности), поэтому мы используем для этого умный алгоритм принятия консенсуса (не византийский). Традиционно алгоритмы принятия консенсуса подразумевают наличие надёжного хранилища — данные, однажды записанные на диск, всегда можно считать в будущем. В реальности же хранилище ненадёжно, оно почти византийское — диск может возвращать ложные данные, не сообщая об ошибке, и даже единственная такая ошибка способна испортить консенсус. TigerBeetle борется с этим, позволив реплике восстанавливать своё локальное хранилище при помощи данных из других реплик.

С инженерной точки зрения мы создаём надёжную, предсказуемую систему. И под предсказуемостью подразумевается реальная предсказуемость. Вместо того, чтобы управлять источниками недетерминированности, мы создаём целую систему с нуля из набора полностью детерминированных, изготовленных вручную компонентов. Вот некоторые из наших нетрадиционных решений (дизайн-документ):

Это сложный режим! Мы распределяем всю память при запуске, и после этого никакого распределения не происходит. Это устраняет всю неопределённость, связанную с распределением.

Код структурирован с брутальной простотой. Один пример: мы не используем для сериализации JSON или ProtoBuf или Cap’n’Proto. Вместо этого мы просто преобразуем полученные от сети данные в нужный тип. Смысл здесь не столько в повышении производительности, сколько в уменьшении количества подвижных частей. Парсинг — это сложная задача, но если вы контролируете обе стороны коммуникационного канала, то его не нужно выполнять, можно передавать данные с контрольной суммой без изменений.

Мы агрессивно минимизируем все зависимости. Мы точно знаем, какие системные вызовы делает наша система, потому что весь ввод-вывод находится в нашем собственном коде (в Linux, на нашей основной платформе продакшена, мы не компонуем libc).

Между компонентами мало абстракции — все части TigerBeetle работают слаженно. Например, один из наших базовых типов, Message, используется во всём стеке:

  • сеть получает байты из TCP-соединения напрямую в Message
  • консенсус обрабатывает и отправляет Message
  • аналогично, хранилище записывает на диск Message

Это естественным образом приводит к очень простому и быстрому коду.
Нам не нужно делать ничего особенного, чтобы выполнять нулевое копирование — так как мы распределяем всё заранее, у нас просто нет дополнительной памяти, в которую можно копировать данные! (Отдельная проблема заключается в том, что нельзя относиться к хранилищу как к отдельному чёрному ящику в устойчивой к сбоям распределённой системе, потому что хранилище тоже подвержено сбоям).

Всё в TigerBeetle имеет чётко заданную верхнюю границу. Нет ничего, что просто было бы u32 — все данные проверяются на соответствие определённым числовым пределам на границах системы.

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

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

Я подбираюсь к тому, что TigerBeetle на самом деле не является обычной «программой» в прямом смысле. Строго говоря, это конечный автомат, явно закодированный таким образом.

Возвращаемся к теме


Ах, да, Rust и Zig, тема нашего поста!

Я заметил. что часто возвращаюсь к своей первой презентации по Rust. Многие базовые вещи изменились (Rust больше не использует только старые идеи), но многое осталось прежним. Если добавить сарказма, можно сказать, что Rust «не для гениальных хакеров-одиночек», однако Zig… как будто подходит под это определение. Если говорить мягче, Rust — это язык для создания модульного ПО, в то время как Zig в каком-то смысле антимодульный.

Уместно будет процитировать Брайана Кэнтрилла:

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

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

Zig этого не делает. Он даже не безопасен по памяти. Мой первый опыт написания нетривиальной программы на Zig выглядел так:

Я: Ого! Это что, я могу наконец просто сохранять указатель на поле структуры в самой структуре?

30 секунд спустя

ПРОГРАММА: Segmentation fault.

Впрочем!

Язык Zig гораздо меньше, чем Rust. Хотя вам придётся хранить всю программу целиком в голове, быть царём и богом, чтобы не испортить управление ресурсами, делать это будет проще.

Неправда, что переписывание программы на Rust в программу на Zig сделает её проще. Напротив, я бы ожидал, что результат окажется существенно более сложным (и подверженным segfault). Я заметил, что большая часть кода на Zig, написанная в стиле «давайте заменим RAII на defer», имеет баги управления ресурсами.

Однако часто возможно спроектировать такое ПО, что там нужен небольшой объём управления ресурсами (например, распределять всё заранее, как это происходит в TigerBeetle, или даже во время компиляции, как во многих мелких встроенных системах). Это сложно: простота всегда сложна. Но если вы пойдёте таким путём, мне кажется, Zig может дать существенные преимущества.

Zig имеет одну фичу (comptime с динамической типизацией), охватывающую большинство механизмов Rust для особых случаев. Это точно компромисс, потому что в сложных случаях ошибки времени инстанцирования гораздо хуже. Однако гораздо большее количество случаев оказывается проще, потому что нет необходимости в программировании на языке типов. Когда дело касается языка, Zig очень спартанский. В нём нет замыканий; если они вам нужны, то придётся самостоятельно упаковывать wide-pointer. Выразительность Zig нацелена на создание как раз того ассемблерного кода, который нужен, а не на обеспечение максимально сжатого и абстрактного исходного кода. Как сказал Эндрю Келли, Zig — это DSL для создания машинного кода.

Zig строго предпочитает явное управление ресурсами. Многие программы на Rust — это веб-серверы. У большинства веб-серверов есть очень конкретный паттерн исполнения для параллельной обработки множества независимых короткоживущих запросов. Наиболее естественным способом кодинга этого было бы предоставление каждому запросу выделенного аллокатора bump, который превращает drop в no-op и «освобождает» память после того, как каждый запрос сбрасывает смещение на ноль. Это было бы довольно эффективно и обеспечило готовое профилирование и ограничение памяти для каждого запроса. Кажется, ни один популярный фреймворк Rust не делает этого — использовать глобальный аллокатор достаточно удобно и создаёт сильный локальный оптимум. Zig заставляет нас передавать аллокатор, поэтому вы можете задуматься о том, какой наиболее подходит!

Аналогично, его стандартная библиотека очень осознанно относится к распределению памяти, внимательнее, чем у Rust. Коллекции не параметризуются по аллокатору, как в C++ или в (будущем) Rust. Вместо этого аллокатор явным образом передаётся каждому методу, который должен выполнять распределение памяти. Это Call Site Dependency Injection, которая более гибка. Например, в TigerBeetle нам требуется пара хэш-таблиц. Размер этих таблиц указывается в момент запуска, они содержат ровно нужное количество элементов и не меняют размера. Поэтому мы передаём аллокатор методу init, но не передаём его циклу событий. Мы одновременно можем и использовать стандартную хэш-таблицу, и быть уверенными, что мы никак не выполним распределение в цикле событий, потому что он не имеет доступа к аллокатору.

Список пожеланий


Наконец, приведу свой список желаний по поводу Zig.

Во-первых, я думаю, что главная сильная сторона Zig заключается исключительно в написании «идеального» системного ПО. Это довольно малая доля рынка, но она важна. Одна из проблем Rust заключается в том, что у нас нет высокоуровневого языка программирования, ориентированного на надёжность, с хорошим качеством реализации (современного ML, если угодно). Это стало благословением для Rust, потому что увеличило его нишу и стимулировало развитие его сообщества. Но в то же время это и проклятие, поскольку при увеличении ниши сложнее сохранять фокус. Для Zig язык Rust уже играет роль «современного ML», что увеличивает потребность в специализации.

Во-вторых, больше всего в Zig меня беспокоит его семантика, связанная с алиасингом, provenance, изменяемостью и самоадресацией. Меня не особо беспокоит то, что это создаст стиль «недействительности итераторов» UB.
TigerBeetle выполняется в -DReleaseSafe, который по большей мере решает вопрос пространственной безопасности памяти, она не выполняет динамическое распределение памяти, поэтому вопрос о временной безопасности памяти снимается, и имеет очень подробный набор тестов на основе фаззера, уничтожающий оставшиеся баги. Меня беспокоит семантика самого языка. Насколько я сейчас понимаю, для корректной компиляции на низкоуровневый язык типа C необходимо разобраться с семантикой указателей. Я не уверен, существует ли «портируемый ассемблерный код»: можно создать компилятор, который выполняет небольшую оптимизацию и «работает ожидаемым образом» в большинстве случаев, но я сомневаюсь, что можно корректно описать поведение такого компилятора. Если вы начнёте задавать вопросы о том, что такое указатели и что такое память, то окажетесь на довольно сложной территории, где байты опасны. Rust пытается задать это чётко, однако писать код, подчиняющийся правилам Rust, без системы статического контроля ссылок на самом деле невозможно — правила слишком неявные. Современная реализация Zig очень нечётко работает с указателями с потенциальным алиасингом, копиями структур с внутренними указателями, и тому подобным. Мне бы хотелось, чтобы у Zig имелся чёткий ответ, какова же желаемая семантика.

В-третьих, поддержка IDE. Я уже писал об этом. На сегодняшний день разработка на Zig довольно удобна — сервер языка достаточно спартанский, но уже вполне полезный, а в остальном Zig чрезвычайно удобен для grep. Однако учитывая модель ленивой компиляции и отсутствие метапрограммирования вне языка, мне кажется, что Zig мог быть в этом более амбициозным. Чтобы хорошо позиционировать себя на будущее с точки зрения поддержки IDE, на мой взгляд, было бы здорово, если бы компилятор получил базовую модель данных для применения в IDE. То есть должен существовать API для создания постоянного процесса анализатора, получающего поток изменений кода и создающий непрерывно обновляемую модель кода без явных запросов компиляции. Модель может быть очень простой: всего лишь дайте мне AST этого файла в этот момент времени, этого будет достаточно; все расширенные фичи IDE можно добавить позже. Самое главное — это формат данных, передаваемых компилятору: не цикл редактирования и компиляции, а постоянно обновляемый взгляд на мир.

В-четвёртых, одна из важных для меня ценностей Zig — это предпочтение автономных процессов с малым количеством зависимостей. В идеале вы получаете двоичный файл ./zig, а затем двигаетесь дальше. Хотелось бы, чтобы предпочтение отдавалось объединению с проектом конкретной версии ./zig, а не использованию zig в масштабе всей системы. Улучшить можно два аспекта. «Получить Zig» — это непростая задача, поскольку она требует бутстреппинга. Для этого нужно выполнить код, который скачает двоичный файл для вашей платформы, однако у каждой платформы есть собственный способ «выполнения кода». Мне бы хотелось, чтобы у Zig имелся надёжный набор скриптов: get_zig.sh, get_zig.bat и так далее (или, возможно, небольшой действительно портируемый двоичный файл?), который бы проекты могли просто распространять, чтобы процесс контрибьютинга был полностью локальным для проекта и автономным:

$ ./get_zig.sh
$ ./zig build

Получив ./zig, вы сможете использовать его для выполнения остальной автоматизации. Мы уже можем выполнить ./zig build для управления этой сборкой, однако в ПО есть что-то ещё, кроме сборки. Всегда присутствует длинный хвост небольших особенностей, которые традиционно решаются набором зависящих от платформы скриптов bash. Мне хотелось бы, чтобы Zig сильнее мотивировал пользователей к созданию всей этой автоматизации на Zig. Картинка стоит тысячи слов, так что:

# ПЛОХО: зависимость от ОС
$ ./scripts/deploy.sh --port 92
# OK: нет зависимости, но много печатать
$ ./zig build task -- deploy --port 92
# БЫЛО БЫ ПРЕКРАСНО:
$ ./zig do deploy --port 92

Попробуем подвести итог:

  • Rust — это про композиционную безопасность, он более масштабируемый язык, чем Scala.
  • Zig — это про совершенство. Это очень острый, опасный, но, в конечном итоге, более гибкий инструмент.

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


  1. dbezheckov
    11.04.2023 07:04
    +8

    Я всегда думал что malloc выделяет память в куче. "Когда мы вызываем malloc, мы просто надеемся, что для неё будет достаточно пространства в стеке, и почти никогда этого не проверяем"


    1. yuriv
      11.04.2023 07:04
      +8

      Вы всю статью прочтите - это бессмысленный набор слов.


      1. DarkEld3r
        11.04.2023 07:04

        Можно несколько примеров? По моему, достаточно приятная статья и это при том, что я "топлю за раст".


    1. Lex98
      11.04.2023 07:04
      +5

      Насколько я понимаю, тут не про выделение памяти, а про то, что на сам вызов malloc может места на стеке не хватить (как и на вызов любой другой функции в теории).


  1. JustForFun88
    11.04.2023 07:04
    +7

    Я бы тогда просто использовал C. Преимущества Rust перед C очевидны и довольно значимы чтобы смириться с небольшой сложностью при разработке небольшой экосистемой. С другой стороны преимущества Zig перед C думаю не стоят того чтобы его использовать, с учётом никакой экосистемы.


  1. gohrytt
    11.04.2023 07:04
    -2

    Zig пока один из четырёх условно новых языков за которымя я наблюдаю, кроме него это Nim, Odin и V - автору рекомендую посмотреть в их сторону, возможно что-то приглянется.
    Пока личного фаворита выбрать не получилось но все четыре невероятно интересные проекты способные лично для меня стать альтернативой C. Как говорится будем наблюдать.