Системное программирование на C Современные альтернативы: Rust, Zig, Go — когда уходить с C и когда оставаться
0%

Современные альтернативы: Rust, Zig, Go — когда уходить с C и когда оставаться

Современные альтернативы: Rust, Zig, Go — когда уходить с C и когда оставаться

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

Вопрос «какой язык лучше» бессмысленен, и мы им заниматься не будем. Осмысленный вопрос звучит так: какие свойства C вам нужны, какие вы терпите вынужденно, и есть ли инструмент, который сохраняет первые и убирает вторые при приемлемых издержках перехода. Ответ зависит от домена, от возраста кодовой базы, от целевой платформы и от того, кого вы можете нанять — и в разных проектах одной компании он разный.

Цифры, из-за которых разговор перестал быть вкусовым, мы уже приводили в статье про неопределённое поведение: около 70% исправленных уязвимостей и в Microsoft, и в Chromium — ошибки безопасности памяти. У этой статистики есть продолжение, которое важнее самой статистики: отчёт Google по Android за 2024 год показывает, что доля таких уязвимостей упала с 76% до 24% за пять лет, причём без переписывания старого кода — просто потому, что новый код писался на безопасном по памяти языке. Это меняет постановку задачи: спорить надо не про «переписать всё», а про то, на чём писать следующие 50 тысяч строк.

Сначала разложите «C» на части

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

Роль C Что именно она даёт Заменяется ли отдельно
Язык синтаксис, семантика, модель памяти да — это и есть предмет спора
ABI System V AMD64 / AAPCS: как передаются аргументы, как раскладываются структуры нет и не нужно: C-ABI — это lingua franca, её говорят все
libc malloc, stdio, POSIX-обёртки, локали частично: musl, свой аллокатор, прямые syscall
Система сборки Make, CMake, Autotools, компоновщик да, и часто первым — это самый дешёвый выигрыш
Экосистема и культура код ядра, драйверов, кодеков; MISRA; ревью медленнее всего

Ключевой пункт — второй. Ни один из «конкурентов» C не пытается заменить C-ABI: Rust, Zig, Go, Swift, C# и Python общаются с миром именно через неё. Поэтому переход почти никогда не выглядит как «перепишем проект»; он выглядит как «поставим новый модуль за границей C-ABI и будем расширять его долю». Это делает решение обратимым, а обратимые решения принимают быстро.

Оси сравнения, которые действительно решают

Опыт показывает: споры о языках заходят в тупик, когда сравнивают «удобство». Сравнивать надо свойства, которые проверяются и стоят денег.

1. Что гарантирует компилятор. У C гарантий нет: он вправе предположить, что UB не происходит, и оптимизировать исходя из этого. У Rust безопасное подмножество исключает висячие указатели, выход за границы, гонки данных и двойное освобождение — но только вне блоков unsafe. У Zig язык не проверяет времена жизни, зато в режимах Debug и ReleaseSafe встроены проверки индексов, переполнений и невалидных приведений. У Go память безопасна, но гонки данных на многословных значениях (интерфейсы, срезы, строки) способны сломать инварианты рантайма.

2. Сколько весит рантайм. Ключевой параметр для прошивок, ядер и обработчиков прерываний. Здесь между Rust/Zig и Go пропасть, и она не сокращается.

3. Модель ошибок. Коды возврата и errno в C; Result и ? в Rust; error unions и try/errdefer в Zig; пара (value, error) в Go. Разница не косметическая: в трёх последних языках забытая проверка ошибки — это ошибка компиляции или предупреждение статического анализа, а не молчаливое продолжение работы.

4. Дистрибуция и кросс-компиляция. Критерий, о котором вспоминают последним, хотя решает он больше остальных: здесь Zig и Go неожиданно сильнее Rust, а Rust сильнее C. 5. Зрелость и стоимость команды. Стабильность языка, наличие тулчейна под ваш таргет, срок выхода нового человека на скорость, возможность нанять.

Обратите внимание на левый нижний квадрант: C там один, и это не случайность. Все остальные языки чем-то платят за то, чтобы из него выйти — либо дисциплиной типов (Rust, Ada), либо рантаймом (Go, JVM), либо частичными проверками (Zig).

Слои между вашим кодом и ядром для C, Rust, Zig и Go

Rust: владение вынесено в систему типов

