Этот пост написан по мотивам выступления, с которым мы с Шисянь Ван ездили на конференцию Rust UnConf, организованную нью-йоркским сообществом Rust. Конференция UnConf собрала поистине потрясающий коллектив энтузиастов a Rust, в компании которых мы более двух часов посвятили глубоким техническим дискуссиям (а также поеданию мороженого). Далее при необходимости я буду ссылаться на опыт нашей компании Antithesis.

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

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

Фаззер создаёт дерево состояний, в котором учтены байты управляющих сигналов, и некоторые из этих байтов находят баги, а другие — нет:

Логическая часть фаззера, которую я буду называть «контроллер», отвечает за то, откуда начинать и какие значения давать на вход. Например, на следующей картинке мы начинаем с «зелёного» состояния (обозначено f3) и предоставляем входные байты 6f, 64, 70:

Фаззер написан на однопоточном C++. Мы сделали так, чтобы можно было задействовать детерминированное имитационное тестирование для проверки фаззера — ещё до того, как успели написать детерминатор.  У нас есть разные контроллеры, которые различными способами пытаются находить баги, и все они взаимодействуют с центральным фаззером через интерфейс обратных вызовов следующим образом (код в этом посте в основном следует интерпретировать как «Rust-подобный псевдокод»):

poll_for_inputs(&controller) -> (start state, inputs)
advertise_outputs(&controller, states)

В главном цикле фаззера (1) делается вызов в метод poll_for_inputs контроллера, смысл которого таков: «с чего я должен начать и что должен делать?». В (2) делается вызов в метод advertise_outputs контроллера, и смысл этого действия таков: «я сделал, что мне было задано, и вот какие значения возвращает на выход система после того, как мы прогнали её на детерминаторе». Под возвращёнными на выход значениями здесь понимается вся информация, поступающая от тестируемой системы и доступная для наблюдения: сообщения из логов, данные об использовании ЦП, утверждения и т.д.

Пару лет назад мы добавили в фаззере возможность делать вызовы прямо в код на Rust, так, чтобы было проще реализовывать новые стратегии управления. Мы пользовались этой функциональностью, чтобы исследовать новые управляющие стратегии, но никакая часть этого кода на Rust в продакшне не задействовалась. В свою очередь, код на стороне Rust у нас многопоточный и асинхронный. Контроллер на стороне Rust использует асинхронный интерфейс, устроенный в общем виде так: «начни отсюда, дай на вход эти значения, затем дождись значений, которые поступят на выход, далее вернись». Интерфейс C++, работающий на основе обратных вызовов, устроен совсем иначе.

В этом посте рассказано, как нам удалось состыковать многопоточный асинхронный Rust с однопоточным синхронным C++. Эта история на 90% о Rust и на 10% о C++, и в части о Rust мы определённо углубимся в дебри. Но не бойтесь, ведь мы вместе, и по пути, возможно, даже успеем обменяться парой шуток.

Основы

Начнём с некоторой базовой информации. Как сочетать C++ и Rust, не усложняя код излишне из-за несовпадения синхронного и асинхронного элемента? И как сочетать какой угодно синхронный и асинхронный код в Rust, не привнося в него дополнительную сложность из C++?

А также, какие проблемы у нас возникнут, если мы решимся реализовать такую комбинацию?

Комбинация C++ и Rust

Чтобы обеспечить взаимодействие Rust и C++, воспользуемся пакетом (крейтом) Rust cxx, который создаёт интерфейс внешних функций (FFI) между C++ и Rust. Напомню, этот пост — прежде всего о Rust-составляющей проекта, но, что касается C++, обычно мы обёртываем функции при помощи идиомы pimpl, так что нам не приходится перекомпилировать весь код на стороне C++ всякий раз, когда мы меняем код на стороне Rust. Так вот, интерфейс FFI позволяет определить три рода вещей:

  1. Extern-типы Rust: типы Rust, предоставляемые для использования в C++. Инструментарий cxx создаёт заголовочный файл C++, который вы затем включаете, и генерирует код, преобразующий присущие C++ соглашения о вызовах в соглашения о вызовах Rust. Таким образом, вы сможете делать вызовы из C++ в Rust.

  2. Extern-типы C++: типы C++, предоставляемые для использования в Rust. Вы указываете присущую Rust сигнатуру для вызовов функций, а cxx сопоставляет её с имеющимися функциями C++ (в соответствии с действующими правилами). Cxx преобразует соглашения о вызовах так, чтобы ваш код на Rust мог направить вызов в C++, просто обратившись за функцией Rust с заданной сигнатурой.

  3. Разделяемые структуры: типы, в которых не содержится ни методов, ни функций (т.e., это просто чистые структуры). Вы объявляете их на Rust, а cxx создаёт заголовочный файл C++. Так что на стороне C++ вы можете их создавать, использовать или делать и то, и другое. Можете свободно передавать эти структуры туда и обратно. Большая разница между ними и предыдущими двумя типами заключается в том, что эти разделяемые структуры — всего лишь типизированные варианты компоновки в памяти. Ни по какую сторону от интерфейса с ними не связано никакого исполняемого кода, поэтому и не приходится преобразовывать соглашения о вызовах.

