Может ли Rust заменить C? Этот вопрос беспокоил меня много лет. Тем временем я успел написать upb — библиотеку C для работы с Protocol Buffers, и сейчас являюсь её техническим руководителем. Вполне понятно стремление обеспечить безопасность памяти в пределах всего программного стека — поэтому и возникла идея портировать upb на Rust.

Притом, что мне приятны базовые принципы Rust, я долгое время относился к этой идее скептически и сомневался, что, портировав upb на Rust, удастся сберечь её производительность и компактность кода, которые мы с коллегами так старались оптимизировать. На самом деле, исходно я собирался написать статью о том, почему именно применительно к upb языку Rust никогда не сравниться с C по производительности.

Но недавно я открыл для себя одну технику, которая заставила меня немного переосмыслить этот вопрос. Я назову её «Rust без паник». Притом, что этот метод определённо не нов, мне нигде не удалось найти подробного разбора, в котором бы рассказывалось, как именно этот метод используется и какие проблемы решает. Правда, интересная дискуссия по этому поводу велась в теме Enforcing no-std and no-panic during build, где есть ссылки на некоторые релевантные треды из почтовой рассылки, посвящённой разработке ядра Linux. Вот другой интересный тред: Negative view on Rust: panicking

Надеюсь, эта статья позволит заполнить данный пробел.

Я считаю, что Rust без паник — как раз та деталь, которая поможет обеспечить конкурентоспособность Rust в области низкоуровневого системного программирования. Теперь я гораздо оптимистичнее расцениваю возможность переноса upb на Rust.

Что такое паники?

Паники — это встроенный в Rust механизм для борьбы с неисправимыми ошибками. Когда наша программа сталкивается с ошибкой, существует три основных способа её обработать:

  1. Попытаться немедленно купировать ошибку (например, повторить операцию или откатиться к резервному плану B).

  2. Отправить ошибку обратно к вызывающей стороне, чтобы уже там можно было решить, что с ней делать.

  3. Немедленно прекратить выполнение.

В Rust предусмотрены Result для случая (2) и panic!() для случая (3). При применении Result подразумевается, что мы столкнулись с «исправимой ошибкой», поскольку вызывающая сторона может проверить её и решить, как на неё отреагировать.

Что касается исправимых ошибок, потенциал такой ошибки считывается по самой сигнатуре функции. Если функция возвращает Result, то с точки зрения вызывающей стороны она является уязвимой. С другой стороны, при использовании паник возникает иллюзия с точки зрения  API, но после этого лучше переходить к простому способу борьбы с ошибками — просто прекращать выполнение.

В стандартах неоднократно описано,  в каких случаях использовать panic!(), а в каких — Result (например, здесь и здесь). Все рекомендации в основном сводятся к следующей идее: паники необходимо применять только там, где в коде есть баги. Мне особенно нравится, как данная формулировка звучит в этом посте на Reddit:

[Если] ваша библиотека является источником паники, то явно будет верно одно либо другое:

  • У вас в библиотеке баг.

  • Ваша библиотека документирует предусловие конкретного публичного API, и если это условие не соблюдается, то возникает паника. Следовательно, пользователь неграмотно применил вашу библиотеку, и теперь у него в коде баг.

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

В этой статье мы сосредоточимся именно на библиотечном случае.

Почему паники в системных библиотеках - это плохо?

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

  1. Объём кода: Чтобы обработать панику, среде выполнения приходится подтянуть примерно 300 КБ кода. Да, этим приходится расплачиваться даже за одиночную panic!(), доступную в коде. С точки зрения объёма кода, это тяжелейшие издержки, учитывая, что размер ядра upb составляет всего 30 КБ. Если мы готовы пойти на #![no_std], то эти издержки можно сгладить, написав собственный обработчик паник. Если постараться, он получится гораздо компактнее того, что используется в std. Действительно, проблема с размером в таком коде решается, но он плохо компонуется, поскольку при таком подходе в каждом двоичном файле может быть лишь один обработчик паник, и в библиотеке теряется смысл предоставлять таковой.

  2. Выход без возможности восстановления: если инициирована паника, то прерывается весь процесс. Во многих приложениях это квалифицируется как тяжёлый отказ, и недопустимо, чтобы библиотека приводила к подобному. Правильно в таких случаях возвращать все ошибки вызывающей стороне, сопровождая их кодами состояния. Чисто технически в Rust некоторые паники можно отловить при помощи catch_unwind, но такой подход полон опасных нюансов и не предназначен для использования в качестве механизма восстановления после ошибок. 

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

