Бэкенд платформы Lubeno полностью написан на Rust. Он вырос до таких размеров, что я уже не могу удерживать все части его кодовой базы в голове.

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

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

Сегодня Rust спас меня снова!

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

let lock = mutex.lock();
// … Заблокированные данные, используются для генерации коммита …
db.insert_commit(commit).await;

Это изменение показалось мне совершенно нормальным, и rust-analyzer со мной согласился. В этом файле не отобразилось никаких ошибок. Но внезапно красным загорелся другой файл в редакторе, указывая на ошибку компиляции в определении router. Мне это показалось полной бессмыслицей: как моя блокировка влияет на выбор обработчика router?

.route("/api/git/post-receive", post(git::post_receive))
                                     ^^^^^^^^^^^^^^^^^
error: future cannot be sent between threads safely
help: within 'impl Future<Output = Result<Response<Body>>', the trait 'Send' is not implemented for "MutexGuard<'_, GitInternal>"

Признаюсь: для того, чтобы разобраться в происходящем, мне понадобилось достаточно много времени. Давайте анализировать вместе!

При поступлении нового HTTP-соединения используемый нами веб-фреймворк порождает для него новую async-задачу. Асинхронные задачи исполняются в планировщике, работающем по принципу work stealing. Это значит, что когда поток завершает всю работу, он начинает «красть» задачи у других потоков, чтобы сбалансировать нагрузку. В Rust это может происходить только в точках «.await».

Есть и ещё одно важное правило — если мьютекс блокируется в одном потоке, то его необходимо освобождать в том же потоке, или возникнет неопределённое поведение.

Rust отслеживает все сроки жизни и знает, что блокировка живёт достаточно долго, поэтому передаёт точку «.await». Это означает, что освобождение блокировки может произойти в другом потоке, а это не допускается, потому что может привести к неопределённому поведению.

Решить проблему очень просто: достаточно освобождать блокировку до «.await».

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

Потрясающе, что компилятор Rust способен выявлять подобные вещи. И что кажущиеся несвязанными такие части языка, как мьютексы, сроки жизни и async-операции образуют такую целостную систему.

С другой стороны, TypeScript пугает

Недавний баг асинхронности в нашей кодовой базе на TypeScript оставался невыявленным ещё долго после того, как код был выпущен в продакшен. Вот виновник бага:

// Пользователь успешно выполнил вход!
if (redirect) {
    window.location.href = redirect;
}

let content = await response.json();
if (content.onboardingDone) {
    window.location.href = "/dashboard";
} else {
    window.location.href = "/onboarding";
}

Всё очень просто. При выполнении входа проверяем, есть ли перенаправление. Если да, то выполняем перенаправление на конкретную страницу. Если нет, переходим к дэшборду или странице онбординга. Присвоение значения «window.location.href» перенаправляет браузер в нужное место.

Я тестировал этот код, и он работал. Но внезапно перестал работать. А работал ли он вообще? Что здесь происходит? Нас всегда перенаправляет на дэшборд, даже если перенаправление есть.

Здесь присутствует состояние гонки планирования. Присвоение значения «window.location.href» не выполняет перенаправление мгновенно, как я предполагал. Оно просто задаёт значение и планирует перенаправление, как только оно станет возможным. Но код продолжает исполняться! Из-за этого следующее присвоение может исполниться до того, как браузер начинает перенаправление, поэтому перенаправляет пользователя в ошибочное место. Для обнаружения причины происходящего мне понадобилась куча времени. Чтобы решить проблему, я просто добавил конструкцию return в блок if, чтобы он никогда не добирался до остальной части кода.

if (redirect) {
    window.location.href = redirect;
    return;
}

Мне кажется, эти возникшие в Rust и TypeScript проблемы схожи. Обе они связаны с async-планированием и обе демонстрируют некое неопределённое поведение, которое не очень-то очевидно. Но проверка типов Rust оказалась гораздо полезнее, благодаря ей баг даже не скомпилировался. Компилятор TypeScript не отслеживает сроки жизни, и у него нет правил заимствования, поэтому он попросту неспособен обнаруживать подобные проблемы.

Рефакторинг без страха

Rust часто рекомендуют как отличный язык для системного программирования, но обычно его не ставят на первое место, когда дело касается веб-приложений. Python, Ruby и JavaScript/Node.js всегда воспринимаются, как более «продуктивные» для веб-разработки. Я же считаю, что это справедливо только для новичков! При работе с этими языками сразу много получаешь «из коробки», поэтому поначалу прогресс очень быстр.

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

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

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

Rust заявляет: «То изменение, которое ты вносишь, влияет на другую часть проекта, о которой ты, возможно, совсем не думал, потому что ты на глубине в шесть вызовов функций, а дедлайн не за горами; но вот конкретные причины, почему это может вызвать проблемы».

А как насчёт тестов?