Сочетание синхронного и асинхронного кода

Ключевая идея здесь заключается в том, что мы собираемся кое-что написать на асинхронном многопоточном Rust – это будет «асинхронная часть» на схеме ниже — и этот код будет передавать информацию по асинхронным каналам. Вот некоторая документация о к��нкретных вариантах реализации асинхронных каналов. Под термином «асинхронный канал» я буду понимать «всё, что угодно, что работает именно так», а не «данный конкретный модуль Rust».

Другим концом эти каналы закреплены в синхронном Rust, который синхронно вызывается из C++. Синхронный Rust будет отправлять данные по асинхронным каналам и получать информацию оттуда, а также выполнять прямые и обратные передачи между форматами C++ и Rust. Можно синхронно опрашивать асинхронные каналы Rust и не допускать блокирования, если в очереди/канале данных не окажется. Например, при помощи этого синхронного метода.

Более сложная логика контроллера будет на чистом Rust, без необходимости задумываться о привлечении C++ ни для преобразования объектов, ни для обработки несоответствий между синхронным и асинхронным кодом.

Проблема 1: потоконебезопасные объекты

Уладив все предусловия, давайте подробнее разберём код, схематически представленный выше.

Он нетривиален с технической точки зрения: start и result_states — это объекты C++ (типа State), и нам придётся передавать их туда-сюда от потока к потоку. При вызове execute мы передаём start справа налево, где run его отправляет, а poll_for_inputs — получает. Затем в advertise_outputs мы передаём result_states слева направо, а run его получает. По умолчанию cxx не реализует для типов C++ ни Send, ни Sync. (Мы также отправляем inputs, но это нативный объект Rust.)

Интерлюдия: отправка и синхронизация

Обсуждение потокобезопасности в Rust не может претендовать на полноту, если не затронуты отправка и синхронизацияSend и Sync — это два маркерных типажа (т.e., типажи, не имеющие методов), сообщающие вам — а ещё важнее, сообщающие компилятору — что безопасно и небезопасно проделывать с типом в ходе операций, связанных с потоками.

Немного перефразируя формулировки из документации по стандартной библиотеке:

  • Тип T является Send, если не будет опасно предоставить ему исключительный доступ (T или &mut T) в пределах различных потоков

  • Тип T является Sync, если не будет опасно предоставить ему совместный доступ (&T) в пределах различных потоков

Широко известно такое следствие: T является Sync тогда и только тогда, когда &T является Send.

Обычно компилятор реализует Send и Sync за вас автоматически, опираясь на собственные заключения о коде Rust. Естественно, компилятор Rust не может судить о коде C++, поэтому не реализует за вас эти типы автоматически. Но вы можете реализовать их вручную, если считаете это целесообразным.

Возвращаясь к задаче

Полагаю, сейчас самое время упомянуть, что я программирую на C++ с 90-х, а на Rust — примерно два с небольшим года. Описываемые здесь события происходили около 2 лет назад, когда я был в Rust совсем новичком. Хоть какое-то мне оправдание.

Компилятор Rust жаловался, что не может передать State через границы потоков, поскольку у него не реализован Send, так что я написал следующий код:

unsafe impl Send for State {}

Вопрос на засыпку: как думаете, это хорошо кончилось?

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

Почему же именно происходит отказ этого кода?

На стороне C++ код примерно такой:

struct State {
    ref_ptr<StateImpl> impl;
    ...
}