Одна идея Rust важнее всех остальных: время жизни объекта — часть его типа, а не комментарий в заголовочном файле. У каждого значения ровно один владелец; ссылки на него бывают либо одной изменяемой, либо любым числом неизменяемых, но не одновременно; ссылка не может пережить владельца. Это проверяется до генерации кода и стоит на исполнении ноль.

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

// uaf.c — компилируется без единого предупреждения даже с -Wall -Wextra
#include <stdlib.h>
#include <stdio.h>

int main(void) {
    int *a = malloc(3 * sizeof *a);
    a[0] = 1; a[1] = 2; a[2] = 3;
    int *first = &a[0];              // указатель внутрь буфера

    int *bigger = realloc(a, 64 * sizeof *a);   // блок мог переехать
    if (!bigger) return 1;
    printf("%d\n", *first);          // use-after-free: читаем по старому адресу
    free(bigger);
}
$ gcc -O2 -Wall -Wextra -o uaf uaf.c && ./uaf     # напечатает 1 — «работает»
$ gcc -O1 -g -fsanitize=address -o uaf uaf.c && ./uaf
==...==ERROR: AddressSanitizer: heap-use-after-free on address 0x...

Тот же смысл на Rust не компилируется:

fn main() {
    let mut a = vec![1, 2, 3];
    let first = &a[0];   // неизменяемое заимствование живёт до последнего использования
    a.push(4);           // push требует &mut self — конфликт с живым заимствованием
    println!("{first}"); // ошибка: cannot borrow `a` as mutable because it is
}                        // also borrowed as immutable [E0502]

Разница не в том, что Rust «поймал ошибку». Разница в том, когда он её поймал: до запуска, без тестового покрытия этой строки, без санитайзера и без того, чтобы ошибка вообще попала в коммит. Санитайзер из статьи про отладку находит только то, что исполнилось; система типов проверяет все пути сразу.

Чего Rust не даёт

Список ограничений стоит знать наизусть, иначе покупаешь не тот товар:

  • Утечки и дедлоки безопасны. Box::leak, mem::forget, цикл из Rc — утечка не считается нарушением безопасности; гонки данных исключены, а гонки состояний и взаимные блокировки — нет.
  • Переполнение целых определено, но не запрещено: паника в отладочной сборке, оборачивание в релизной. Логическую ошибку это не отменяет — нужны checked_*/saturating_*.
  • unsafe возвращает всё обратно. Внутри unsafe действуют примерно те же правила, что в C, плюс более строгие требования к алиасингу. Ошибки в unsafe разбирают через Miri — интерпретатор, ловящий нарушения модели памяти.
  • Стабильного ABI у языка нет. Раскладка repr(Rust)-типов не гарантирована между версиями компилятора; наружу отдают только #[repr(C)].
  • Время сборки. Мономорфизация обобщений и LLVM дают минуты на средних проектах; это заметная часть цены.

Граница с C: как выглядит миграция на самом деле

Rust спроектирован под сосуществование с C, и это его главное практическое преимущество перед «языками мечты». Направление внутрь — bindgen читает заголовочные файлы и генерирует объявления; направление наружу — cbindgen генерирует заголовок из Rust-кода.

// Вызов существующей C-библиотеки: небезопасные объявления прячем за safe-обёрткой.
use std::ffi::{c_char, c_int, c_void, CStr};

unsafe extern "C" {                 // в edition 2024 блок extern сам помечается unsafe
    fn buf_new(cap: usize) -> *mut c_void;
    fn buf_push(b: *mut c_void, s: *const c_char) -> c_int;
    fn buf_free(b: *mut c_void);
}

/// Владелец C-объекта. Время жизни выражено типом, а не соглашением в README.
pub struct Buf { raw: *mut c_void }

impl Buf {
    pub fn with_capacity(cap: usize) -> Option<Self> {
        let raw = unsafe { buf_new(cap) };
        if raw.is_null() { None } else { Some(Buf { raw }) }
    }
    pub fn push(&mut self, s: &CStr) -> Result<(), i32> {
        // единственное место, где мы отвечаем за корректность вручную
        let rc = unsafe { buf_push(self.raw, s.as_ptr()) };
        if rc == 0 { Ok(()) } else { Err(rc) }
    }
}

impl Drop for Buf {
    fn drop(&mut self) {
        unsafe { buf_free(self.raw) }   // free вызывается ровно один раз, всегда
    }
}

Обратное направление — Rust как ускоритель или как безопасная замена одного модуля внутри C-приложения:

