habrahabr

Грязные трюки C++ из userver и Boost

  • вторник, 12 ноября 2024 г. в 00:00:13
https://habr.com/ru/companies/yandex/articles/852244/

Привет, я Антон Полухин из Техплатформы Екома и Райдтеха Яндекса. Моя команда разрабатывает userver — современный опенсорсный асинхронный фреймворк с богатым набором абстракций для быстрого и комфортного создания микросервисов, сервисов и утилит на C++.

Когда мы пишем какой‑то код для userver и для таких сложных проектов, как Boost, периодически мы сталкиваемся с нестандартными проблемами. И эти нестандартные проблемы требуют нестандартных решений. Вот о таких решениях мы сегодня и поговорим.

А именно:

  • Посмотрим, как работают исключения на платформе Linux x86, и сделаем с ними что‑то интересное.

  • Залезем ещё глубже под капот исключений и сделаем их ещё быстрее.

  • Сделаем висячую ссылку на невалидный объект, и всё будет хорошо.

  • А под конец то, что все любим, — погрузимся в шаблонное метапрограммирование.


Исключения

Во что компилятор превращает ключевое слово throw? Напишем функцию test, напишем внутри него throw, выкинем число как исключение.

void test(int i) {
    throw i;
}

Не пишите так в реальных проектах, этот код только для осознания того, как работают исключения! 

Давайте посмотрим, во что превратит компилятор написанную функцию. Для этого воспользуемся замечательным сайтом godbolt.org. Поставим флажки для оптимизаций и увидим, что два топовых компилятора GCC и Clang превращают вот эту жалкую одну строчечку throw в целый набор ассемблерных команд, внутри которых аж два вызова функций.

Первая функция называется __cxa_allocate_exception. И как несложно догадаться из названия, она аллоцирует память для исключения.

Наверху, перед вызовом функции, есть число 4 — размер нашего int. Это число — входной параметр функции. После него идёт код с записыванием typeinfo в какие‑то части проаллоцированной памяти и вызов __cxa_throw.

Функция __cxa_allocate_exception располагается в C++ Runtime на Linux‑платформах. То есть в библиотеке, которая линкуется со всеми бинарниками, которые написаны на C++.

Реализации у C++ Runtime разные, у каждого компилятора — своя. Посмотрим на реализацию от GCC, она не сильно отличается от clang. И в этой реализации есть интересный момент.

extern void *__cxa_allocate_exception (size_t) _ITM_NOTHROW WEAK;
extern void __cxa_free_exception (void *) _ITM_NOTHROW WEAK;
extern void __cxa_throw (void *, void *, void (*) (void *)) WEAK;

Так же как и в Clang‑реализации, __cxa_allocate_exception помечен атрибутом WEAK. То есть можно эту функцию взять и переопределить в своём коде. Давайте заглянем внутрь функции и выясним, что там происходит.

extern "C" void *
__cxxabiv1::__cxa_allocate_exception(std::size_t thrown_size) noexcept
{
  thrown_size += sizeof (__cxa_refcounted_exception);

  void *ret = malloc (thrown_size);

#if USE_POOL
  if (!ret)
    ret = emergency_pool.allocate (thrown_size);
#endif

  if (!ret)
    std::terminate ();

  memset (ret, 0, sizeof (__cxa_refcounted_exception));

  return (void *)((char *)ret + sizeof (__cxa_refcounted_exception));
}

__cxa_allocate_exception на самом деле аллоцирует память не только под тело исключения — наш integer —, но ещё заранее резервирует память под какую‑то служебную структуру __cxa_refcounted_exсeption. В неё записываются данные для исключения.

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

Итак, написали throw что‑то. Получили:

  • аллокацию под заголовок исключения и тело исключения;

  • зануление заголовка;

  • возврат указателя на место для тела исключения;

  • по указателю компилятор размещает значение исключения, служебную информацию и вызывает __cxa_throw.

И вот эти первые три пункта давайте сейчас возьмём и подменим своими, чтобы во все исключения подмешивать stacktrace. То есть в том месте, где вызывается throw, мы подменяем функцию аллокации исключений на нашу, внутри неё собираем stacktrace и после этого у нас все исключения будут со stacktrace.

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

Подмешиваем stacktrace

Итак, создаём свою функцию __cxa_allocate_exception