Здесь ref_ptr — это класс, реализующий указатель с подсчётом ссылок. Подобно Rc или Arc в Rust или shared_ptr в C++. В частности, ref_ptr не потокобезопасен. Поэтому, когда мы используем объекты State на стороне Rust, иногда мы сталкиваемся с условиями гонки, при которых получаем неверно подсчитанное количество ссылок, после чего удаляем объект, который до сих пор используется. Далее при попытке обратиться к этому объекту получаем ошибку сегментирования.

Например, допустим, что какая-то сущность в главном потоке C++ увеличивает счёт ссылок, а в асинхронном потоке Rust находится какая-то другая сущность тоже его увеличивает. В результате счётчик вырастет на 2, но, если мы столкнёмся с условиями гонки, счётчик вырастет всего на 1, пусть даже ссылки есть у двух сущностей. Далее, если один из потоков уменьшит количество ссылок на единицу, выйдет в 0 и удалим этот объект, это приведёт к оцепенению другого потока.

Таким образом, State явно не есть Send. Любые операции, затрагивающие подсчёт ссылок (клонирование или отбрасывание) допустимы только в главном потоке C++. Напомню формулировку из интерлюдии: небезопасно владеть State в другом потоке, поскольку при попытке его клонировать или отбросить (такие операции можно делать, если владеешь T) подсчитанное количество ссылок изменится, что небезопасно делать в других потоках. По той же причине небезопасно иметь &State в другом потоке, поскольку невозможно клонировать совместно используемую ссылку в другом потоке.

Решение

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

В нём используется две структуры Rust. Структура CppOwner существует только в главном потоке и владеет оригинальным объектом C++. Структура CppBorrower выступает в качестве ссылки на объект C++. Вполне нормально передавать CppBorrower от потока к потоку; необходимо, чтобы CppOwner существовала в главном потоке. Если мы захотим что-то отбрасывать, то сначала должны будем отбросить все CppBorrower, а потом — CppOwner (в главном потоке):

Только в главном потоке допустимо делать так:

pub struct CppOwner<T> {
    value: Arc<T> 
}

impl<T> CppOwner<T> {
    pub fn borrow(&self) -> CppBorrower<T> {
        CppBorrower { value: self.value.clone() }
    }

    pub fn has_borrowers(&self) -> bool {
        Arc::strong_count(&self.value) > 1
    } 
}

impl<T> Drop for CppOwner<T> {
    fn drop(&mut self) {
        if self.has_borrowers() {
            panic!("No!");         
        }
    }
}

Во всех потоках:

pub struct CppBorrower<T> {
    value: Arc<T> 
}

impl<T> Clone for CppBorrower<T> {
    fn clone(&self) -> Self {
        Self { value: self.value.clone() }
    }
}

unsafe impl<T: Sync> Send for CppBorrower<T> {}

impl Deref ...

Чтобы это использовать, создаём CppOwner в главном потоке и держим её под рукой, пока «работа идёт», и в процессе работы передаём CppBorrower туда, где она может нам понадобиться:

// В главном потоке
let cpp_state = CppOwner::new(state.cpp_clone());
// Отправляем заимствование другим потокам через асинхронный канал
channel.send(cpp_state.borrow());
// Отслеживаем CppOwner
self.in_flight.insert(cpp_state);

Позже, также в главном потоке, проверяем, какие объекты C++, остающиеся «в работе», в данный момент никто не заимствует — и отбрасываем их:

// Позже, но всё равно в главном потоке
self.in_flight.retain(|s| s.has_borrowers());

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

Резюмируем: мы создаём структуры CppOwner в главном потоке и отслеживаем, сколько из них сейчас «в работе». По мере надобности мы создаём CppBorrower, что��ы свободно передавать их куда нужно. В главном потоке CppOwner попадает под сборку мусора, как только у него не останется CppBorrower. Поскольку все операции, затрагивающие счёт ссылок (в C++) происходят только в CppOwner в главном потоке, нам удаётся избежать той гонки, которая случалась у нас ранее.

Проблема при проектировании

Этот подход оказался рабочим. Мы пользовались им около двух лет.

А потом случилось такое, из-за чего нам пришлось усомниться в спроектированном решении: кто-то ещё попытался воспользоваться Rust-интерфейсом для фаззера. В боевом коде!