// Отдаём наружу владение буфером — вместе с парной функцией освобождения.
#[repr(C)]
pub struct Blob { ptr: *mut u8, len: usize, cap: usize }

#[unsafe(no_mangle)]                       // edition 2024: атрибут явно небезопасен
pub extern "C" fn rs_compress(src: *const u8, n: usize) -> Blob {
    let input = unsafe { std::slice::from_raw_parts(src, n) };
    // паника через extern "C" начиная с Rust 1.81 гарантированно завершает процесс,
    // поэтому всё, что может паниковать, ловим здесь и превращаем в пустой Blob
    let out = std::panic::catch_unwind(|| compress(input)).unwrap_or_default();
    let mut v = std::mem::ManuallyDrop::new(out);
    Blob { ptr: v.as_mut_ptr(), len: v.len(), cap: v.capacity() }
}

#[unsafe(no_mangle)]
pub extern "C" fn rs_blob_free(b: Blob) {
    if !b.ptr.is_null() {
        unsafe { drop(Vec::from_raw_parts(b.ptr, b.len, b.cap)) }
    }
}
# Собираем статическую библиотеку с C-ABI и линкуем в обычный C-проект
$ cargo build --release          # [lib] crate-type = ["staticlib"] в Cargo.toml
$ cbindgen --config cbindgen.toml --crate mylib --output mylib.h
$ gcc -O2 main.c -o app target/release/libmylib.a -lpthread -ldl -lm

Границы владения памятью между C и Rust

Правило, которое стоит вывесить над столом: указатель может пересекать границу сколько угодно раз, право освобождать — ни разу. Память, выделенную malloc, освобождает C; память из Rust-аллокатора — Rust. Смешение двух куч даёт повреждение метаданных, падающее позже и совсем в другом месте.

Про сам язык — модель владения, лайфтаймы, трейты, асинхронность и unsafe — подробно в отдельном треке: владение и заимствование, времена жизни, unsafe и FFI, конкурентность.

Zig: тот же уровень контроля, но без сюрпризов

Zig отвечает на другую жалобу на C. Не «в нём легко ошибиться с памятью», а «в нём слишком много скрытого и слишком мало выразительности»: препроцессор как отдельный недоязык, отсутствие обобщений, макросы, скрытые аллокации в чужих библиотеках, неявные приведения типов.

Три решения определяют язык:

Аллокатор — обычный параметр. В стандартной библиотеке Zig нет функции, которая выделяет память сама. Если функция выделяет — она принимает Allocator. Это делает тестирование на утечки тривиальным и радикально упрощает жизнь во встраиваемых системах, где глобальной кучи может не быть вовсе.

comptime вместо препроцессора и шаблонов. Обобщения, вычисление констант, генерация кода и рефлексия — это один механизм: выполнение обычного Zig-кода на этапе компиляции. Типы — значения типа type.

Никакого скрытого потока управления. Нет перегрузки операторов, нет исключений, нет деструкторов, нет неявных вызовов. Строка a + b — это сложение, а f() — единственный вызов.

const std = @import("std");

// Обобщение — это функция от типа. Никакого отдельного синтаксиса шаблонов.
fn Stack(comptime T: type) type {
    return struct {
        items: []T,
        len: usize = 0,
        alloc: std.mem.Allocator,

        const Self = @This();

        pub fn init(alloc: std.mem.Allocator, cap: usize) !Self {
            return .{ .items = try alloc.alloc(T, cap), .alloc = alloc };
        }

        pub fn deinit(self: *Self) void {
            self.alloc.free(self.items);   // освобождает тот, кто выделил
        }

        pub fn push(self: *Self, v: T) !void {
            if (self.len == self.items.len) {
                self.items = try self.alloc.realloc(self.items, self.items.len * 2);
            }
            self.items[self.len] = v;      // индекс проверяется в Debug и ReleaseSafe
            self.len += 1;
        }
    };
}

pub fn main() !void {
    // DebugAllocator (до 0.14 назывался GeneralPurposeAllocator) сам ловит
    // утечки, двойное освобождение и запись за границей блока.
    var dbg = std.heap.DebugAllocator(.{}){};
    defer if (dbg.deinit() == .leak) std.debug.print("обнаружена утечка\n", .{});

    var s = try Stack(u32).init(dbg.allocator(), 4);
    defer s.deinit();                      // выполнится при любом выходе из области

    for (0..10) |i| try s.push(@intCast(i));
    std.debug.print("len = {d}\n", .{s.len});
}
$ zig build-exe stack.zig                 # режим Debug: все проверки включены
$ zig build-exe stack.zig -O ReleaseSafe  # проверки остаются, оптимизация включена
$ zig build-exe stack.zig -O ReleaseFast  # проверки снимаются: снова территория UB

