Rust borrow checker и свобода Go: в чём разница подходов к памяти
Сравнение двух языков через призму безопасности памяти: разбираем, как именно Rust проверяет владение на этапе компиляции и почему Go сознательно отказался от этих гарантий в пользу простоты и скорости разработки.
Введение
Rust и Go часто ставят рядом как современные языки для серверной разработки, но их философия управления памятью противоположна. Rust вводит borrow checker — статический анализатор, который отслеживает, кто и как использует каждый участок памяти, и запрещает компилировать программу при нарушении правил владения. Go пошёл другим путём: сборщик мусора и интерпретация ссылок как обычных указателей без статического контроля. Эта разница определяет не только производительность, но и сам стиль написания кода, профиль ошибок и целевую аудиторию языка.
Раздел 1. Что такое borrow checker в Rust
Borrow checker — компонент компилятора Rust, реализованный в crate rustc_borrowck и работающий поверх MIR (Mid-level Intermediate Representation). Его задача — доказать, что программа не содержит целого класса ошибок: use-after-free, двойного освобождения, гонок данных на этапе компиляции, без участия рантайма.
Механизм опирается на три ключевых концепта:
- Ownership (владение). У каждого значения ровно один владелец. Когда владелец выходит из области видимости, значение drop'ается автоматически — деструктор вызывается детерминированно, без пауз GC.
- Borrowing (заимствование). Владелец может временно передать право пользования значением через ссылку
&T(неизменяемую) или&mut T(изменяемую). Правила: либо много&T, либо ровно одна&mut T— но не одновременно. - Lifetimes (времена жизни). Аннотации
'aсвязывают время жизни ссылок с временем жизни данных, на которые они указывают, чтобы компилятор мог проверить корректность при перемещении значений через границы функций.
Классический пример:
fn main() {
let mut s = String::from("hello");
let r1 = &s;
let r2 = &s;
println!("{r1} {r2}"); // OK: две неизменяемые ссылки
let r3 = &mut s;
r3.push_str(" world");
println!("{r3}");
// println!("{r1}"); // ошибка: r1 живёт дольше, чем r3
}
Borrow checker отслеживает NLL (Non-Lexical Lifetimes) — ссылки активны только до последнего использования, а не до конца блока. Это смягчает многие раздражающие случаи, которые были в ранних версиях Rust.
Полиморфизм времён жизни позволяет писать функции вида fn longest<'a>(x: &'a str, y: &'a str) -> &'a str — компилятор сам выводит минимальное подходящее время жизни в месте вызова.
Раздел 2. Что borrow checker ловит, а что нет
Список того, что статически запрещено:
| Ошибка | Как Rust её предотвращает |
|---|---|
| Use-after-free | Время жизни ссылки не может пережить владельца |
| Double free | Значение имеет единственного владельца |
| Data race | &mut T эксклюзивен в пределах потока, Send/Sync контролируют межпоточные ссылки |
| Iterator invalidation | Заимствование коллекции блокирует мутацию |
Что borrow checker не делает:
- Не защищает от логических ошибок и некорректных алгоритмов.
- Не предотвращает все утечки памяти —
Rc::cycle()создаёт цикл, который не освободится автоматически. Для этого естьWeak. - Не отменяет необходимость в
unsafe— для FFI, низкоуровневых структур данных, кастомных аллокаторов можно обойти гарантии, и тогда ответственность снова на разработчике. - Не ловит гонки между потоками, использующими
UnsafeCellили сырые указатели — это зонаcargo+clippy+ человеческого ревью.
Стоит упомянуть, что borrow checker работает на уровне отдельных функций и их сигнатур. Когда lifetime-аннотации становятся слишком сложными, есть инструменты: GAT (Generic Associated Types), impl Trait с ограничениями, crate ` polonius для более точного анализа. Экосистема постепенно сокращает количество ситуаций, где правила мешают выразить намерение.
Раздел 3. Как устроена память в Go
В Go нет ни владения, ни заимствований, ни времён жизни. Переменная — это либо значение, либо указатель, ссылающийся на объект в куче. Управляет временем жизни этих объектов сборщик мусора — concurrent tri-color mark-and-sweep, работающий параллельно с горутинами и почти не делающий stop-the-world пауз (микросекунды на современных версиях Go 1.21+).
Пример, который в Rust требует аккуратности с borrow checker, в Go тривиален:
package main
import "fmt"
func main() {
s := []int{1, 2, 3}
a := s
s[0] = 99
fmt.Println(a) // [99 2 3] — слайс разделяет базовый массив
}
Слайс — это структура из указателя, длины и ёмкости. Присваивание копирует три поля, но базовый массив остаётся общим. Никаких конфликтов: GC соберёт массив, когда исчезнет последняя ссылка.
Go добавляет статическую проверку только в одном месте — escape analysis компилятора. Он решает, разместить значение на стеке или в куче:
func newInt() *int {
x := 42
return &x // escape: x перемещается в кучу
}
Разработчик может влиять на это косвенно через структуру кода и директивы //go:nosplit, //go:noinline, но не управляет временем жизни объектов напрямую.
Раздел 4. Цена и выгода каждого подхода
Rust: платишь временем разработки, получаешь предсказуемость
Преимущества:
- Нет GC-пауз. Подходит для систем реального времени, высокочастотного трейдинга, игровых движков, embedded.
- Предсказуемый расход памяти. Аллокации явные, освобождения детерминированные, проще считать peak memory.
- Безопасность по построению. Многие CVE-классы CVE-баз ядра Linux и Chromium физически не могут появиться в коде на Rust.
- Компилятор как тимлид. Многие архитектурные ошибки ловятся до code review.
Недостатки:
- Сложность онбординга. Концепции ownership и lifetimes требуют переучивания для программистов с опытом в Java/Go/Python.
- Время компиляции. Полный анализ замедляет сборку крупных проектов.
- Борьба с borrow checker. В ряде паттернов (двойной связанный список, графические структуры, определённые деревья) правила мешают, и приходится применять
Rc<RefCell<T>>,Arc<Mutex<T>>или interior mutability. unsafeкак лазейка. Экосистема частично полагается на него, и аудитunsafe-блоков — отдельная дисциплина.
Go: платишь ресурсами, получаешь скорость итераций
Преимущества:
- Простота. Синтаксис минимален, нет скрытых эффектов владения, рефакторинг менее рискован.
- Быстрый цикл разработки. Код можно переписывать смело: если компилируется — скорее всего работает в плане памяти корректно.
- Зрелая стандартная библиотека и инструменты.
go vet,staticcheck,pprofдля анализа аллокаций. - Горутины и каналы как основная абстракция конкурентности — не нужно думать о
Send/Sync, достаточно правильно изолировать данные.
Недостатки:
- GC-паузы. Для большинства серверных задач это незаметно, но в латентно-чувствительных системах (HFT, телеком, обработка аудио) это проблема.
- Утечки памяти возможны и обнаруживаются только профилировщиком. Забытый
time.Ticker, канал без читателя, ссылка в глобальной карте — всё это держит объекты живыми. - Аллокации везде. Каждый
append, каждая escape'нувшая переменная уходит в кучу. Это даёт нагрузку на GC, даже если объём работы небольшой. - Нет статических гарантий безопасности памяти. Race detector (
go test -race) ловит гонки только в тестах, а не в продакшене.
Раздел 5. Когда что выбирать
Rust сильнее в зонах, где важны детерминизм, контроль над каждым байтом и формальная безопасность: системное ПО, драйверы, криптография, WebAssembly-модули с жёсткими требованиями к размеру, встраиваемые системы, высокопроизводительные сервисы с фиксированным SLA по памяти.
Go хорош там, где важны скорость разработки, простота сопровождения больших команд и быстрая масштабируемость через горутины: микросервисы, инфраструктурные утилиты, CLI-инструменты, обработка данных в реальном времени с умеренными требованиями к латентности, оркестрация контейнеров, DevOps-инструменты. Docker, Kubernetes, Prometheus, Terraform, Vault — большинство этих проектов написаны на Go именно потому, что язык позволяет команде быстро эволюционировать кодовую базу.
Иногда оба языка соседствуют в одной системе: критический по производительности компонент на Rust, бизнес-логика и оркестрация на Go. Связь между ними — через gRPC, WebAssembly или FFI.
Заключение
Borrow checker — это не магия, а формальная система правил поверх промежуточного представления компилятора Rust, которая переносит целый класс ошибок памяти из рантайма в compile time. Go сознательно отказался от этой проверки в пользу сборщика мусора и escape analysis, получив взамен простоту языка и предсказуемость поведения команд. Выбор между ними — это выбор между «платить сложностью на этапе написания кода» и «платить аллокациями и GC на этапе исполнения». Для системного программирования Rust даёт уникальные гарантии; для серверной разработки Go остаётся одним из самых прагматичных инструментов.