Из-за этого нам пришлось тщательнее задуматься о методологии. Сборка мусора не слишком эффективна — время от времени мы запускаем цикл для перебора всех имеющихся CppOwner, опрашивая каждого из них: «нужно ли тебя удалить»? Если поступать так часто то много работы будет делаться впустую. Если нечасто — то придётся расходовать гораздо больше памяти, чем следует. Принципиально объём той работы, которую следует сделать, должен быть пропорционален количеству удалений, а в рассматриваемой сейчас реализации он пропорционален количеству объектов. Или, может быть, количеству объектов, помноженному на количество итераций. Пока мы работали с потоками операций, характерными для исследовательских задач, это работало нормально, так как нам не приходилось одновременно держать под рукой большое количество объектов, но в продакшне такой случай оказался проблематичен. (Иными словами, объём работы зависит от количества объектов, которые приходится держать под рукой, а не от немногочисленных объектов, которые мы удаляем.)

Решение получше

Ранее у CppOwner был Arc<T> (где T — это тип C++):

struct CppOwner<T> {
    value: Arc<T>
}

CppOwner приходилось оставаться в главном потоке, и мы могли лишь передавать CppBorrower-ы куда-либо.

В новой версии мы решили так, чтобы CppOwner непосредственно владел T (он, а не Arc<T>):

struct CppOwner<T> {
    value: T
}

Затем мы передаём Arc<CppOwner<T>> куда нужно. Чтобы это происходило безопасно, в ситуации, когда счёт ссылок снижается до нуля, и мы отбрасываем CppOwner<T>, мы отправляем T обратно в главный поток на удаление.

На первый взгляд план отличный, но с ним сразу же возникает загвоздка. Это можно сделать лишь при условии, что CppOwner<T> — это Send, а это происходит автоматически лишь при условии, что T — это Send. А мы не хотим, чтобы каждый тип C++ делал unsafe impl Send (ранее в примере с ошибкой сегментирования мы видели, насколько это опасно). Итак, как же CppOwner<T> может быть Send, когда T — не Send?

SendWrapper

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

Вот структура SendWrapper<T>, которая может передавать T через границы потоков:

pub struct SendWrapper<T>(T);

// Даже когда T: !Send  
unsafe impl<T> Send for SendWrapper<T> {}

В данном случае суть — в комментарии: SendWrapper<T> является Send, даже когда сам T не является.

Но, поскольку T может быть или не быть Send, небезопасно предоставлять исключительный доступ T, поэтому мы должны тщательно следить за тем, чтобы SendWrapper<T> этому препятствовал. Не составляет труда не предоставлять никакого способа получать &mut T (например, SendWrapper не реализует DerefMut), но при этом также нужно убедиться, что при этом не отбрасывается SendWrapper. Поэтому мы пишем такой код:

impl<T> Drop for SendWrapper<T> {  
    fn drop(&mut self) {  
        panic!("Cannot drop a SendWrapper!")  
    }  
}

Более качественная CppOwner

Теперь мы хотим заложить правильную логику в CppOwner, чтобы отправлять T обратно в главный поток при отбрасывании:

pub struct CppOwner<T>(ManuallyDrop<SendWrapper<T>>);

Теперь CppOwner содержит SendWrapper (обёрнутую в ManuallyDrop, это обёртка, не позволяющая компилятору автоматически вызывать деструктор T.).

В нашей реализации Drop для CppOwner подтягивается SendWrapper, которая затем помещается в специальную «очередь на отбрасывание», которая отправляет её обратно в главный поток:

impl<T> Drop for CppOwner<T> {  
    fn drop(&mut self) {  
        let val: SendWrapper<T> = unsafe { ManuallyDrop::take(&mut self.0) };  
        DROP_QUEUE.push(val);  
    }  
}

Подождите, а что это за DROP_QUEUE? Это статический экземпляр нового типа DropQueue. Этот тип определяется так:

pub struct DropQueue<T>(ConcurrentQueue<SendWrapper<T>>);

А в DropQueue есть метод drain, который мы вызываем в главном потоке, чтобы извлечь и отбросить T. (Вызывать drain безопасно только в главном потоке, поскольку отбрасывать T безопасно только в главном потоке.)

impl<T> DropQueue<T> {  
    // ОСТОРОЖНО: вызывать только в главном потоке  
    pub unsafe fn drain(&self) {  
        for val in self.0.try_iter() {  
            drop(unsafe { val.unwrap_unchecked() })  
        }  
    }  
}