В случае с upb меня беспокоили сразу все три этих фактора. В идеале хотелось бы портировать upb на Rust так, чтобы пользователи этого даже не заметили. Чтобы это сделать, хотелось бы удержать и производительность, и площадь, занимаемую кодом в памяти, и порядок выдачи отчётов об ошибках на том же уровне, какой сейчас имеется в коде на C. В такой идеальной картине паники только мешают.

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

Что такое Rust без паник?

Это такое подмножество Rust, в котором panic!() недоступны для использования. В программах, написанных на Rust без паник, паника гарантированно не возникает никогда, ни при каких обстоятельствах. Применительно к библиотеке это бы означало, что мы могли бы собрать такой вариант cdylib, в котором уже на этапе линковки не ставится никакой обработчик паник. В данном случае остановимся именно на cdylib, а не на staticlib, поскольку cdylib, чтобы сделать файл .so, придётся вызывать линковщик. В такой конфигурации предусматривается один проход на сборку мусора, при котором весь недостижимый код отбрасывается. Следовательно, будет учитываться только тот код, который действительно подтягивается при статическом связывании библиотеки в двоичный файл. 

Можно поэкспериментировать с godbolt.org и посмотреть, получается ли у нас задуманное. Воспользовавшись моим инструментом Bloaty, можете посмотреть, укладывается ли двоичный файл cdylib в 300 КБ (с учётом того, того, что обработчик паник был включён в этот файл при линковке или в10 КиБ (если допустить, что он не был включён). В Rust есть ряд пакетов (крейтов), специально спроектированных в расчёте на прямую проверку отсутствия паник, в том числе, no_panicpanic_never и no_panics_whatsoever. Для наших целей ни один из них не подходит идеально (второй и третий предназначены лишь для работы с двоичными файлами, но не с библиотеками, а первый придётся отдельно применять к каждой релевантной для нас функции). В любом случае, ни один из них не применяется в Godbolt. Вот как они обычно работают: пытаются спровоцировать ошибки связывания в том случае, когда обработчик паник включён в сборку.

Давайте немного подробнее познакомимся с этим подмножеством. Как будет «Hello, World» на Rust без паник?

#[no_mangle]
pub extern "C" fn hello_world() {
    println!("Hello, World!")    // возможна паника
}

Нет, в документации по println!() сказано:

Паникует, если не удаётся записать информацию в io::stdout.

Действительно, попробовав это в Godbolt, увидим большой двоичный файл:

Итак, println!() отметается. Если мы хотим делать вывод в stdout, то потребуется использовать такой API, в котором явно не заявляется, что такая паника возможна.

API stdout выглядит перспективно, поскольку содержит API write_all(), возвращающей Result. Благодаря этому у нас появляется возможность явно обрабатывать ошибки:

use std::io::{self, Write};

#[no_mangle]
pub extern "C" fn hello_world() -> bool {
    io::stdout().write_all(b"Hello, World!\n").is_ok()
}

Выглядит так, как будто здесь должно обойтись без паник. Мы обращаемся всего к двум  API, stdout() и write_all(), и ни для одной из этих функций в документации не упомянута потенциальная паника.

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

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

Как можно диагностиров��ть, что именно пошло не так? Под macOS у линковщика есть очень удобная опция -why_live, которая выведет в консоль цепочку символьных ссылок, предотвратившую выбраковку символа как мёртвого. К сожалению, в Godbolt у нас нет доступа к этой опции, но в macOS можно выполнить эту команду:

$ RUSTC_LOG=rustc_codegen_ssa::back::link=info \
  RUSTFLAGS="-C link-arg=-Wl,-why_live,_rust_panic" \
  cargo build --release 2>&1 | rustfilt

В результате (опуская все избыточные детали) получаем следующий вывод:

_core::panicking::panic from [...]
  _core::ops::function::FnOnce::call_once from [...]
    l_anon.56b0c16dbe4596c74313e318a3dfaa78.520 from [...]
      _std::sync::once_lock::OnceLock<T>::initialize from [...]
        _std::io::stdio::stdout from [...]
          _hello_world from [...]

Ссылка на панику явно идёт из core::ops::function::FnOnce::callonce, а этот код вызывается из _std::io::stdio::stdout.

Всё это наводит нас на мысль, что стандартная библиотека Rust, по-видимому, не соответствует озвученным выше критериям, поскольку допускает возникновение паник даже в таких API как std::io::stdout(), хотя в документации к этому API не описаны никакие условия, которые могли бы спровоцировать панику.

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

Чтобы получить такую версию «Hello, World», в которой совершенно не будет паник, нужно воспользоваться libc — это библиотека C. Это целесообразно, поскольку библиотека C обычно пишется так, чтобы все ошибки возвращались либо как коды состояний, либо как errno. К сожалению, это означает, что нам придётся прибегнуть к unsafe:

extern crate libc;

#[no_mangle]
pub extern "C" fn hello_world() -> bool {
    const MSG: &'static str = "Hello, World!\n\0";
    let result = unsafe {
        libc::printf(MSG.as_ptr() as *const _)
    };
    result >= 0
}

Проверив это в Godbolt, увидим, что двоичный файл маленький — и тем самым подтвердим, что эта библиотека действительно не содержит паник:

Вариант без паник - в режиме оптимизации

Попробуем сложить два числа. Не вызовет ли это панику?

#[no_mangle]
pub extern "C" fn hello_world(a: i32, b: i32) -> i32 {
    a + b
}

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

Это можно наблюдать в Godbolt, если добавить в окне отдельные секции для оптимизированных и неоптимизированных сборок. Правда, к сожалению, это, по-видимому, не работает в компиляторе Rust, применяемом для ночных сборок. В ночных сборках итоговый двоичный файл получается >300 КБ, и это свидетельствует о том, что в него включена среда для исполнения паник. Мне не удалось самостоятельно установить, почему так происходит.

В сущности, здесь образуется новый класс кода, который «работает без паник в оптимизированном режиме, но допускает паники в отладочном режиме».

В случае с библиотекой upb это просто отличный вариант, поскольку при нём мы можем позволить себе дополнительные проверки согласованности в отладочном режиме, но при этом не сталкиваться с проблемами из-за паник в релизных сборках. Так в Rust приобретается эквивалент assert() в C. Переполнения как таковые с точки зрения безопасности не так страшны, поэтому, избавляясь от паник в оптимизированных сборках, мы ничуть не жертвуем безопасностью.

Стандартная библиотека Rust

Что насчёт использования стандартных контейнеров, например, Vec?

use std::hint::black_box;

#[no_mangle]
pub extern "C" fn hello_world() {
    let vec: Vec<u32> = Vec::new();
    black_box(vec);
}

Оказывается, это тоже «оптимизированный код без паник» (возможно, на внутреннем уровне Vec выполняет какую-то арифметику, которая может вызывать переполнения):

Но как только мы попробуем фактически записывать элементы в Vec, мы сразу же оказываемся за пределами непаникующего Rust:

В Vec есть парочка API, которые вместо паник используют ошибки выделения, причём, эти ошибки всплывают. Теоретически, следующий код должен работать без паник:

#![feature(vec_push_within_capacity)]

use std::hint::black_box;

#[no_mangle]
pub extern "C" fn hello_world() -> bool {
    let mut vec: Vec<u32> = Vec::new();
    if !vec.try_reserve(1).is_ok() {
        return false;
    }
    if !vec.push_within_capacity(1).is_ok() {
        return false;
    }
    black_box(vec);
    true
}

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

На самом деле, стандартная библиотека Rust не рассчитана на работу без паник. Например, если не удастся выделить память — это, как правило, вызовет панику. Если мы хотим работать без паник, то от большей части материала стандартной библиотеки придётся отказаться. Реалистичный вариант – согласиться на полный #![no_std].

Танцы с оптимизатором

Вот ещё один каверзный вопрос: а это Rust без паник?

#[no_mangle]
pub extern "C" fn hello_world(data: &[u8]) -> u8 {
    if data.len() < 1 {
        return 0;
    }
    data[0]
}

С одной стороны, в документации по индексации срезов чётко сказано, что она может привести к панике. С другой стороны, согласно той же документации, паника может сработать лишь в том случае, если программа выйдет за пределы индекса — и мы вставили в код ограничивающее условие, гарантирующее, что такого ни в коем случае не произойдёт. Итак, достижима ли паника?

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

Так что в итоге этот пример доведён до состояния «в оптимизированном режиме работает без паник», точно как арифметическая операция – но по совершенно иной причине!

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

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

Чуть более опасный танец

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

Например, рассмотрим эту (откровенно надуманную) программу:

pub struct S<'a> {
    data: &'a[u8],
    ofs: usize,   // Инвариант: ofs < data.len()
}