extern "C" BOOST_SYMBOL_EXPORT
void* __cxa_allocate_exception(size_t thrown_size) throw() {
  static const auto orig_allocate_exception = []() {
    void* const ptr = ::dlsym(RTLD_NEXT, "__cxa_allocate_exception");
    BOOST_ASSERT_MSG(ptr, "Failed to find '__cxa_allocate_exception'");
    return reinterpret_cast<void*(*)(size_t)>(ptr);
  }();

  if (!boost::stacktrace::impl::ref_capture_stacktraces_at_throw()) {
    return orig_allocate_exception(thrown_size);
  }

#ifndef NDEBUG
  static thread_local std::size_t in_allocate_exception = 0;
  BOOST_ASSERT_MSG(in_allocate_exception < 10, "Suspicious recursion");
  ++in_allocate_exception;
  const decrement_on_destroy guard{in_allocate_exception};
#endif

  static constexpr std::size_t kAlign = alignof(std::max_align_t);
  thrown_size = (thrown_size + kAlign - 1) & (~(kAlign - 1));

  void* const ptr = orig_allocate_exception(thrown_size + kStacktraceDumpSize);
  char* const dump_ptr = static_cast<char*>(ptr) + thrown_size;

  constexpr size_t kSkip = 1;
  boost::stacktrace::safe_dump_to(kSkip, dump_ptr, kStacktraceDumpSize);

#if !BOOST_STACKTRACE_ALWAYS_STORE_IN_PADDING
  if (is_libcpp_runtime()) {
    const std::lock_guard<std::mutex> guard{g_mapping_mutex};
    g_exception_to_dump_mapping[ptr] = dump_ptr;
  } else
#endif
  {
    BOOST_ASSERT_MSG(
      reference_to_empty_padding(ptr) == nullptr,
      "Not zeroed out, unsupported implementation"
    );
    reference_to_empty_padding(ptr) = dump_ptr;
  }

  return ptr;
}

Тут всё просто: имя функции должно совпадать с именем из C++ Runtime. Дальше нам понадобится указатель на изначальную функцию __cxa_allocate_exception. Мы не хотим заморачиваться с какими‑то занулениями памяти, хитрыми аллокациями, подсчётом размера служебных заголовков. Хотим просто позвать изначальную функцию из рантайма C++. Для этого пишем лямбду, которую тут же и вызываем, и внутри неё вызываем метод dlsym.

  static const auto orig_allocate_exception = []() {
    void* const ptr = ::dlsym(RTLD_NEXT, "__cxa_allocate_exception");
    BOOST_ASSERT_MSG(ptr, "Failed to find '__cxa_allocate_exception'");
    return reinterpret_cast<void*(*)(size_t)>(ptr);
  }();

Передаём туда имя функции, которую ищем. Вернётся указатель не на нашу функцию, а на функцию из C++ Runtime. Проверяем, что указатель не нулевой (он всегда должен быть не нулевым), а дальше делаем опять платформо‑специфичную вещь — преобразовываем void* в указатель на функцию.

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

Подправляем размер для аллоцирования и зовём изначальный метод __cxa_allocate_exception с новым размером:

static constexpr std::size_t kAlign = alignof(std::max_align_t);
thrown_size = (thrown_size + kAlign - 1) & (~(kAlign - 1));

void* const ptr = orig_allocate_exception(thrown_size + kStacktraceDumpSize);

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

char* const dump_ptr = static_cast<char*>(ptr) + thrown_size;

constexpr size_t kSkip = 1;
boost::stacktrace::safe_dump_to(kSkip, dump_ptr, kStacktraceDumpSize);

Метод safe_dump_to записывает указатели на фреймы трейса в память по указателю dump_ptr. Никакой символизации там не происходит, в человекочитаемый вид трейс не приводится.

Метод получения исключения

А дальше будет грязно... После того, как мы проаллоцировали память и вышли из функции __cxa_allocate_exception, компилятор не подскажет, какого размера был изначальный объект, который мы проаллоцировали. А объект может быть произвольного размера.

Можно кинуть char, и он будет размером 1 байт. Можно кинуть std::string, размер будет побольше. Можно кинуть что‑то размером в десятки килобайт. А чтобы воспользоваться stacktrace, нам надо знать, где начинается кусок памяти, куда мы его записали.

Нужно где‑то этот указатель «прикопать».

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