Здесь в SendWrapper используется другая функция, о которой мы ещё не говорили — небезопасный метод, при помощи которого можно достать T из SendWrapper.

Можно нанести немалый вред, если вызвать этот метод в неподходящее время, но мы так делать не будем. Мы пометим его как небезопасный, так что здесь действует обычное правило «будьте крайне осторожны и убедитесь, что здесь вы делаете именно то, что нужно» плюс правило «убедитесь, что соблюдаете все правила, прописанные в комментариях с пометкой ОСТОРОЖНО». Кроме того, SendWrapper используется только внутри CppOwner; мы не предоставляем её другому коду, вызываемому за пределами этой структуры.

impl<T> SendWrapper<T> {  
    pub unsafe fn unwrap_unchecked(self) -> T  
}

Вот мы и решили проблему со сборкой мусора. Метод отбрасывания, относящийся к CppOwner, отправляет объект обратно в главный поток для последующей деструкции. Поэтому работа, которую приходится выполнять, пропорциональна только количеству сущностей, которые нам требуется отбрасывать, а не количеству итераций. Более того, мы в основном избавились от CppBorrower, заменив её на Arc<CppOwner>.

Проблема 2: потоконебезопасные функции

Вернёмся немного назад, к нашему исходному решению для сборки мусора с CppOwner и CppBorrower, но ещё до того, как система была улучшена при помощи SendWrapper. Тогда обнаружилась и другая проблема.

Вот пример кода, который мы написали:

async fn run() {
  loop {
    let (start, inputs) = create_rollout_somehow();
    let result_states = execute(start, inputs).await;
    let details = result_states.get_details();
    do_something_with(details);
  }
}

Здесь start — это объект C++ типа State, который мы преобразовали в CppOwner<State> и CppBorrower<State>, так, чтобы корректно работали и сборка мусора, и подсчёт ссылок. Но при этом на стороне C++ есть функция get_details, которую небезопасно вызывать между потоками; её безопасно вызывать только в главном потоке. Функция get_details подробнее сообщает нам, что происходит с тестируемой системой, а именно: какие сообщения логируются, на какие утверждения мы в этом случае натыкаемся, отказал ли какой-то процесс или контейнер, т.д.

Более общее правило: функции на стороне C++ могут при вызовах между потоками действовать как опасно, так и безопасно; данная функция — безопасна.

Первое решение

Вот как можно решить эту проблему:

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

В асинхронной части кода:

1. Создадим объект запроса, содержащий параметры для функции

2. Передадим объект запроса из одного потока в другой через канал «request»

В синхронной части, написанной на Rust:

1. Опросим канал на предмет того, есть ли в нём объект запроса

2. Вызовем функцию C++ с заданными параметрами

3. Запишем результаты во второй асинхронный канал, который называется “results

Далее на стороне асинхронного кода дожидаемся результатов, которые должны поступить в этот канал “results”.

В целом такой паттерн сработал; требовалось лишь внимательно следить за тем, что именно вызывается в главном потоке, а что — в произвольных потоках.

Проблема при проектировании

На этапе подготовки всего этого к продакшну нас, в частности, волновали очень тонкие вопросы, связанные с безопасностью потоков. В Rust действует такая модель: структура может быть или потокобезопасна, или нет (причём, существует два варианта потокобезопасности: Send и Sync). Но мы обнаружили, что некоторые методы в структуре могут быть потокобезопасны, тогда как другие таковыми не являются. Это несовпадение между двумя языками оказалось фундаментальным; речь не о том, что какой-то вариант правилен, а какой-то нет; они просто разные. А нам требовалось заставить их работать вместе.

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

Резюмируя данную проблему, вот чего мы хотим:

  • Работать с «функциями, которые можно вызывать только в главном потоке»

  • Выяснить, как смоделировать частичную потокобезопасность для некоторых из наших типов C++

  • Работать безопасно и эргономично, чтобы коллеги по команде могли пользоваться этим кодом, не внося в него неочевидных багов

  • Определить и хорошо документировать обязательства по обеспечению безопасности в коде на C++ (какие вещи разрешено и не разрешено делать в том коде на C++, который взаимодействует с кодом на Rust)

Более качественное решение

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

MainThreadToken