impl<'a> S<'a> {
    pub fn new(data: &[u8]) -> Option<S> {
        match data.len() {
            0 => None,
            n => Some(S{data: data, ofs: n - 1}),
        }
    }

    pub fn get(&self) -> u8 {
        self.data[self.ofs]
    }
}

#[no_mangle]
pub extern "C" fn hello_world(s: &S) -> u8 {
    s.get()
}

В этой программе структура S обладает следующим инвариантом: сдвиг S::ofs всегда будет находиться в пределах границ. Фактически, этот инвариант гарантирует, что проверка границ в S::get() в любом случае окончится успешно. Причём, мы можем категорически гарантировать соблюдение этого инварианта, поскольку он обусловлен нашей функцией new() — а это единственный код, в котором задаются эти члены структур.

Но оптимизатор не способен рассуждать на таком уровне, поэтому считает, что паника в принципе возможна. Поэтому он оставляет в программе проверку границ, даже в оптимизированном режиме:

Чтобы обеспечить здесь отсутствие паник, нужно помочь компилятору, напомнив, что фиксирует этот инвариант структуры в критическом пути:

use std::hint::assert_unchecked;

pub struct S<'a> {
    data: &'a[u8],
    ofs: usize,   // Инвариант: ofs < data.len()
}

impl<'a> S<'a> {
    fn check_invariant(&self) {
        unsafe { assert_unchecked(self.ofs < self.data.len()) }
    }