Я считаю, что тесты — это прекрасно! Это очень мощный инструмент при выполнении крупного рефакторинга, когда нужна помощь в выявлении регрессий. Но компилятор не требует их для исполнения кода, поэтому вы можете просто решить не добавлять тесты.

Иногда работы бывает слишком много, а времени слишком мало. Но с тестами повышается ментальная нагрузка. Мне нужно выбирать подходящий уровень абстракций. Буду ли я тестировать поведение или подробности реализации? Предотвратит ли этот тест все ошибки в будущем? Принимать все эти решения утомительно, к тому же ты часто можешь ошибаться.

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

Разумеется, некоторые свойства приложения не могут быть частью системы типов, и таком случае отлично проявляют себя тесты!

Бонус: Zig тоже пугает!

Zig часто сравнивают с Rust; оба стремятся быть языками системного программирования. Я считаю, что Zig замечательный, но он пугает. Давайте взглянем на простой пример обработки ошибок.

const std = @import("std");

const FileError = error{
    AccessDenied,
};

fn doSomethingThatFails() FileError!void {
    return FileError.AccessDenied;
}

pub fn main() !void {
    doSomethingThatFails() catch |err| {
        if (err == error.AccessDenid) {
            std.debug.print("Access was denied!\n", .{});
        } else {
            std.debug.print("Unexpected error!\n", .{});
        }
    };
}

У нас есть функция «doSomethingThatFails», которая всегда завершается сбоем со значением ошибки «FileError.AccessDenied», после чего мы перехватываем ошибку и выводим, что доступ запрещён.

Только ведь мы этого не делаем. В логике обработки ошибок есть опечатка: «AccessDenid != AccessDenied». Код без проблем скомпилируется. Компилятор Zig сгенерирует по новому числу для каждого уникального «error.*», не беспокоясь о том, какие типы вы сравниваете. Это просто числа.

Однако если использовать оператор «switch» вместо «if», то компилятор Zig внезапно озаботится: «Ой, а тут ведь очевидная ошибка! Возвращаемая ошибка никогда не будет иметь это значение, потому что это не FileError», и откажется компилировать код. Он способен обнаружить баг, но просто не считает важным беспокоиться об этом. Если значение выглядит, как число, оператор «if» вполне может сравнивать его, как число.

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

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


  1. 4ou4
    08.09.2025 14:56

    Сколько времени занимает изучения Rust и нужно ли перед этим знать С?


    1. Fell-x27
      08.09.2025 14:56

      Знание Си в расте скорее мешает. Кучу раз видел как сишники, используя раст, просто продолжают кодить на Си, но в синтаксисе Раста. Компилятор ругается? Ну просто поставим unsafe, и все заработает!

      Си знать не нужно. Но желательно понимать, как работает память компьютера, указатели, стек, куча.

      Для освоения Раста лично для меня бОльшую пользу сыграл Хаскель. Внезапно, да. Но так вышло, что с ним знаком был сильно раньше, а потом оказалось, что в Расте используется много фич, характерных для ФП и Хаскеля в частности.

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

      P. S. Мне для вкатывания в Раст на уровень "ну теперь я хотя бы понимаю, что происходит, читаю код и могу писать что-то на начально-среднем уровне" понадобилось меньше недели. Но это зависит от бэкграунда сильно, конечно.


    1. yokotoka
      08.09.2025 14:56

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


  1. kolya7k
    08.09.2025 14:56

    Только взглянул на ваш код на TS сразу увидел проблему. С таким сталкивался еще в школе 25 лет назад…

    Пример на Rust как раз показывает всю ущербность Rust. Сам себе создал проблему на ровном месте (await, отсутствие контроля за потоками), сам ее нашел еще и в другом месте и героически не дал собрать проект.

    Напомнило C++ 2000-х годов, где сообщение об ошибке ссылалось на что угодно, только не на строку с проблемой.

    На C++ за десятилетия программирования я ни разу не столкнулся с ошибкой что освободил мьютекс из другого потока…

    Дедлоки - да, но они и в расте могут быть.

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


    1. Dhwtj
      08.09.2025 14:56

      Сам себе создал проблему на ровном месте (await, отсутствие контроля за потоками), сам ее нашел еще и в другом месте и героически не дал собрать проект

      Предлагаете await не использовать совсем? Назад, к лучине, берестяной грамоте. Await на любом языке может неявно менять поток. Но rust об этом скажет, а c++ и др. нет.

      На C++ за десятилетия программирования я ни разу не столкнулся с ошибкой что освободил мьютекс из другого потока

      Короткие локи? Если делать долгие задачи, то обязательно появится


  1. freemorger
    08.09.2025 14:56

    Ну, на моей практике Rust наоборот теряет в эффективности с ростом проекта. Система владения-заимствования уничтожает архитектуру и системы


    1. blind_oracle
      08.09.2025 14:56

      При разработке многопоточных проектов, да и многих прочих, borrow checker часто вообще не видно - 99% лайфтаймов выводятся автоматически, а при шаринге между потоками всё равно используешь Arc и подобные вещи.