Мы хотим пометить функции, как доступные для вызова только из главного потока, и для этого мы придумали структуру MainThreadToken. Что она доказывает: имея дело с MainThreadToken, мы определённо находимся в главном потоке. Код кажется обманчиво простым:

#[derive(Clone, Copy)]  
pub struct MainThreadToken(PhantomData<*mut ()>);

Если вы ранее не пользовались PhantomData, объясню: это конструкция Rust, в которой никаких данных не хранится; она просто позволяет компилятору «вообразить, что у данной сущности именно этот тип». Так что весь этот токен занимает 0 байт оперативной памяти, но действует, как будто имеет тип  mut(). Тип mut гарантирует, что эта структура не является ни Send, ни Sync.

Этот код гарантирует, что невозможно будет передавать MainThreadToken между потоками, но не гарантирует, что вы выполнили эту операцию именно в главном потоке. Вы могли бы сделать это и в любом другом потоке, и тогда бы застряли в нём, а не в главном потоке C++. Так что вот вам ещё немного кода:

pub static MAIN_THREAD_ID . . .

// Гарантированно встретится где-то в главном потоке  
initialize(MAIN_THREAD_ID);

/// # Осторожно
/// Эта функция обязательно должна вызываться именно из главного потока фаззера  
pub unsafe fn new() -> Self {  
    assert_eq!(*MAIN_THREAD_ID, std::thread::current().id());  
    Self(PhantomData)  
}

Здесь есть два элемента управления, частично дублирующих друг друга. Ключевого слова unsafe и комментария об осторожности должно быть достаточно, чтобы с этим кодом обращались правильно. Но у нас предусмотрена ещё и (2) проверка во время выполнения, так что, если вы используете код неправильно, то получите панику, а не неопределённое поведение. Настоящий пурист мог бы ограничиться только unsafe и предупреждением об осторожности, но проверка во время выполнения в этом коде служит дополнительной защитой от дурака.

В принципе, не следует слишком часто вызывать этот new(). Можно вызвать его всего один раз, а затем скопировать или склонировать полученный в результате токен (в том же потоке).

Чем нам это поможет? Если мы имеем дело с функцией, которую безопасно вызывать только в главном потоке, как, например, метод drain, о котором мы говорили выше:

// ОСТОРОЖНО: вызывать только в главном потоке
pub unsafe fn drain(&self)

то её можно обезопасить при помощи MainThreadToken:

pub fn drain(&self, _token: MainThreadToken)

Фактически, мы не используем MainThreadToken где-либо в реализации; просто сам факт его наличия и возможность вызвать с ним функцию означает, что мы в главном потоке. Так что компилятор может статически проверить, в самом ли деле вы вызываете метод (например, drain) только там, где это следует делать.

Моделирование потокобезопасности для наших типов C++

Крейт cxx позволяет различать два типа методов C++: константные (которые становятся &self на стороне Rust) и неконстантные (которые становятся Pin<&mut Self> на стороне Rust).

C++

Rust

int get_data() const;

fn get_data(&self) -> i32;

void push(int x);

fn push(self: Pin<&mut Self>, x: i32) -> ();

Мы собираемся оставить неконстантные как есть — ведь &mut Self гарантирует, что они никогда не будут вызываться конкурентно, а  SendWrapper как раз обеспечивает, чтобы вы не получали &mut–доступ. Таким образом, неконстантные функции можно вызывать из C++ только в главном потоке.

Но  с константными методами всё несколько сложнее. Const в C++ означает, что двоичные разряды представления в функции не меняются. Но вполне возможно, что класс является указателем на другой класс, и, пусть указатель и не меняется, что-то в другом классе конкурентно изменится. Причём, даже не хочется заговаривать о ключевом слове  mutable в C++…

Например, если в каком-то другом коде есть копия указателя other, он может изменить и тем самым повлиять на значение, возвращаемое do_const.

struct Other {
    int x;
};

struct MyObj {
    Other* other;
    int do_const() const {
        return other->x;
    }
};

Если углубиться ещё сильнее, do_const не может менять указатель other, но может изменить x. Так что код other->x++ в do_const совершенно корректен.

Методы Sync и Unsync

Очевидно, по одному лишь const не определить, опасно или безопасно будет вызывать метод C++ в других потоках, поэтому реализуем такое различие самостоятельно. Те вещи, которые можно безопасно вызывать из других потоков, можно назвать “sync”, а те, которые нельзя — “unsync.” (Точные определения дам ниже.)