Разбор режимов сборки здесь важнее синтаксиса. Debug и ReleaseSafe вставляют проверки индексов, знакового и беззнакового переполнения, приведений с потерей и разыменования пустого optional; нарушение даёт панику с трассировкой, а не тихую порчу памяти. ReleaseFast и ReleaseSmall эти проверки убирают — и там нарушения снова становятся неопределённым поведением ровно в том смысле, который мы разбирали в статье про UB. Практика: держите ReleaseSafe по умолчанию и снимайте проверки точечно там, где профилировщик показал стоимость.

Чего Zig не даёт: проверки времён жизни. Use-after-free в Zig компилируется. Ловится он не языком, а инструментами — отладочным аллокатором, который не возвращает страницы системе и распознаёт повторное использование, тем же ASan через zig cc, тестами.

Zig как инструмент для C-проекта, даже если вы не пишете на Zig

Самая недооценённая часть: в дистрибутиве Zig лежит clang, заголовки нескольких libc (glibc разных версий, musl, mingw) и линковщик. Отсюда — zig cc, кросс-компилятор для C, который работает из коробки и умещается в один архив:

# Кросс-сборка C-проекта из Linux под три платформы без единого тулчейна в системе
$ zig cc -target aarch64-linux-musl -O2 -o app-arm64 main.c
$ zig cc -target x86_64-windows-gnu -O2 -o app.exe   main.c
$ zig cc -target x86_64-linux-gnu.2.28 -O2 -o app-old main.c  # привязка к версии glibc
$ CC="zig cc" ./configure && make                             # подставляется в Autotools

Последняя строка решает боль, знакомую по сборке и компоновке: сборка под старую glibc без контейнера со старым дистрибутивом. Zig можно взять как систему сборки, оставив весь код на C, — обратимый шаг с нулевым риском для кода.

Цена Zig очевидна и её надо называть прямо: язык до версии 1.0. Ломающие изменения в каждом релизе, переработанный ввод-вывод в 0.15, изъятая и переделываемая асинхронность, небольшая экосистема пакетов, мало кандидатов на рынке. Для внутреннего инструмента или нового продукта, где команда готова обновлять код под релизы, это приемлемо; для десятилетней поддержки в регулируемой отрасли — обычно нет.

Go: когда «системное» на самом деле означает «сетевое»

Go в этом списке стоит особняком, и путаница здесь стоит дорого. Go — не замена C для драйверов и прошивок: у него сборщик мусора, планировщик горутин и рантайм, который нельзя выключить. Зато Go — очень хорошая замена C там, где на C писали не от хорошей жизни: демоны, агенты, прокси, CLI-инструменты, всё, что раньше было «сетевой сервис на C с epoll и пулом потоков».

Что Go меняет по сравнению с C в этом классе задач:

  • Память безопасна без вашего участия — ценой GC. Сборщик конкурентный, неперемещающий, паузы «стоп-мир» субмиллисекундные; подробности — в докладе Getting to Go и руководстве по GC.
  • Конкурентность на порядок дешевле в написании. Горутина стоит килобайты, а не мегабайт стека; вместо ручной работы с pthread_mutex_t из статьи о потоках — каналы и go. Детали — в конкурентности Go.
  • Дистрибуция тривиальна: один статический бинарник, кросс-компиляция переменными окружения, сборка секунды вместо минут.

Что вы теряете: контроль над раскладкой памяти (ограниченно, но всё же), предсказуемость хвостовых задержек под жёсткими SLA, возможность работать без кучи, и — главное — дешёвый вызов C.

# Кросс-компиляция и сборка без cgo: получается статический бинарник
$ CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags="-s -w" -o agent ./cmd/agent
$ go build -gcflags='-m' ./...     # что убежало в кучу: анализ побегов
$ go build -race ./... && ./app    # детектор гонок поверх ThreadSanitizer

Цена границы cgo