Нам везёт: в GCC и в Clang есть прям кусок памяти в служебном заголовке размером с указатель, куда нужную нам информацию можно записать. Но есть и плохая новость: в зависимости от того, какой у нас C++ Runtime, это место разбрелось по разным офсетам. То есть придётся немного повозиться, чтобы попасть в нужное неиспользуемое место служебного заголовка.

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

Полный код работы с байтиками и unorderd_map можно посмотреть в Гитхабе.

Полдела сделано — мы записываем трейсы во все исключения. Теперь всего лишь нужно из выкинутого исключения достать stacktrace. Для этого всё в той же библиотеке, где мы подменяем __cxa_allocate_exception, пишем вспомогательную функцию current_exception_stacktrace:

namespace impl {
const char* current_exception_stacktrace() noexcept {
    auto exc_ptr = std::current_exception();
    void* const exc_raw_ptr = get_current_exception_raw_ptr(&exc_ptr);
    if (!exc_raw_ptr) {
        return nullptr;
    }
    return reference_to_empty_padding(exc_raw_ptr);
}
}

В ней вызываем std::current_exception и получаем умный указатель на текущее исключение. С помощью арифметики из него достаём именно тот указатель, который был возвращён из __cxa_allocate_exception. И если всё пошло по плану, мы получаем нужное смещение, где записан указатель на stacktrace, или получаем этот указатель из std::unordered_map. Возвращаем указатель наружу из функции current_exception_stacktrace.

Дальнейшая магия происходит уже в заголовочном файле:

basic_stacktrace from_current_exception(Allocator alloc) noexcept {
    const char* trace = impl::current_exception_stacktrace();
    if (trace) {
        try {
            // Matches the constant from implementation
            constexpr std::size_t kStacktraceDumpSize = 4096;
            return from_dump(trace, kStacktraceDumpSize, alloc);
        } catch (const std::exception&) {
            // ignore
        }
    }
    return basic_stacktrace{0, 0, alloc};
}

Первым делом зовём служебную функцию, получаем указатель на stacktrace. Если трейса нет, возвращаем пустой. Если указатель есть, то данные копируем в объект типа boost::stacktrace, всё ещё не производя символизацию, и возвращаем boost::stacktrace. Теперь у нас есть boost::stacktrace, который содержит stacktrace из исключения.

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

Вот пример работы:

    try {
      foo();
    } catch (const std::exception&) {
      auto trace = boost::stacktrace::stacktrace::from_current_exception();
      std::cout << "Trace: " << trace << '\n';
    }

 А это результат:

Trace:
 0# get_data_from_config(std::string_view) at /home/axolm/basic.cpp:600
 1# bar(std::string_view) at /home/axolm/basic.cpp:6
 2# foo() at /home/axolm/basic.cpp:87
 3# main at /home/axolm/basic.cpp:17

Полученный механизм весьма удобен, если вы, скажем, пользуетесь Continuous Integration. Когда какой‑то тест проваливается, из него вылетает исключение, попробуй разберись, откуда оно вылетело и почему так произошло. И зачастую воспроизвести эту ситуацию на локальной машине весьма нетривиально. Добавляете stacktrace, печатаете все исключения, которые вылетели из неожиданных мест — и готово, у вас сильно упростилась отладка.

Выглядит решение страшновато и сложно? Хорошая новость. Всё это реализовано для платформы Linux в Boost, начиная с версии 1.85. Для платформы Windows всё совершенно по‑другому. Там исключения аллоцируются на стеке, и происходит совсем другая магия. Она доступна в Boost начиная с версии 1.86.

А тем временем мы переходим к холиварной теме.

Что лучше — исключение или коды возврата? 

Давайте разбираться. Пишем два кусочка кода.

В одном используем функцию nonthrowing_foo. Она сообщает о том, что ей плохо, с помощью кодов возврата. Если что‑то пошло не по плану, надо эту ошибку как‑то обработать. Мы обрабатываем её самым простым способом — убиваем приложение.

Если мы используем исключения, то просто зовём функцию, которая может кинуть исключение. Уже здесь видна разница между подходами. Подход с кодами возврата заставляет вас писать обработку ошибок внутри вашей бизнес‑логики. Соответственно код становится сложнее читать, и есть шанс что‑то забыть обработать в кодах возврата. Особенно если функция, которая сообщает об ошибке через коды возврата, не использует атрибут [[nodiscard]]. С исключениями всё просто: взяли, написали код, а обработка ошибок вынесена отдельно от бизнес‑логики и не мешается под ногами.