На стороне C++ определим два неинтеллектуальных «маркерных макроса» для SYNC и UNSYNC:

#define SYNC
#define UNSYNC

В сущности, они ничего не делают. Но с их помощью мы можем помечать наши функции. Например:

int get_immutable_data() SYNC const;
int get_mutable_data() UNSYNC const;

Например, вполне можно представить, как get_immutable_data возвращает значение ID, устанавливаемого в конструкторе, а get_mutable_data разыменует указатель и возвращает из него некоторое значение. При этом содержимое базового объекта вполне может меняться (как в примере с MyObj выше).

Эти макросы — не для компилятора, а для нас с вами. Когда мы определяем класс, при последующем код-ревью не составляет труда посмотреть, (1) использовал ли кто-то SYNC и UNSYNC? и (2) правильно ли это было сделано? Если автор кода вообще не прибегал к этим макросам, то мы отправляем отсмотренный код ему обратно с просьбой: «пожалуйста, разберись с этим и добавь».  

Дойдя до этого места, переименуем функцию unsync так, чтобы подчеркнуть, что она именно несинхронная. Пока нам это не помогает, но поможет, когда мы доберёмся до Rust. В частности, в cxx используется одно и то же имя для функций на Rust и C++, и мы хотим, чтобы в имени на Rust был фрагмент _unsync.

Вот что у нас получается:

int get_immutable_data() SYNC const;
int get_mutable_data_unsync() UNSYNC const;

Что представляют собой методы Sync и Unsync?

Итак, что именно означает это различие SYNC/UNSYNC, которое мы специально предусмотрели?

1.    У неконстантных методов может быть исключительный доступ. Например, речь может идти о неатомарных записях или операциях конструктора/деструктора

2.    У константных синхронных методов может быть синхронизированный совместный доступ. Эти функции являются потокобезопасными без внешней синхронизации. Это может быть, например: чтение неизменяемых данных, атомарные операции, а таже такие операции, при которых класс C++ реализует синхронизацию.

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

В любых условиях методы “const, sync” должны быть безопасны для конкурентного вызова как с другими методами “const, sync”, так и с методами “const, unsync”. Однако методы “const, unsync” безопасно вызывать только с методами “const, sync”, но не с другими методами  “const, unsync”. Причём, ни одни из них не безопасно вызвать конкурентно с неконстантными методами. Эти взаимосвязи обобщены в следующей таблице:

Безопасны ли конкурентные вызовы?

non-const

const, sync

const, unsync

non-const

Нет

Нет

Нет

const, sync

Нет

Да

Да

const, unsync

Нет

Да

Нет

Методы Sync и Unsync на стороне Rust

Для подобных SYNC-методов:

int get_immutable_data() SYNC const;

на стороне Rust просто будем использовать сигнатуры, заданные по умолчанию, например:

fn get_immutable_data(&self) -> i32;

Для подобных UNSYNC-методов:

int get_mutable_data_unsync() UNSYNC const;

на стороне Rust делаем метод небезопасным и добавляем комментарий «Осторожно»:

/// # Осторожно: только в главном потоке
unsafe fn get_mutable_data_unsync(&self) -> i32;

На данном этапе можем пометить тип Rust (произведённый cxx от типа C++) как Sync. Не Send, а просто Sync (это означает, что будет безопасно ставить на него разделяемую ссылку).

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