Если Go вызывает C, каждый вызов — не просто call. Горутина исполняется на стеке в несколько килобайт, который рантайм умеет наращивать копированием; C так нельзя, поэтому вызов уходит на системный стек, а планировщик должен отпустить исполняющий поток, чтобы блокировка в C не заморозила остальные горутины.

Практические следствия, о которые ошибаются чаще всего:

/*
#include <stdlib.h>
#include <string.h>
*/
import "C"
import "unsafe"

// Правило cgo: Go-указатель, ведущий на память с другими Go-указателями,
// передавать в C нельзя — GC не отслеживает ссылки, живущие в чужой куче.
func upper(s string) string {
    cs := C.CString(s)                  // malloc + копия: С-строка нужна с NUL
    defer C.free(unsafe.Pointer(cs))    // освобождает тот, кто выделил
    C.strupr_compat(cs)                 // работа на стороне C
    return C.GoString(cs)               // копия обратно в кучу Go
}

Две копии на вызов и переключение стека — вот почему совет «выносите горячие циклы в C» в Go обычно не работает: выигрыш съедается границей. Правильная стратегия — делать границу редкой и толстой: один вызов, обрабатывающий пакет данных, вместо тысячи мелких. Классический разбор — «cgo is not Go» Дэйва Чейни. Плюс cgo отключает кросс-компиляцию по умолчанию и требует C-тулчейна для каждой цели — то есть отдаёт главное преимущество Go.

Остальное поле

Язык Ниша, где он выигрывает у C Чем платите
C++ огромная кодовая база уже есть; RAII и шаблоны без рантайма — см. статью 12 UB никуда не делся, сложность языка, время сборки
Ada / SPARK авионика, ЖД, оборона: контрактное проектирование и формальные доказательства узкий рынок труда, дорогие тулчейны
Swift (Embedded) Apple-платформы и, с 2024 года, микроконтроллеры без рантайма экосистема сосредоточена вокруг одного вендора
C# с NativeAOT десктоп и сервисы, где нужен нативный старт без JIT GC остаётся, размер рантайма
Java с Panama (FFM API) доступ к нативным библиотекам без JNI из существующего Java-стека GC, стартовое время, footprint
Nim, Odin, D, Carbon точечные удобства: синтаксис, метапрограммирование, миграция с C++ малые сообщества, риск непрерывности

Общий принцип отбора: язык годится в системный проект, если у него есть (а) компилятор для вашего таргета, (б) режим без сборщика мусора или с предсказуемыми паузами, (в) способ говорить на C-ABI, (г) кто-то, кто будет чинить компилятор через пять лет.

Дерево решения

Обратите внимание, к чему сходятся ветки. Ответ «остаёмся на C» появляется не потому, что C лучше, а потому, что нет компилятора под таргет или требуется сертифицированный тулчейн. Это проверяемые факты, а не предпочтения — и именно так и надо формулировать решение в проектной документации.

Стратегия миграции: модуль, а не проект

Единственная стратегия с приемлемым риском выглядит так: не трогать работающий C, а вводить новый язык за границей, которая и так существует. Жизненный цикл одного модуля:

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

Живые примеры такого подхода, на которые можно ссылаться в разговоре с руководством: Prossimo от ISRG переписал TLS-стек (rustls), NTP-демон (ntpd-rs) и sudo (sudo-rs) — не «всю систему», а отдельные компоненты с большой поверхностью атаки. Curl поддерживает rustls как один из TLS-бэкендов, сохраняя основной код на C. Rust for Linux живёт в ядре с версии 6.1 — с абстракциями над C-API ядра, а не с переписыванием подсистем.

Что померить до решения, а не после

Решение должно опираться на числа вашего проекта, а не на бенчмарки из интернета:

$ size ./app && nm -C --size-sort -S ./app | tail -20   # что тянет за собой рантайм
$ /usr/bin/time -v ./app ...        # пиковый RSS под нагрузкой, а не в покое
$ ./bench --histogram               # p99 и p99.9: среднее ничего не скажет о паузах GC
$ cargo build --release --timings   # цена итерации: холодная и инкрементальная сборка
$ perf stat -e cycles,instructions ./bench-ffi   # стоимость границы, если она будет

Отдельная строка, которую часто забывают: сколько времени уйдёт у команды. Реалистичная оценка для инженера, знающего C: продуктивность на Rust — недели до первых задач и месяцы до комфорта с лайфтаймами и асинхронностью; на Zig — дни, потому что язык маленький, но плюс постоянные правки под новые релизы; на Go — дни.