Что ж с производительностью? Опять воспользуемся Godbolt, закинем туда эти два примера. Увидим, что в коде, который занимается возвратом ошибок, есть ассемблерные инструкции на проверку кода возврата: если что‑то пошло не по плану, они перепрыгивают на то место, где идёт обработка ошибки.

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

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

На практике такого не бывает (надеюсь), и разница составляет единицы процентов. Есть замечательная статья, где автор сравнивает скорость работы парселки XML, написанной с исключениями и кодами возврата. Рекомендую для ознакомления.

Но все эти результаты для случая, когда мы исключения не выбрасываем. В противном случае, вы уже видели, какой ужас там творится: аллокация памяти, зануление этой памяти, какой‑то typeinfo куда‑то сохраняется, потом зовётся __cxa_throw. Кто‑то ещё может взять и какой‑нибудь stacktrace писать в это исключение, чтобы всё совсем уж было медленно.

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

Что снижает скорость исключений

Этим вопросом задались люди в комитете по стандартизации C++. И вот такие интересные результаты мы получили:

С ростом количества потоков, которые выбрасывают исключения, механизм исключений начинает деградировать и работать всё медленнее и медленнее. В некоторых реализациях механизма исключений есть глобальный mutex. При этом он захватывается не один раз на выброс исключения, а залочивается и разлочивается на каждый stack frame, который сворачивается при выбросе исключения.

Вот история из нашей практики. Мы пишем асинхронный фреймворк userver и хотим, чтобы всё работало супер‑пупер‑быстро. У нас ошибки — это редкий кейс, мы о них сообщаем через исключение.

Например, возьмём драйвер базы данных для Mongo. Большую часть времени с ним всё замечательно: база работает как часы, запросы быстренько идут, быстренько асинхронно обрабатываются. Но если вдруг базе стало очень плохо и она «прилегла отдохнуть», или сеть решила потупить, или какой‑нибудь роутер решил перезагрузиться и сетка пропала на несколько секунд…

Тогда сотня потоков приложения, работающего с базой данных, понимает, что базы данных нет. Выкидываются исключения, чтобы пользователь как‑то обработал эту ситуацию. После чего сервис десять с лишним секунд занимался тем, что залочивал, разлочивал мютексы и пытался протолкнуть исключения до catch‑блока, который их обработает. За этот десяток секунд база данных уже пришла в себя, сетка восстановилась, а сервис ещё парочку секунд продолжал мурыжить исключения. Нехорошо.

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

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

Подменяем dl_iterate_phdr

Эту идею мы позаимствовали от клёвых ребят из ScillaDB и творчески доработали. Итак, зовём изначальный dl_iterate_phdr и всё кешируем в std::vector. После чего подменяем WEAK-функцию dl_iterate_phdr своей, которая смотрит в кеш: если он есть, берёт данные с кеша, если нет, фолбетчится на изначальную оригинальную функцию dl_iterate_phdr

Готово! Вроде бы. 

Есть такой нюанс. Когда вылетает исключение, механизм обработки этих исключений зовёт dl_iterate_phdr, чтобы понять, к какому куску кода в бинарных файлах принадлежат указатели, как‑то связанные с исключением. То есть если мы что‑то закешировали, а потом пользователь взял и позвал dlopen, то появился ещё один кусочек бинарного кода, которого нет в нашем кеше.

Если из этого кусочка кода кинуть исключение, будет Undefined Behaviour. Исключение может не размотать стек или вовсе не пойматься. Для нас это неприемлемо. Мы должны гарантировать, что пользователь не накосячит, как бы он ни старался. Поэтому нужно сразу ему давать информацию о том, что он делает что‑то не так. А именно — что нельзя звать dlopen вот в этот момент, мы уже всё закешировали.

Что мы сделаем? Да как всегда — подменим изначальные WEAK‑функции для работы с динамическими библиотеками своими. В них получаем указатель на изначальную функцию, а потом зовем assert, что кеш ещё не заполнен. Если кеш уже взведён, значит, пользователь неправильно пользуется фреймворком и ему надо подсказать, как воспользоваться правильно.

Полный пример (и парочку других оптимизаций для работы с исключениями) можно найти в фреймворке userver. А если ваш проект совместим с лицензией Apache 2.0, то можно сразу воспользоваться этим кодом.

Страшное число 42