fn get_mutable_data(&self, _token: MainThreadToken) -> i32 {  
    // ОСТОРОЖНО: мы в главном потоке фаззера, поскольку владеем `MainThreadToken`.  
    unsafe { self.get_mutable_data_unsync() }  

Любой, кто станет пользоваться этим классом, должен вызывать безопасную версию функции. (Кстати, именно поэтому мы добавили _unsync в конце имени функции, чтобы имя безопасной версии не казалось странным.)

Вызов методов C++ в других потоках

Давайте ненадолго вернёмся к SendWrapper<T>. Ранее мы не упоминали, что для него есть несколько реализаций:

unsafe impl<T> Send for SendWrapper<T> {}

// &SendWrapper<T> -> &T, only if T is Sync  
impl<T: Sync> Deref for SendWrapper<T> . . .

Таким образом, SendWrapper всегда реализует Send, независимо от того, делает ли это T. Но она реализует Deref лишь в том случае, если T реализует Sync. Поэтому мы: (1) не можем получить T или &mut T, (2) можем получить &T лишь в случае, когда T является Sync (т.e., если &T может безопасно пересекать границы потоков).

Ух, получилось немного многословно. Что же это значит? Это значит, что мы можем определить пару различных видов структур Rust, произведённых от структур C++:

1.    Структуры, которые можно протаскивать куда нужно, заключая их в SendWrapper<T>, но в других потоках никаких их методов вызывать нельзя

2.    Структуры, методы которых допустимо вызывать в других потоках

Если как следует к этому присмотреться, то может возникнуть вопрос: а для чего вообще могут пригодиться структуры типа (1). Суть — в окончании формулировки, а именно в невозможности вызывать какие-либо методы в других потоках.  При использовании модели с объектом запроса можно передавать SendWrapper<T> туда-сюда от потока к потоку, а затем вызывать методы в главном потоке.

В случае (2) наша реализация Deref означает, что требуется реализовать Sync в оригинальном типе C++ T, чтобы иметь возможность вызывать функции класса в различных потоках:

// Длинный комментарий о безопасности
unsafe impl Sync for SomeCppType {}

Здесь мы, опять же, опускаем длинный комментарий, в котором разъясняется вся схема и обязательства, которые мы берём на себя в рамках файла cxx (это просто обычный файл Rust, в котором мы используем именно макросы cxx, а не что-либо иное), где идут строки impl Sync.

Обобщая этот метод, резюмируем: мы хотим вызывать любые функции типа в разных потоках, и для этого делаем следующее.

На стороне C++ мы:

1.    Используем маркеры SYNC или UNSYNC в любом константном методе, в зависимости от того, опасно вызывать метод сразу во многих потоках или нет. Это важно. Ранее я писал «конкурентно», но более точная формулировка – «во многих потоках». Так, если одновременно можно вызывать метод всего в одном потоке, пусть даже последовательно вызывая метод сначала в одном потоке, затем в другом и т.д. – то это unsync.

2.    К именам unsync-функций добавляем суффикс _unsync.

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

На стороне Rust мы:

1.    Используем то же имя, что и на стороне C++

2.    Все методы _unsync помечаем как unsafe, сопровождая соответствующим комментарием о безопасности.

3.    Делаем безопасную версию функции unsync, но без функции unsync, и для этого мы используем MainThreadToken ().

4.    Помечаем тип как Sync. (Небезопасные методы unsync от этого «явно отказываются», а безопасные версии этих методов unsync требуют MainThreadToken, который обязывает использовать правильный поток).

5. Также могут действовать дополнительные требования к безопасности.

Это решение сделано гораздо более в стиле Rust, но вполне устраивает и меня как специалиста по C++. На стороне C++ мы точно определили некоторые вещи и знаем, что именно проверять при код-ревью. Таким образом, гарантируется, что предоставляемые нами функции построены правильно. (Меня как специалиста по C++ устраивает формулировка «и будьте аккуратны, а разработчик что-либо проверит в ходе код-ревью» при условии, если чётко определено, что понимается под этим «что-либо». На стороне Rust, как только мы применим все вышеприведённые правила, компилятор сможет удостовериться, что функции, где бы они ни использовались, используются правильно. Комбинация первого и второго обеспечивает исправное совместное функционирование обеих частей.

Подытожим методологию

В этой стране разобрано много материала. При помощи крейта cxx мы делаем вызовы из C++ в Rust, причём, код C++ однопоточный, а код Rust — многопоточный. Из-за этого возникает две основные проблемы.

Первая проблема заключается в потоконебезопасных объектах, и исходно мы решали эту проблему при помощи структур CppOwner и CppBorrower. Затем мы улучшили это решение при помощи новой версии CppOwner, включающей структуру SendWrapper для перетаскивания типа C++ между потоками, а CppOwner при этом обрабатывал отбрасывание компонентов, отправляя SendWrapper обратно в главный поток, где её можно безопасно удалять.

Вторая проблема заключается в потоконебезопасных функциях. Её мы решили, выработав определённые соглашения о том, как именовать и помечать функции на стороне C++, а затем помечать некоторые функции на стороне Rust как unsafe. Также мы создали «доказательный» тип MainThreadToken, который можно иметь только в главном потоке. С его помощью мы делаем безопасные версии небезопасных функций.

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