Когда оставаться на C — честный список

Это не список утешений. Это ситуации, где переход объективно проигрывает.

Нет компилятора под таргет. DSP, экзотические микроконтроллеры, вендорские тулчейны (TI, Microchip, Renesas), где официально поддерживается только C конкретной версии. LLVM-бэкенда нет — разговор окончен. Смежная тема — встраиваемое программирование.

Сертификация и нормативные требования. DO-178C в авиации, ISO 26262 в автомобилях, IEC 62304 в медтехнике требуют квалифицированного тулчейна и трассируемости. Для C такие компиляторы существуют десятилетиями; для Rust они появились недавно (Ferrocene) и не покрывают всех комбинаций; для Zig их нет.

Вы и есть тот самый C-интерфейс. Библиотека, чей публичный API — заголовочный файл, встраиваемый в чужие проекты (zlib, SQLite, libpng): вы можете сменить язык реализации, но не язык интерфейса. Если реализация небольшая и стабильная — смена языка добавляет зависимостей больше, чем убирает рисков.

Кодовой базе тридцать лет и она работает. Плотность уязвимостей падает с возрастом кода: старый оттестированный код содержит меньше ошибок на строку, чем свежий. Переписывание переносит зрелый код в состояние «новый», то есть увеличивает число дефектов в моменте — это и есть главный вывод из данных Google по Android.

Команда и ресурсы. Пять человек, знающих C и предметную область, лучше двух, знающих Rust: смена языка без плана обучения теряет и то и другое. Сюда же — прошивка на 32 КБ флеша, загрузчик, обработчик прерываний: C здесь по-прежнему естественный выбор, хотя no_std Rust и Zig в эту нишу заходят.

Оставаться на C — нормальное инженерное решение, если оно сопровождается дисциплиной из статьи об UB: санитайзеры в отладочной сборке, _FORTIFY_SOURCE=3 и закалка в релизной, статический анализ и непрерывный фаззинг в CI, размерные API вместо голых указателей.

Типичные ошибки при выборе

Ошибка Почему это ошибка
«Rust безопасный, значит, ошибок не будет» безопасность памяти ≠ корректность; дедлоки, логика и unsafe остаются вашими
«Перепишем всё за квартал» переписывание возвращает зрелый код в состояние нового; риск растёт, а не падает
«Go — системный язык, напишем драйвер» GC и рантайм несовместимы с обработчиком прерываний и жёстким реальным временем
«Вынесем горячий код из Go в C» стоимость границы cgo часто превышает выигрыш; сначала измерьте
«Zig почти готов, подождём 1.0» ждать можно годами; либо принимаете ломающие изменения, либо не берёте
«Выберем язык, потом разберёмся со сборкой» кросс-компиляция, реализация libc и упаковка решают больше споров, чем синтаксис
«У нас C, значит, нам не подходит ничего» граница C-ABI существует всегда; вопрос только в том, что стоит по другую её сторону

Мини-итог

Про постановку задачи. Мигрируют не язык, а роли: код, сборку, интерфейс. C-ABI не заменяется и не должна — она и есть точка, где сосуществуют языки.

Про выбор конкретного инструмента. Rust — когда нужны проверяемые гарантии без рантайма и вы готовы платить сложностью и временем сборки. Zig — когда нужен контроль уровня C с современной эргономикой и вы принимаете статус pre-1.0; отдельно — как кросс-компилятор для существующего C, шаг с нулевым риском. Go — когда «системное» означает сетевые сервисы и инструменты, а GC-паузы укладываются в бюджет. C — когда нет компилятора под таргет, нужна сертификация, кодовая база зрелая или интерфейс обязан быть заголовочным файлом.

Про процесс. Новый код на безопасном языке даёт основной выигрыш; переписывание старого — почти никогда. Границу вводите там, где она уже есть; закрывайте её парными функциями создания и освобождения; проверяйте дифференциальным фаззингом; переключайте флагом с возможностью откатиться.

Про C. Даже если вы уходите — знание из этого трека не устаревает. Владение в Rust, аллокаторы в Zig, побеги в куче в Go, стоимость границы FFI, выравнивание структур, содержимое objdump — всё это понимают только те, кто прошёл через указатели, стек и компоновку.

Источники

Что дальше

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

А чтобы выбрать порядок и не распыляться — общая карта портала: дорожная карта обучения.

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

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

Доска запросов