Хватит на сегодня исключений. Внимание, загадка! Есть вот такой код.

template <class T>
constexpr T unsafe_do_something() noexcept {
    typename std::remove_reference<T>::type* ptr = nullptr;
    ptr += 42;
    return static_cast<T>(*ptr);
}

И он делает что-то хтонически ужасное. Внутри этого кода есть указатель, он нулевой. К нему добавляется 42, после чего этот указатель разадресовывается и возвращается из функции. 

Внимание, вопрос: зачем такое писать?

Отгадка. Это написанная на коленке замена std::declval.

То есть вы пишете какой‑то сложный шаблонный код с метапрограммированием. Вы работаете с непонятными типами данных, которые вам передал пользователь, скажем, T. Но вот вам нужна для compile‑time‑вычислений ссылка на этот тип данных, чтобы что‑то из неё вывести. Обычно для этого вызывается std::declval, но некоторые компиляторы определяют, что ссылка потенциально может позваться в runtime, и не дают приложению скомпилироваться.

Вторая загадка. А зачем здесь += 42?

Отгадка. На разыменование nullptr ругнулся PVS‑Studio на статическом анализе. Инструмент сказал: «Ты что делаешь, человек? Ты же нулевой указатель разадресовываешь!» Некоторые компиляторы со временем подтянулись и стали выдавать похожую диагностику. Поэтому чтобы статические анализаторы не ругались, надо их запутать. Поэтому добавляем 42, и статические анализаторы начинают думать, что вы знаете, что делаете.

При этом очень не хочется, чтобы эту функцию кто‑то вызвал на runtime. Тогда вместо compile‑time‑проверки можно сделать LinkTime‑проверку. Для этого пишем функцию, у которой нет тела. Какую‑нибудь report_if_you_see_this_link_error_with_this_function. После чего эту функцию мы используем внутри нашей реализации declval.

Дальше происходит интересное. Компилятору на этапе компиляции безразлично, что мы зовём внутри нашего declval. Но если вдруг он оказывается в том месте, которое потенциально можно позвать на runtime, то линкеру потребуется тело функции report_if_you_see_this_link_error_with_this_function. И соответственно, если кто‑то попытается использовать наш declval неправильно, приложение его не слинкуется.

Пример реализации можно найти в Boost.PFR.

Compile-time-трюки

Переходим к тому, что все любят — compile-time-трюкам. Смотрите, что будет, если написать вот такую невзрачную функцию print()

#include <iostream>

template <auto member_ptr>
void print() {
    std::cout << __PRETTY_FUNCTION__ << std::endl;
}

Тут функция использует шаблонные параметры. Другая особенность этого print в том, что он использует платформоспецифичный макрос компилятора __PRETTY_FUNCTION__. Он разворачивается в строку с именем функции, внутри которой написан этот макрос. И ещё в этой строке содержатся все шаблонные параметры, с которыми была проинстанцирована эта функция print().

Например:

struct S {
    int the_member_name;
} s;

int main() { print<&s.the_member_name>(); }

Выведет это:

void print() [with auto member_ptr = (& s.S::the_member_name)]

Дальше интереснее. А именно попробуем разбить на части структуру с помощью Structured Binding.

struct S2 {
    int the_member_name;
    short other_name;
} s2;

int main() {
    const auto& [a, b] = s2;
    print<&a>();
    print<&b>();
}

Теперь мы явно не указываем имена полей. И всё равно компилятор правильно отображает имена этих полей:

void print() [member_ptr = &s2.the_member_name]
void print() [member_ptr = &s2.other_name]

__PRETTY_FUNCTION__ можно звать в compile‑time внутри constexpr и constval, а значит, с помощью небольших страданий с парсингом, можно из этой длинной строчки вычленить имя поля произвольной структуры.

Именно это было сделано очень клёвыми энтузиастами. Они сделали pull request в Boost PFR, и теперь там доступна из коробки функциональность по доставанию имён полей по их индексу.

Таким образом, мы получаем инструмент для написания универсального сериaлизатора любых агрегатов в строки (JSON, YAML).


Если вдруг у вас есть идеи, как сделать мир C++ лучше, пожалуйста, заходите на сайт stdcpp.ru, делитесь ими. А мы вам поможем их доработать и привнести в стандарт C++, если идея стоящая.

А если вам интересно посмотреть на то, о чём я сегодня рассказывал во фреймворке userver, то заходите к нам на Гитхаб.