    pub fn new(data: &[u8]) -> Option<S> {
        match data.len() {
            0 => None,
            n => {
                let s = S{data: data, ofs: n - 1};
                s.check_invariant();
                Some(s)
            }
        }
    }

    pub fn get(&self) -> u8 {
        self.check_invariant();
        self.data[self.ofs]
    }
}

#[no_mangle]
pub extern "C" fn hello_world(s: &S) -> u8 {
    s.get()
}

Здесь используется std::hint::assert_unchecked — очень острый инструмент, позволяющий формулировать компилятору обещания о разумности кода. Здесь мы с его помощью проинформировали компилятор об инварианте нашей структуры. Тем самым мы добились желаемого эффекта: в оптимизированном виде код работает без паник.

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

Заключение

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

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

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

Для практического внедрения этой техники нужен инструмент, который позволял бы диагностировать, откуда был доступен обработчик паники. Основной приём, описанный в этой статье (проверка размеров двоичного файла) ничего не сообщает нам о том, откуда именно пришла паника. Под macOS для этой цели отлично подходит опция связывания -why_live. Надеюсь, и в других линковщиках, например, в LLD, появится поддержка этой опции. Если нет — можно было бы написать самостоятельный инструмент, который мог бы анализировать двоичный файл уже после связывания и находить такую цепочку ссылок, которая приведёт нас к обработчику паники.

Хорошо бы в Rust в принципе упростить возможность работать в пределах такого подмножества языка, которое не предполагает паник. Естественно, написание кода без паник не является центральной практической составляющей этого языка, но во многих сферах (встраиваемые системы, ядро Linux, т.д.) паник хочется избежать. Было бы здорово, если бы функции или даже пакеты могли сами позиционировать себя как работающие без паник и сообщать компилятору, что это свойство является транзитивным. В таком случае преобразование функции из непаникующей в паникующую приводило бы к перестройке API.

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


  1. middle
    31.10.2025 10:45

    1. Для арифметики можно использовать wrapping-методы или даже тип-обёртку Wrapping.

    2. Для доступа к памяти можно использовать указатели, раз у нас уже всё равно unsafe и мы полагаемся на "обещания о разумности кода". Анализировать корректность явного доступа к памяти будет проще, чем assert, спрятанный в check_invariant.


  1. Kelbon
    31.10.2025 10:45

    Хорошо бы в Rust в принципе упростить возможность работать в пределах такого подмножества языка, которое не предполагает паник

    можно просто взять С++ или С

    оптимизатор Rust.


    это называется LLVM )


    1. sdramare
      31.10.2025 10:45

      Взять С++ и получить и получить UB в миллионе мест, это конечно то что надо для системной библиотеки.