Мы используем куки, чтобы пользоваться сайтом было удобно.
Хорошо
to the top
>
>
>
Путеводитель C++ программиста по неопре…

Путеводитель C++ программиста по неопределённому поведению: часть 12 из 11

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

1211_book_pt_12_ru/image1.png

Почему часть 12 из 11? Потому что невозможно не обыграть эту излюбленную ошибку C и C++ программистов, которая даже имеет собственное название — Off-by-one Error. Да и не стоит отказываться от традиции ;)

std::vector::reserve и std::vector::resize

Очень многие программы на C++ становились и становятся жертвами подлого обмана со стороны этих двух братьев-близнецов. А также, естественно, невнимательности, спешки и автодополнения.

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

Но вот беда, у std::vector нет конструктора, в котором бы можно было указать: "Хочу пустой вектор, но с зарезервированной capacity = N". У вектора есть другой конструктор, заполняющий N-элементами по-умолчанию:

// Создаст N пустых строк
std::vector<std::string> text(N);

Но это ведь не оптимально: создать целый вектор, проинициализировать каждый элемент в нём, чтобы потом переписать их... Нет-нет, это неправильное использование C++!

У нас есть метод reserve(), его надо вызвать после создания пустого вектора.

std::vector<std::string> text;
text.reserve(N);

Но снова напасть! Ведь у нас есть ещё и метод resize(), который также может принять ровно один аргумент, если элементы вектора конструируемы по умолчанию.

И, разумеется, программисты их путают:

  • имена короткие и начинаются одинаково;
  • имеют схожий смысл;
  • стоят рядом в выдаче автодополнения;
  • и в наши дни ещё и ИИ-ассистент может не того из них посоветовать!

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

auto read_text(size_t N_lines) {
  std::vector<std::string> text;
  text.resize(N_lines);    // Ай!
  for (size_t line_no = 0; line_no < N_lines; ++line_no) {
    std::string line;
    std::getline(std::cin, line);
    text.emplace_back(std::move(line));
  }
  return text; 
}

Но никакого неопределённого поведения. Эх!

Но что, если программист написал reserve() там, где на самом деле требовался resize()? Ну случайно. Причём эта случайность имеет довольно неплохой шанс, ведь выдача автодополнения часто упорядочена по алфавиту, а reserve стоит в нём раньше.

std::pair<std::vector<std::byte>, size_t>
  read_text(std::istream& in, size_t buffer_len)
{
  std::vector<std::byte> buffer;
  buffer.reserve(buffer_len);
  in.read(reinterpret_cast<char*>(buffer.data()), buffer_len);
  return {
    std::move(buffer), static_cast<size_t>(in.gcount())
  };
}

int main() {
  auto [buffer, actual_size] = read_text(std::cin, 45);
  for (size_t i = 0; i < actual_size; ++i) {
    std::cout << static_cast<int>(buffer[i]) << "\n";
  }
}

Программа будет успешно работать!

// Пример ввода/вывода
>> hello
104
101
108
108
111

И вот здесь стоит на минуту остановиться и отвлечься. Я множество раз видел на самых разных технических форумах заявления вида:

  • "С и C++ — это языки для работы близко к железу";
  • "Undefined behavior — это просто формальность";
  • "Если программист знает, как работает память, как работает его программа и типы, он может эту ерунду игнорировать";
  • и так далее.

Случай с reserve() выше может быть использован в качестве защиты такой спорной позиции. Действительно. Я знаю, что:

  • reserve() действительно выделяет память, так что диапазон [buffer.data(), buffer.data() + buffer_len) валиден;
  • std::istream::read проинициализировал память в диапазоне [buffer.data(), buffer.data() + actual_size);
  • operator[] вектора по умолчанию не проверяет переданный индекс;
  • Доступ к вектору в цикле происходит в инициализированных пределах.

Поэтому всё работает. Более того, оно даже работает без language undefined behaviour. Если вы скопируете реализацию std::vector к себе, удалите из неё все asserts с условными инструментациями санитайзеров и станете пользоваться вот таким же странным образом, у вас в коде не будет неопределённого поведения. По крайней мере, в этом примере.

Но. Это std::vectorstd! И он декларирует library undefined behaviour — вы обратились к элементу с индексом пределами [0, vector::size()). А это не прощается.

Я не смог найти ни одного компилятора, доступного онлайн, на котором бы можно было воспроизвести последовательность оптимизаций, приводящих к падению невероятной красоты. Но я видел несколько закрытых баг-репортов в отношении Apple Clang, который такое проворачивал.

LLVM может генерировать под x86 инструкцию ud2 — это недопустимая инструкция, часто используема как индикатор недостижимого кода. Если программа попытается её выполнить, то умрёт от сигнала SIGILL. Код, который провоцирует неопределённое поведение, может быть помечен как недостижимый и в дальнейшем заменён на ud2 или выброшен. В нашем замечательном примере компилятору вполне известно, что buffer.size() == 0. И его не меняли.

Так, например, если мы попробуем один в один переписать это же безобразие в Rust, агрессивно использующем возможности LLVM:

fn read(n: usize, mut reader: impl std::io::Read) ->
  std::io::Result<(Vec<u8>, usize)>
{
  // Резервируем память. Она будет неинициализированной.
  let mut buf = Vec::<u8>::with_capacity(n);

  // unsafe Rust довольно сложен, и формально здесь
  // нельзя напрямую создавать &mut [u8]
  // на неинициализированную память.
  // Но "мы знаем, что делаем" — на результат это не повлияет.
  // Пока...
  let actual_size = reader.read(unsafe {
      std::slice::from_raw_parts_mut(buf.as_mut_ptr(), n)
  })?;
  // В отличие от C++, в Rust можно сделать
  // unsafe { buf.set_len(actual_size) };
  // И сделать этот пример практически корректным.
  // Но мы здесь собрались смотреть на Undefined Behavior.
  Ok((buf, actual_size))
}

pub fn main() {
  let (buf, n) = read(42, std::io::stdin()).unwrap();
  for i in 0..n {
    println!("{}",
      unsafe { buf.get_unchecked(i) }
    )
  }
}

В дебажной сборке мы упадём с ошибкой сегментации и сообщением:

thread 'main' panicked at library/core/src/panicking.rs:220:5:
unsafe precondition(s) violated: slice::get_unchecked requires
that the index is within the slice
note: run with `RUST_BACKTRACE=1` environment variable to
display a backtrace thread caused non-unwinding panic. aborting.
Program terminated with signal: SIGSEGV

В релизной с -C opt-level=3 мы увидим пустой вывод и успешный выход. И, если посмотрим на сгенерированный код, то цикла мы в нём не обнаружим. Код обращения к элементам вектора был полностью выброшен как недостижимый. Спасибо аннотации assert_unsafe_precondition!(check_language_ub, ...).

example::main::h67df0b7f9b5f8d1a:
      ....
      call    qword ptr [rip + <std::io::stdio::Stdin as
                std::io::Read>::read::h30ce8d6974df759c@GOTPCREL]
      mov     esi, 42
      test    rax, rax
      jne     .LBB1_3    # Это unwrap
      mov     edx, 1
      mov     rdi, rbx
      call    qword ptr [rip + __rust_dealloc@GOTPCREL]
      add     rsp, 8
      pop     rbx
      pop     r14
      ret
.LBB1_7:

"Ну это же Rust!" — можно возразить, закатывая глаза. Да. Но это лишь дело времени, когда Clang начнёт применять те же оптимизации к C++.

Что же делать?

По-хорошему, конечно, не ошибаться и не путать reserve() и resize()...

Как выяснилось после множества экспериментов с разными утилитами, состояние диагностики подобного standard library level неопределённого поведения в C++ в 2024 году остаётся весьма плачевным:

  • Статические анализаторы, к сожалению, молчат;
  • Санитайзеры по умолчанию тоже не реагируют;
  • _ITERATOR_DEBUG_LEVEL от MSVC молчаливо падает;
  • -fsanitize=address перестаёт молчать только лишь с -stdlib=libc++ ==1==ERROR: AddressSanitizer: container-overflow on address 0x504000000050 at pc 0x59481461d1b0 bp 0x7ffcf01b08b0 sp 0x7ffcf01b08a8 READ of size 1 at 0x504000000050 thread T0.

Но стойте, стойте! А что, если это не ошибка? Мы сознательно использовали reserve(), так как он не инициализирует память. И хотели её, как в примере с Rust, переписать какими-нибудь данными из файла и в конце изменить size(). Но вектор просто не предоставляет такое API...

На этот случай в стандартной библиотеке C++ есть целых два более корректных способа.

std::make_unique_for_overwrite


std::pair<std::unique_ptr<std::byte[]>, size_t>
read_text(std::istream& in, size_t buffer_len)
{
  // Выделяем default-инициализированный буфер,
  // но default инициализация массива тривиальных объектов — это
  // отсутствие инициализации.
  auto buffer = std::make_unique_for_overwrite<std::byte[]>(buffer_len);
  in.read(reinterpret_cast<char*>(buffer.get()), buffer_len);
  return {
    std::move(buffer), static_cast<size_t>(in.gcount())
  };
}

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

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

C++23. Std::basic_string::resize_and_overwrite

Да! Чудо случилось, и в C++23 мы можем сделать всё почти так же замечательно и эффективно, как в Rust. Но только для "строк". Но ведь по старой доброй традиции из С, у нас строки — это просто последовательность байт...

// Придётся написать немного СharTraits магии,
// если мы хотим использовать
// std::basic_string c типом std::byte.
struct ByteTraits {
  using char_type = ::std::byte;
  static char_type* copy(char_type* dst,
                         char_type* src, size_t n) {
    memcpy(dst, src, n);
    return dst;
  }
  static void assign(char_type& dst, const char_type& src) {
    dst = src;
  } 
};

std::basic_string<std::byte, ByteTraits>
  read_text(std::istream& in, size_t buffer_len)
{
  std::basic_string<std::byte, ByteTraits> buffer;
  buffer.resize_and_overwrite(buffer_len,
                              [&in](std::byte* buf, size_t len) {
    in.read(reinterpret_cast<char*>(buf), len);
    return static_cast<size_t>(in.gcount());
  });
    
  return buffer;
}

int main() {
  auto buffer = read_text(std::cin, 45);
  size_t actual_size = buffer.size();
  std::cout << actual_size << std::endl;
  for (size_t i = 0; i < actual_size; ++i) {
    std::cout << static_cast<int>(buffer[i]) << "\n";
  }
}

И ура! Оно также работает, как ожидается.

Унарный минус и беззнаковые числа

Вы разрабатываете графический интерфейс для игры. У вас уже есть кнопочки, панельки, иконки. Всё отлично. И вот вы решаете, чтобы интерфейс ощущался интереснее, реализовать анимацию для checkbox элемента — при нажатии для снятия галочки он будет красиво отъезжать в сторону где-то на 30% своей ширины.

У вас были подобные структуры и функции:

struct Element {
  size_t width; // original non-scaled width
  ....
};

// You are using smart component system that uses
// IDs to refer to elements.
using ElementID = uint64_t; 

// Positions in OpenGL/DirectX/Vulkan worlds are floats
struct Offset {
  float x;
  float y;
};

size_t get_width(ElementID);
float screen_scale();
void move_by(ElementID, Offset);

И вы добавили свою:

void on_unchecked(ElementID el) {
  auto w = get_width(el);
  move_by(el, Offset {
    -w * screen_scale() * 0.3f,
    0.0f
  });
}

Ваш checkbox был шириной 50 пикселей. Вы запустили тест... и элемент улетел за пределы экрана!

Вы пошли смотреть логи и обнаружили:

Offset: 5.5340234e+18 0

Как же так?! Неопределённое поведение?

Нет. Вполне определённое.

Всему виной унарный минус, который мы случайно применили к беззнаковой переменной.

For unsigned a, the value of -a is 2^N − a,
where N is the number of bits after promotion.

Это очень злобная ошибка, которую не диагностируют Clang и GCC с флагами -Wall -Wextra -Wpedantic; MSVC же имеет такую диагностику.

Статические анализаторы, например, PVS-Studio, также могут найти ошибку.

В более современных языках программирования применение унарного минуса к беззнаковым значениям чаще всего не компилируется. Так, например, сделано в Rust, Zig и в Kotlin.

Полезные ссылки

Невыровненные ссылки

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

Байтики у программиста были упакованными, чтобы никакого лишнего выравнивания! И поля у него были упорядочены также, чтобы никакого лишнего выравнивания:

#pragma pack(1)
struct Record {
  long value;
  int data;
  char status;
};

int main() {
  Record r { 42, 42, 42};
  static_assert(sizeof(r) == sizeof(int) + sizeof(char) + sizeof(long));
  std::cout <<
    std::format("{} {} {}", r.data, r.status, r.value); // 42 - '*'
}

Он проверял этот код с санитайзером, и санитайзер говорил ему, что всё в порядке:

Program returned: 0
42 * 42

Ну раз всё в порядке, то можно больше байтиков отформатировать!

int main() {
  Record records[] = { { 42, 42, 42}, { 42, 42, 42}  };
  static_assert(sizeof(records) ==
                2 * ( sizeof(int) + sizeof(char) + sizeof(long) ));
  for (const auto& r: records) {
    std::cout << std::format("{} {} {}", r.data, r.status, r.value); // 42 - '*'
  }
}

И что-то взорвалось (под ARM бы уж точно):

Program returned: 0
/app/example.cpp:16:48: runtime error: reference binding to
misaligned address 0x7ffd1eda9f85 for type 'const int',
which requires 4 byte alignment
0x7ffd1eda9f85: note: pointer points here
 00 00 00 00 2a 00 00  
 00 2a 00 00 00 00 00 00 
 00 00 00 00 00 00 00 00 
 03 00 00 00 00 00 00 00  b0

Да, нельзя читать невыровненную память, это влечёт неопределённое поведение. Мы это уже знаем. Нельзя разыменовывать невыровненный указатель. Но вот беда, в C++ же есть ссылки, и они тоже обязаны быть правильно выровненными.

Мы точно видим одну ссылку:

for (const auto& r: records);

Но там же не тип const int! Ну да, это Record, и с ней всё в порядке. #pragma pack(1) задаёт требование к выравниванию 1, так что тут никакой проблемы.

Откуда же взялась ссылка на const int?

А она у нас неявно взялась. Ведь неявное создание ссылок — это ключевая особенность C++!

template< class... Args >

// Вот они эти два коварных &&!
std::string format( std::format_string<Args...> fmt, Args&&... args ); 

// Все три поля будут переданы по ссылке!
std::cout << std::format("{} {} {}", r.data, r.status, r.value);

Да, "универсальная ссылка" — это всё ещё ссылка.

В упакованной структуре поля не выровнены. Ссылки на них брать нельзя.

Но ведь в первоначальном варианте с одной структурой всё работало без предупреждений...

Ха! Нам просто повезло, что:

  • поля в структуре упорядочены так, что и без pragma pack нет паддинга между ними;
  • стек обычно выровнен на sizeof(void*), чего достаточно для всех полей в структуре.

Мы можем добавить один лишний char на стек, и всё изменится.

int main() {
  char data[1];
  Record r { 42, 42, 42};
  memset(data, 0, 1);
  std::cout <<
    std::format("{} {} {}", r.data, r.status, r.value); // 42 - '*'
}

Program returned: 0
/app/example.cpp:17:44: runtime error:
reference binding to misaligned address 0x7ffe3b4e1f36 for type 'int',
which requires 4 byte alignment
0x7ffe3b4e1f36: note: pointer points here
 00 00 00 00 2a 00  00 00 2a 00 00 00 00 00 
 00 00 00 00 00 00 00 00  00 00 00 00 00 00 00 00  00 00

Как же исправить это досадное недоразумение?

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

int main() {
  Record records[] = { { 42, 42, 42}, { 42, 42, 42}  };
  for (const auto& r: records) {

    // В C++23 для этого есть замечательный auto()
    std::cout << std::format("{} {} {}",
      auto(r.data), auto(r.status), auto(r.value)); 

    // В С++20 
    auto data = r.data; auto status = r.status; auto value = r.value;
    std::cout << std::format("{} {} {}", data, status, value); 

    // Или совершенно уродливо и неустойчиво к изменениям в типах
    std::cout << std::format("{} {} {}", static_cast<int>(r.data), 
                                         static_cast<char>(r.status), 
                                         static_cast<long>(r.value>));
  }
}

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

В Rust:

#[repr(C, packed)]
struct Record {
  value: i64,
  data: i32,
  status: i8, 
}

fn main() {
  let r = Record { value: 42, data: 42, status: 42 };
  // В Rust макросы одно из немногих мест,
  // где ссылки могут появляться неявно для читающего код
  println!("{} {} {}", r.data, r.status, r.value); 

  /*
  error[E0793]: reference to packed field is unaligned
  --> <source>:10:26
      |
   10 |     println!("{} {} {}", r.data, r.status, r.value);
      = note: packed structs are only aligned by one byte,
              and many modern architectures penalize unaligned
              field accesses
      = note: creating a misaligned reference is undefined
              behavior (even if that reference is never dereferenced)
      = help: copy the field contents to a local variable,
              or replace the reference with a raw pointer and
              use `read_unaligned`/`write_unaligned`
              (loads and stores via `*p` must be properly aligned
              even when using raw pointers)
  */

  // Вот так правильно:
  println!("{} {} {}", {r.data}, {r.status}, {r.value});
}

Владение, исключения и ошибки

Команде однажды завели баг-репорт: "Сервис упал c segmentation fault. В core dump стэк-трейс указывает как последнюю функцию перед падением что-то из вашей библиотеки. Разберитесь!" Упал сервис ровно один раз за полгода.

Этим чем-то был вызов free где-то глубоко-глубоко внутри библиотеки Protobuf. И несколько последующих стэк-фреймов указывали на вызов деструктора уже в нашей библиотеке. Потратив некоторое время на анализ кода деструктора, дежурный инженер не нашёл ничего подозрительного и предположил, что это похоже на какую-то ранее встреченную проблему в Protobuf. И как воспроизвести, никто не представлял. Тупик...

Я заинтересовался этой загадочной историей и залез в core dump поглубже.

На пару десятков стэк-фреймов выше, уже принадлежащих чужому сервису, засветилась функция lru_insert. Это интересно. Это оказалась функция вставки в LRU-кэш. И уже можно было заподозрить, что, возможно, вызов деструктора как-то связан с вытеснением объекта из кэша.

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

auto metadata = new Metadata(....);
metadata->cached = true;
lru_insert(cache, key, metadata);
// check if insert successfull!
if (auto item_handle = lru_get(cache, key)) {
  ....
} else {
  // not found -> it's not cached
  metadata->cached = false;
}

Если есть голый new, то где-то должен быть и delete... И я нашёл его. Целых два.

Один при создании кэша:

auto cache =
  lru_create(n, [](void* data){ delete static_cast<Metadata*>(data); });

А второй где-то в другом месте:

if (!metadata->cached) {
  delete metadata;
}

Дело пахнет повторным удалением. Но что же здесь пошло не так?

lru_insert(cache, key, metadata);
// check if insert successfull!
if (auto item_handle = lru_get(cache, key)) {
  ....
} else {
  // not found -> it's not cached
  metadata->cached = false;
}

Код восхитителен тем, что явно выполняет целых два обращения к кэшу: на вставку и на проверку. Но ведь можно ограничиться только одной вставкой: если lru_insert предоставит информацию об успехе. Может ли дело быть в этом? Нет ли случайно в этом сервисе гонок, которые могут вклиниться между вставкой и проверкой? Но меня уверили, что процесс однопоточный.

Наверное, стоит углубиться в функцию lru_insert. Её написали 10 лет назад и больше не трогали. Её протестировали, она надёжна. Как я могу в ней сомневаться?

void lru_insert(Cache* c, const char* key, void* data) 
{
  try {
    c->cache.insert(std::string(key), 
                    boost::intrusive_pointer(new LRUItem(data,
                                             c->deleter)));
  } catch (...) {}
}

От увиденного мне стало дурно. Ведь здесь опять голый new, который может привести к утечке в самом редком и очень часто игнорируемом случае: если конструктор std::string бросит исключение (std::bad_alloc). И, как мы видим, эта функция полностью игнорирует пойманные исключения.

Но, как утверждает Rust, утечка — это совершенно безопасно! И к use-after-free или double-free привести не может. А мы получили segfault с очень большой вероятностью именно из-за double-free.

Есть, конечно, ещё вариант, что cache.insert может бросить исключение, не выполнить вставку и заставить intrusive_pointer удалить объект. Но этот сценарий не согласуется с увиденным core dump: повторное удаление (и падение) какого-то старого объекта произошло внутри insert. Если бы новый объект был удалён, падение произошло бы в другом месте... или бы не произошло вовсе. Неопределённое поведение!

У нас есть ещё один подозреваемый в этом фрагменте кода. Давайте-ка глянем функцию lru_get. Её тоже написали 10 лет назад, протестировали, и нет ни малейшего повода в ней сомневаться!

// LRUItemHandle protects data from deletion,
// if it's evicted from the cache
LRUItemHandle* lru_get(Cache* c, const char* key) {
  try {
    auto item_ptr = c->cache.get(std::string(key));
    if (!item_ptr) {
      return nullptr;
    } 
    return new LRUItemHandle(item_ptr);
  } catch (...) {
    return nullptr;
  }
}

Я был в ужасе. Надеюсь, вы тоже. Вернёмся обратно к злосчастному фрагменту.

lru_insert(cache, key, metadata);
// допустим, что insert отработал успешно.
if (auto item_handle = lru_get(cache, key)) {
  ....
} else {
  // У lru_get есть как минимум две возможности соврать нам 
  // о наличии элемента в кэше. И похоже что нас обманули.
  metadata->cached = false;
}

Как выяснилось, в экстремально редком случае раз в полгода (в зависимости от нагрузки) у сервиса заканчивалась память. Но вместо падения по out-of-memory он продолжал пытаться работать. Таковы требования. И часто ему это удавалось делать успешно, пока однажды std::bad_alloc не был выброшен в этой надёжной и протестированной библиотеке c LRU-кэшем.

Теперь, когда все улики собраны и преступление реконструировано, надо сделать шаг назад и обдумать произошедшее.

  • Разработчики сервиса проиграли в игру с ручным разделением владения данными между разными компонентами и динамическим определением. Кто эти данные будет освобождать? Это сложная игра.
  • У библиотеки с LRU-кэшем, с которым и хотели разделить владение, оказалось чудовищное API. Почему это C-API — обоснование тому есть, и оно не касается нашей истории. Но это чудовищное C-API.
// Эта функция не сообщает о возможной ошибке при вставке.
// Эта функция пытается завладеть data, но в случае ошибки
// Data может быть удалена или нет — зависит от типа ошибки:
// - Не удалена при ошибке аллокации до входа в c->cache.insert
// - Удалена при любой ошибке внутри c->cache.insert
void lru_insert(Cache* c, const char* key, void* data)

// Эта функция может упасть с ошибкой, либо не найти элемент.
// Но различить эти два исхода пользователю
// не предлагается возможности.
LRUItemHandle* lru_get(Cache* c, const char* key)

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

API библиотеки с "надёжным и протестированным" LRU-кэшем я расширил новыми функциями, более устойчивыми к ошибкам. И постарался исправить старые насколько это возможно: например, проблему с владением в lru_insert нельзя было исправлять полностью... потому что нашёлся пользователь, который полагался на неправильное поведение.

А функцию стоило написать так с самого начала:

// Эта функция принимает владение data и передаёт его кэшу.
// В случае любой ошибки data НЕ будет освобождена.
// В случае успеха контролировать время жизни data будет кэш.
ErrorCode lru_try_insert(Cache* c, const char* key, void* data) try {
  // Подготавливаем слот для сырого указателя.
  auto slot =
    boost::intrusive_pointer(new LRUItem(nullptr, c->deleter));
  // Вставляем пустой слот в кэш. 
  // Слот должен быть пуст,
  // чтобы не удалить данные при ошибке вставки.
  c->cache.insert(std::string(key), slot);
  // Передаём владение в слот.
  // На этом этапе никакой больше ошибки произойти не может.
  // деструктор LRUItem гарантирует вызов deleter(data)
  slot->data = data 
  return ErrorCode::LRU_OK;
} catch (...) {
  return ErrorCode::LRU_ERROR;
}

Но лучше бы, конечно, пересмотреть зависимости зависимостей и использовать для LRU-кэша C++ библиотеку с более безопасными RAII-типами.

Исключения (или паники, как в Rust) всегда осложняют ручное управление ресурсами. Это специфично не только для низкоуровневых языков. Например, программы на языках Go, Java, С#, Python и прочих также страдают от незакрытых файлов или соединений с базами данных, если программист забыл использовать try-with-resourcesfinally или defer блоки.

Ручное управление ресурсами крайне рекомендуется сводить к минимуму:

Также крайне рекомендую C++ разработчикам пробовать Rust как минимум в качестве тьюториала по владению, его передаче и разделению. Строгий borrow checker раскроет вам много интересных паттернов, в которых при полностью ручном контроле можно легко получить double-free.

Корутины: время жизни и смерти

async/await синтаксис плотно вошёл в жизнь современных разработчиков: от фронтенда до бэкенда и низкоуровневой системщины. В 2007 году он появился в F# и за следующие годы разбежался по множеству языков: C# (2012), Python (2015), JavaScript (2017), Kotlin (2018), Rust (2019), Zig (2020, но в 2024 убрали из-за проблем реализации в self-hosted компиляторе).

Несмотря на все его неоднозначности (знаменитая проблема "цветных" функций), возможность писать простой линейный код вместо классического callback-спагетти для сложных асинхронных задач — полезна и сокращает усилия на прототипирование.

Чтобы не отставать, в С++20 также добавили долгожданную поддержку: вместо async функций у нас относительно явные и более общие типы — корутины. И для них тоже есть await... простите, co_await! A также co_return и co_yield — в С++ одним махом решили как проблемы асинхронных функций, так функций-генераторов. Или создали проблемы с ними...

К сожалению, поддержка есть, а вот корутин в cтандартной библиотеке нет! Если хотите, реализуйте свои. Но я, пожалуй, возьму корутины из boost::asio, чтобы продемонстрировать следующий восхитительный пример.

#include <iostream>
#include <concepts>
#include <vector>
#include <string>
#include <ranges>
#include <chrono>

#include <boost/asio/co_spawn.hpp>
#include <boost/asio/detached.hpp>
#include <boost/asio/io_context.hpp>
#include <boost/asio/steady_timer.hpp>

using namespace boost::asio;
namespace this_coro = boost::asio::this_coro;

using namespace std::literals::string_literals;
using namespace std::chrono;

using Request = std::string;

struct MetricEmitter {
  std::string metric_class;
  void emit(std::chrono::milliseconds elapsed) const {
    std::cout << metric_class << " " << elapsed << "\n";
  }
};

// Демонстрационная корутина для имитации ввода-вывода
awaitable<void> some_io(int delay) {
  steady_timer timer(co_await this_coro::executor);
  timer.expires_after(milliseconds(delay));
  co_await timer.async_wait(use_awaitable);
  co_return;
}

awaitable<void> handle_request(const Request& r) {
  co_await some_io(15);
  std::cout << "Hello " << r << "\n";
  co_return;
}

template <std::ranges::range Requests>
awaitable<void> process_requests_batch(Requests&& reqs) 
requires
std::convertible_to<std::ranges::range_value_t<Requests>, Request> {
  auto executor = co_await this_coro::executor;
  // Добавляем к обработке запроса метрики времени выполнения.
  auto handle_with_metrics =
    [metrics = MetricEmitter { "batch_processor" } ]
    (auto&& request) -> awaitable<void> 
  {
      auto start = steady_clock::now();
      co_await handle_request(std::move(request));
      auto finish = steady_clock::now();
      metrics.emit(duration_cast<milliseconds>(finish - start));
  };
  for (auto&& r: std::move(reqs)) {
    // запускаем конкурентное исполнение для каждого реквеста.
    co_spawn(executor, handle_with_metrics(std::move(r)), detached);
  }
  co_return;
}

awaitable<std::vector<Request>> accept_requests_batch() {
  co_return std::vector{ "Adam"s, "Helen"s, "Bob"s };
}

awaitable<void> run() {
  co_await process_requests_batch(co_await accept_requests_batch());
  co_await some_io(100);
}

int main()
{
  // Запускаем наши корутины в однопоточном контексте исполнения.
  boost::asio::io_context io_context(1);
  co_spawn(io_context, run(), detached);
  io_context.run();
}

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

Hello <имя>
batch_processor <время обработки>

В каком-то порядке.

Но на самом деле он с очень большой вероятностью упадёт с ошибкой сегментации.

Посмотрим, какое приветственное сообщение покажет нам address sanitizer:

gcc -std=c++23 -O0 -fsanitize=address
AddressSanitizer:DEADLYSIGNAL
==1==ERROR: AddressSanitizer:
SEGV on unknown address 0x00000000001b
(pc 0x7a9f129aedf4 bp 0x7a9f12a1b780 sp 0x7fff9f00a228 T0)
==1==The signal is caused by a READ memory access.
==1==Hint: address points to the zero page.
    #0 0x7a9f129aedf4  (/lib/x86_64-linux-gnu/libc.so.6+0x1aedf4)
       (BuildId: 490fef8403240c91833978d494d39e537409b92e)
    #1 0x7a9f1288b664 in _IO_file_xsputn
       (/lib/x86_64-linux-gnu/libc.so.6+0x8b664) (BuildId:
       490fef8403240c91833978d494d39e537409b92e)
    #2 0x7a9f1287ffd6 in fwrite
       (/lib/x86_64-linux-gnu/libc.so.6+0x7ffd6)
       (BuildId: 490fef8403240c91833978d494d39e537409b92e)
    #3 0x7a9f12e900ab 
       (/opt/compiler-explorer/gcc-14.2.0/lib64/libasan.so.8+0x820ab)
       (BuildId: e522418529ce977df366519db3d02a8fbdfe4494)
    #4 0x7a9f12ce8d1c in
       std::basic_ostream<char, std::char_traits<char> >
       & std::__ostream_insert<char, std::char_traits<char> >
       (std::basic_ostream<char,
       std::char_traits<char> >&, char const*, long)
       (/opt/compiler-explorer/gcc-14.2.0/lib64/libstdc++.so.6+0x14cd1c)
       (BuildId: 998334304023149e8c44e633d4a2c69800a2eb79)
    #5 0x407e03 in handle_request /app/example.cpp:39
    #6 0x40f697 in
       std::__n4861::coroutine_handle<void>::resume() const
       /opt/compiler-explorer/gcc-14.2.0/include/c++/14.2.0/coroutine:137
    #7 0x449403 in
       boost::asio::detail::awaitable_frame_base
       <boost::asio::any_io_executor>::resume()
       /app/boost/include/boost/asio/impl/awaitable.hpp:501
    #8 0x445ba4 in
       boost::asio::detail::awaitable_thread
       <boost::asio::any_io_executor>::pump()
       app/boost/include/boost/asio/impl/awaitable.hpp:770
    #9 0x454bc7 in
       boost::asio::detail::awaitable_handler<
       boost::asio::any_io_executor, boost::system::error_code>::operator()
       (boost::system::error_code const&)
       /app/boost/include/boost/asio/impl/use_awaitable.hpp:93
    #10 0x4517e6 in
        boost::asio::detail::binder1<
        boost::asio::detail::awaitable_handler<
        boost::asio::any_io_executor, boost::system::error_code>,
        boost::system::error_code>::operator()()
        /app/boost/include/boost/asio/detail/bind_handler.hpp:115
    #11 0x44f337 in void
        boost::asio::detail::handler_work<
        boost::asio::detail::awaitable_handler<
        boost::asio::any_io_executor, boost::system::error_code>,
        boost::asio::any_io_executor, void>::complete<
        boost::asio::detail::binder1<
        boost::asio::detail::awaitable_handler<
        boost::asio::any_io_executor, boost::system::error_code>,
        boost::system::error_code>>
         (boost::asio::detail::binder1<
        boost::asio::detail::awaitable_handler<boost::asio::any_io_executor,
        boost::system::error_code>, boost::system::error_code>&,
        boost::asio::detail::awaitable_handler<boost::asio::any_io_executor,
        boost::system::error_code>&)
        /app/boost/include/boost/asio/detail/handler_work.hpp:433
    #12 0x44ccfe in
        boost::asio::detail::wait_handler<
        boost::asio::detail::awaitable_handler<
        boost::asio::any_io_executor, boost::system::error_code>,
        boost::asio::any_io_executor>::do_complete
         (void*, boost::asio::detail::scheduler_operation*,
        boost::system::error_code const&, unsigned long)
        /app/boost/include/boost/asio/detail/wait_handler.hpp:76
    #13 0x41747c in
        boost::asio::detail::scheduler_operation::complete
        (void*, boost::system::error_code const&, unsigned long)
        /app/boost/include/boost/asio/detail/scheduler_operation.hpp:40
    #14 0x41e925 in
        boost::asio::detail::scheduler::do_run_one
        (boost::asio::detail::conditionally_enabled_mutex::scoped_lock&,
        boost::asio::detail::scheduler_thread_info&,
        boost::system::error_code const&)
        /app/boost/include/boost/asio/detail/impl/scheduler.ipp:493
    #15 0x41dcfb in
        boost::asio::detail::scheduler::run(boost::system::error_code&)
        /app/boost/include/boost/asio/detail/impl/scheduler.ipp:210
    #16 0x41f27a in boost::asio::io_context::run()
        /app/boost/include/boost/asio/impl/io_context.ipp:64
    #17 0x4099bf in main /app/example.cpp:75

Похоже, что ссылка:

awaitable<void> handle_request(const Request& r)

Немножечко испортилась. Но что же пошло не так?

Корутины — очень сложные объекты, которые обманчиво просты в использовании из-за синтаксического сахара. В этом же весь смысл! Поддержка async/await на уровне языка и компиляторов делает простым то, что всегда было сложно делать вручную... Так происходит в высокоуровневых и безопасных языках с автоматическим управлением памятью: Python, C#, JavaScript, Kotlin. Но не в C++. И не в Rust (и не в Zig).

В примере выше есть как минимум три точки отказа, содержащих ошибки. Можете подумать об этом, пока мы будем разворачивать проблемы корутин в С++.

Что такое корутина?

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

Но есть один нюанс, ведущий к частому недопониманию: "магическим" свойством на самом деле обладает не функция, а объект, который из неё возвращается.

JavaScript разработчики знают, я надеюсь, что:

async function myFunction() {
  return "Hello";
}

То же самое, что и:

function myFunction() {
  return Promise.resolve("Hello");
}

Аналогично в Rust:

async fn my_foo() -> String
{
  "Hello".to_string()
}

То же самое, что и:

fn my_foo() -> impl Future<Output = String> {
  // создает анонимный объект Future
  async {
    "Hello".to_string()
  }
}

В C++ же нет специального синтаксиса для объявления функции корутинами. Вместо этого нужно явно указать тип возвращаемого значения (например, awaitable). И требования к этому типу специфичны и не сразу понятны:

  • awaitable должен удовлетворять концепту std::coroutine_handle_traits. То есть у него должен быть ассоциированный тип promise = typename awaitable::promise_type.
  • Тип promise должен удовлетворять документации концепта promise, который настолько сложен для описания, что про корутины в C++ приходится писать отдельные книжки. Но если кратко, то promise контролирует поведение операций co_awaitco_yield и co_return.
  • Инстанциация handle = std::coroutine_handle<promise> должна быть успешной.
  • Должна быть возможность сконструировать awaitable c помощью promise.get_return_object().

И вот только тогда внутри тела функции, возвращающей awaitable, можно будет (и часто нужно будет) использовать синтаксический сахар co_awaitco_yieldco_return:

awaitable<std::string> myFunction() {
  co_return "Hello";
}

Который рассахаривается в нечто подобное (это приблизительный псевдокод):

awaitable<std::string> myFunction() {
  using Promise = awaitable::promise_type;
  using Handle = std::coroutine_handle<Promise>;
  Promise p;
  auto state = new ImplicitlyGeneratedStateMachine<Handle>(p);
  // state = _0;
  // ....
  //{  
  //  switch(state) {
  //     case _0: { state = _1; p.initial_suspend(); }
  //     case _1: { p.yield_value("Hello"); }
  //   }
  // }
  return p.get_return_object();
}

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

awaitable<void> process_request(const std::string& r) { 
  co_await some_io(1);
  std::cout << r; 
}

awaitable<void> send_dummy_request() {
  return process_request("hello");
}

int main(){
  boost::asio::io_context io_context(1);
  co_spawn(io_context, send_dummy_request(), detached);
  io_context.run();
}

Запускаем. Проверяем. Работает? Успешно ничего не печатает... Странно. Давайте-ка уберём some_io().

awaitable<void> process_request(const std::string& r) { 
  std::cout << r; 
}

Запускаем. Проверяем. Получаем что? Правильно:

<source>: In function 'boost::asio::awaitable<void>
          process_request(const std::string&)':
<source>:19:73: warning: no return statement in function returning
 non-void [-Wreturn-type]
 19 | awaitable<void> process_request(const std::string& r) { std::cout << r; }
    |                                                                         ^
ASM generation compiler returned: 0
<source>: In function 'boost::asio::awaitable<void>
process_request(const std::string&)':
<source>:19:73: warning: no return statement in function
returning non-void [-Wreturn-type]
 19 | awaitable<void> process_request(const std::string& r) { std::cout << r; }
    |                                                                         ^
Execution build compiler returned: 0
Program returned: 132
Program terminated with signal: SIGILL

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

Добавим co_return:

awaitable<void> process_request(const std::string& r) { 
  std::cout << r; 
  co_return;
}

Снова пусто...

Подключаем санитайзер!

==1==ERROR: AddressSanitizer: stack-use-after-return on
address 0x758796100150 at pc 0x7587985d01e6
bp 0x7ffda95e0290 sp 0x7ffda95dfa50
READ of size 5 at 0x758796100150 thread T0
    #0 0x7587985d01e5 
       (/opt/compiler-explorer/gcc-14.2.0/lib64/libasan.so.8+0x821e5)
       (BuildId: e522418529ce977df366519db3d02a8fbdfe4494)
    #1 0x758798428d1c in
       std::basic_ostream<char, std::char_traits<char> >&
       std::__ostream_insert<char, std::char_traits<char> >
       (std::basic_ostream<char, std::char_traits<char> >&, char const*, long)
       (/opt/compiler-explorer/gcc-14.2.0/lib64/libstdc++.so.6+0x14cd1c)
       (BuildId: 998334304023149e8c44e633d4a2c69800a2eb79)
    #2 0x405c47 in process_request /app/example.cpp:21
    #3 0x4082f5 in std::__n4861::coroutine_handle<void>::resume() const
       /opt/compiler-explorer/gcc-14.2.0/include/c++/14.2.0/coroutine:137
    #4 0x427e8b in
       boost::asio::detail::awaitable_frame_base
       <boost::asio::any_io_executor>::resume()
       /app/boost/include/boost/asio/impl/awaitable.hpp:501
    #5 0x425e2c in
       boost::asio::detail::awaitable_thread
       <boost::asio::any_io_executor>::pump()
       /app/boost/include/boost/asio/impl/awaitable.hpp:770

Ой, какое несчастье, ссылка const std::string& r, похоже, умерла. Почему?

А вот же:

awaitable<void> send_dummy_request() {
  return process_request("hello");
}

Тут тоже нет никаких магических co_* ключевых слов. А значит, мы просто вызываем функцию process_request, она возвращает объект-корутину, который...

Самое время уточнить, что происходит с аргументами функций, использующих co_*: они неявно копируются внутрь неявно создаваемого объекта. Значение копируется как значение, ссылка — как ссылка.

awaitable<std::string>
myFunction(int arg1, const std::string& arg2)
{
  using Promise = awaitable::promise_type;
  using Handle = std::coroutine_handle<Promise>;
  Promise p;
  auto state = new ImplicitlyGeneratedStateMachine<Handle>(p);
  //  state = _0;
  //  int __arg1 { arg1 };
  //  const std::string& __arg2 { arg2 };
  //{  
  //  switch(state) {
  //     case _0: { state = _1; p.initial_suspend(); }
  //     case _1: { p.yield_value("Hello"); }
  //   }
  //}
  return p.get_return_object();
}

То есть:

awaitable<void> process_request(const std::string& r) {....}

awaitable<void> send_dummy_request() {
  // Неявно конструируется локальный временный std::string,
  // ссылка на него передаётся в функцию process_request,
  // Где дальше копируется внутрь стейт-машины.
  return process_request("hello");
  // Возвращаем стейт-машину, а локальный временный объект умирает.
  // Use-after-free при попытке получить результат стейт-машины.
}

Нужны волшебные слова... Между тем вы ещё не чувствуете, насколько всё может стать плохо в контексте шаблонов? Нет? Когда может быть совершенно не ясно, корутину нам передали или нет? Нет? Ну ничего страшного. Возьмите это в качестве упражнения на дом — написать std::invoke, поддерживающий корутины. А мы пока продолжим добавлять волшебные слова.

awaitable<void> send_dummy_request() {
  co_return process_request("hello");
}
<source>: In function
'boost::asio::awaitable<void> send_dummy_request()':
<source>:26:5: error: no member named 'return_value' in
'std::__n4861::coroutine_traits
<boost::asio::awaitable<void> >::promise_type' {aka
'boost::asio::detail::awaitable_frame
<void, boost::asio::any_io_executor>'}
   26 |   co_return process_request("hello");
      |   ^~~~~~~~~
Compiler returned: 1

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

awaitable<void> send_dummy_request() {
  auto task = process_request("hello"); // Функция вернула
                                        // корутину-стейт-машину.
  auto result = co_await task; // Нужно дождаться завершения.
  co_return result;            // и вернуть результат.
}

Ой, небольшая заминка...

<source>: In function
'boost::asio::awaitable<void> send_dummy_request()':
<source>:27:28: error: use of deleted function
'boost::asio::awaitable<T, Executor>::awaitable
(const boost::asio::awaitable<T, Executor>&)
[with T = void; Executor = boost::asio::any_io_executor]'
   27 |     auto result = co_await task;
      |                            ^~~~
In file included from /app/boost/include/boost/asio/co_spawn.hpp:22,
                 from <source>:8:
/app/boost/include/boost/asio/awaitable.hpp:123:3: note: declared here
  123 |   awaitable(const awaitable&) = delete;
      |   ^~~~~~~~~

Это особенность Boost.Asio. Для большей безопасности он требует применять co_await только к rvalue. Небольшое исправление:

auto result = co_await std::move(task);

И мы получаем...

<source>:28:10: error: deduced type 'void' for 
result' is incomplete
   28 |     auto result = co_await std::move(task);
      |          ^~~~~~

Вы все ещё не почувствовали, насколько всё может стать плохо с шаблонами? Ничего страшного, помним, что у С++ всегда были проблемы с таким замечательным типом, как void. Просто объединим co_return и co_await в одну строчку:

awaitable<void> send_dummy_request() {
  auto task = process_request("hello"); // Функция вернула
                                        // корутину-стейт-машину.
                                        
  co_return co_await std::move(task); // Нужно дождаться завершения
                                      // и вернуть результат.
}

Компилируется, и...

==1==ERROR: AddressSanitizer: stack-use-after-return on address
0x76cc8d801070 at pc 0x76cc8fa541e6 bp 0x7ffed68d7510
sp 0x7ffed68d6cd0
READ of size 5 at 0x76cc8d801070 thread T0
    #0 0x76cc8fa541e5
       (/opt/compiler-explorer/gcc-14.2.0/lib64/libasan.so.8+0x821e5)
       (BuildId: e522418529ce977df366519db3d02a8fbdfe4494)
    #1 0x76cc8f8acd1c in
       std::basic_ostream<char, std::char_traits<char> >&
       std::__ostream_insert<char, std::char_traits<char> >
       (std::basic_ostream<char, std::char_traits<char> >&,
       char const*, long)
       (/opt/compiler-explorer/gcc-14.2.0/lib64/libstdc++.so.6+0x14cd1c)
       (BuildId: 998334304023149e8c44e633d4a2c69800a2eb79)
    #2 0x405c47 in process_request /app/example.cpp:20
    #3 0x408cc7 in std::__n4861::coroutine_handle<void>::resume() const
       /opt/compiler-explorer/gcc-14.2.0/include/c++/14.2.0/coroutine:137
    #4 0x428cef in
       boost::asio::detail::awaitable_frame_base
       <boost::asio::any_io_executor>::resume()
       /app/boost/include/boost/asio/impl/awaitable.hpp:501
    #5 0x426e00 in
       boost::asio::detail::awaitable_thread
       <boost::asio::any_io_executor>::pump()
       /app/boost/include/boost/asio/impl/awaitable.hpp:770
    #6 0x432170 in
       boost::asio::detail::awaitable_async_op_handler
       <void (), boost::asio::any_io_executor>::operator()()
       /app/boost/include/boost/asio/impl/awaitable.hpp:804
    #7 0x431a95 in
       boost::asio::detail::binder0
       <boost::asio::detail::awaitable_async_op_handler
       <void (), boost::asio::any_io_executor> >::operator()()
       /app/boost/include/boost/asio/detail/bind_handler.hpp:56
    #8 0x431f04 in void
       boost::asio::detail::executor_function::complete
       <boost::asio::detail::binder0
       <boost::asio::detail::awaitable_async_op_handler
       <void (), boost::asio::any_io_executor> >, std::allocator<void> >
        (boost::asio::detail::executor_function::impl_base*, bool)
       /app/boost/include/boost/asio/detail/executor_function.hpp:113
    #9 0x40a5ac in boost::asio::detail::executor_function::operator()()
       /app/boost/include/boost/asio/detail/executor_function.hpp:61
    #10 0x42a66d in
       boost::asio::detail::executor_op
       <boost::asio::detail::executor_function, std::allocator<void>,
       boost::asio::detail::scheduler_operation>::do_complete
       (void*, boost::asio::detail::scheduler_operation*,
       boost::system::error_code const&, unsigned long)
       /app/boost/include/boost/asio/detail/executor_op.hpp:70
    #11 0x410754 in
        boost::asio::detail::scheduler_operation::complete
        (void*, boost::system::error_code const&, unsigned long)
        /app/boost/include/boost/asio/detail/scheduler_operation.hpp:40
    #12 0x41728b in
        boost::asio::detail::scheduler::do_run_one
         (boost::asio::detail::conditionally_enabled_mutex::scoped_lock&,
        boost::asio::detail::scheduler_thread_info&,
        boost::system::error_code const&)
        /app/boost/include/boost/asio/detail/impl/scheduler.ipp:493
    #13 0x41680d in
        boost::asio::detail::scheduler::run(boost::system::error_code&)
        /app/boost/include/boost/asio/detail/impl/scheduler.ipp:210
    #14 0x417be0 in boost::asio::io_context::run()
        /app/boost/include/boost/asio/impl/io_context.ipp:64
    #15 0x406b67 in main /app/example.cpp:36

Та же самая проблема. Строка умерла по той же самой причине. А что, если мы теперь соединим всё в одну строку кода?

awaitable<void> send_dummy_request() {
  co_return co_await process_request("hello");
}

А вот теперь всё наконец-то правильно. И программа печатает заветное слово "hello".

Здорово, не правда ли, как мы прошли путь от неправильного:

awaitable<void> send_dummy_request() {
  return process_request("hello");
}

К правильному:

awaitable<void> send_dummy_request() {
  co_return co_await process_request("hello");
}

Разве могла бы такая красота получиться, если бы C++ требовал маркировку [co_]async у объявления функций? Тогда бы у нас было скучно (прям как в Rust):

[[co_async]] awaitable<void> send_dummy_request() {
  return process_request("hello"); // Compilation error!
                                   // Type mismatch / co_return
                                   // should be used.
}

Но это всё была синтаксическая забава, в процессе которой мы поймали ошибку: const-ссылки, rvalue-ссылки и неявное создание временных объектов. Ссылки неявно захватываются стейт-машиной, а неявные временные объекты неявно умирают. Рецепт простой: создавайте временные переменные явно, контролируйте их время жизни, при возможности избегайте ссылочных параметров у корутин. Вернёмся к самому первому примеру и исправим ошибки и потенциальные проблемы со ссылками.

// Принимаем теперь все параметры для корутин по значению. 

awaitable<void> handle_request(Request r) {
  co_await some_io(15);
  std::cout << "Hello " << r << "\n";
  co_return;
}

template <std::ranges::range Requests>
awaitable<void> process_requests_batch(Requests reqs) 
requires
std::convertible_to<std::ranges::range_value_t<Requests>, Request>
{
  auto executor = co_await this_coro::executor;
  // добавляем к обработке запроса метрики времени выполнения
  auto handle_with_metrics = 
   [metrics = MetricEmitter { "batch_processor" } ](auto request) ->
     awaitable<void>
  {
      auto start = steady_clock::now();
      co_await handle_request(std::move(request));
      auto finish = steady_clock::now();
      metrics.emit(duration_cast<milliseconds>(finish - start));
  };
  for (auto&& r: std::move(reqs)) {
    // Запускаем конкурентное исполнение для каждого реквеста.
    co_spawn(executor, handle_with_metrics(std::move(r)), detached);
  }
  co_return;
}

awaitable<std::vector<Request>> accept_requests_batch() {
  co_return std::vector{ "Adam"s, "Helen"s, "Bob"s };
}

awaitable<void> run() {
  co_await process_requests_batch(co_await accept_requests_batch());
  co_await some_io(100);
}

Компилируем. Запускаем. И получаем... правильно, новую ошибку сегментации!

==1==ERROR: AddressSanitizer: heap-use-after-free on address
0x511000000228 at pc 0x7c812eca71e6 bp 0x7ffded723390
sp 0x7ffded722b50
READ of size 15 at 0x511000000228 thread T0
    #0 0x7c812eca71e5
       (/opt/compiler-explorer/gcc-14.2.0/lib64/libasan.so.8+0x821e5)
       (BuildId: e522418529ce977df366519db3d02a8fbdfe4494)
    #1 0x7c812eaffd1c in
       std::basic_ostream<char, std::char_traits<char> >&
       std::__ostream_insert<char, std::char_traits<char> >
       (std::basic_ostream<char, std::char_traits<char> >&,
       char const*, long)
        (/opt/compiler-explorer/gcc-14.2.0/lib64/libstdc++.so.6+0x14cd1c)
        (BuildId: 998334304023149e8c44e633d4a2c69800a2eb79)
    #2 0x41f592 in
       MetricEmitter::emit
       (std::chrono::duration<long, std::ratio<1l, 1000l> >)
       const /app/example.cpp:24
    #3 0x40ab8e in operator() /app/example.cpp:52
    #4 0x40f771 in std::__n4861::coroutine_handle<void>::resume() const
       /opt/compiler-explorer/gcc-14.2.0/include/c++/14.2.0/coroutine:137
    #5 0x4494e7 in
       boost::asio::detail::awaitable_frame_base
       <boost::asio::any_io_executor>::resume()
       /app/boost/include/boost/asio/impl/awaitable.hpp:501
    #6 0x445c86 in
       boost::asio::detail::awaitable_thread
       <boost::asio::any_io_executor>::pump()
       /app/boost/include/boost/asio/impl/awaitable.hpp:770
    #7 0x454cab in boost::asio::detail::awaitable_handler
       <boost::asio::any_io_executor, boost::system::error_code>::operator()
       (boost::system::error_code const&)
       /app/boost/include/boost/asio/impl/use_awaitable.hpp:93
    #8 0x4518ca in
       boost::asio::detail::binder1
       <boost::asio::detail::awaitable_handler
       <boost::asio::any_io_executor, boost::system::error_code>,
       boost::system::error_code>::operator()()
       /app/boost/include/boost/asio/detail/bind_handler.hpp:115
    #9 0x44f41b in void
       boost::asio::detail::handler_work
       <boost::asio::detail::awaitable_handler
       <boost::asio::any_io_executor, boost::system::error_code>,
       boost::asio::any_io_executor, void>::complete
       <boost::asio::detail::binder1
       <boost::asio::detail::awaitable_handler
       <boost::asio::any_io_executor, boost::system::error_code>,
       boost::system::error_code> >
       (boost::asio::detail::binder1
       <boost::asio::detail::awaitable_handler
       <boost::asio::any_io_executor, boost::system::error_code>,
       boost::system::error_code>&,
       boost::asio::detail::awaitable_handler<boost::asio::any_io_executor,
       boost::system::error_code>&)
       /app/boost/include/boost/asio/detail/handler_work.hpp:433
    #10 0x44cde2 in
       boost::asio::detail::wait_handler
       <boost::asio::detail::awaitable_handler
       <boost::asio::any_io_executor, boost::system::error_code>,
       boost::asio::any_io_executor>::do_complete
       (void*, boost::asio::detail::scheduler_operation*,
       boost::system::error_code const&, unsigned long)
       /app/boost/include/boost/asio/detail/wait_handler.hpp:76
    #11 0x417556 in
       boost::asio::detail::scheduler_operation::complete
        (void*, boost::system::error_code const&, unsigned long)
       /app/boost/include/boost/asio/detail/scheduler_operation.hpp:40
    #12 0x41e9ff in
       boost::asio::detail::scheduler::do_run_one
        (boost::asio::detail::conditionally_enabled_mutex::scoped_lock&,
       boost::asio::detail::scheduler_thread_info&,
       boost::system::error_code const&)
       /app/boost/include/boost/asio/detail/impl/scheduler.ipp:493
    #13 0x41ddd5 in
       boost::asio::detail::scheduler::run(boost::system::error_code&)
       /app/boost/include/boost/asio/detail/impl/scheduler.ipp:210
    #14 0x41f354 in boost::asio::io_context::run()
       /app/boost/include/boost/asio/impl/io_context.ipp:64
    #15 0x4099ae in main /app/example.cpp:75

Судя по трейсу, теперь у нас умерла другая строка, та, что была сохранена в MetricEmitter.

template <std::ranges::range Requests>
awaitable<void> process_requests_batch(Requests reqs) 
requires
std::convertible_to<std::ranges::range_value_t<Requests>, Request>
{
  auto executor = co_await this_coro::executor;
  // добавляем к обработке запроса метрики времени выполнения
  auto handle_with_metrics =
    [metrics = MetricEmitter { "batch_processor" } ](auto request) ->
      awaitable<void>
  {
    auto start = steady_clock::now();
    co_await handle_request(std::move(request));
    auto finish = steady_clock::now();
    metrics.emit(duration_cast<milliseconds>(finish - start));
  };
  for (auto&& r: std::move(reqs)) {
    // запускаем конкурентное исполнение для каждого реквеста.
    co_spawn(executor, handle_with_metrics(std::move(r)), detached);
  }
  co_return;
}

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

struct MetricEmitter {
  std::string metric_class;
  void emit(std::chrono::milliseconds elapsed) const {
    std::cout << metric_class << " " << elapsed << "\n";
  }

  awaitable<void> wrap_request(Request r) const {
    auto start = steady_clock::now();
    co_await handle_request(std::move(r));
    auto finish = steady_clock::now();
    // Корутина также неявно захватывает указатель this!
    emit(duration_cast<milliseconds>(finish - start));
  }
};

// Так что, наверное, очевидно, что
auto task =
  MetricEmitter{"batch_process"}.wrap_request(request);
co_await task; // MetricEmitter умер. Будет use-after-free

У нас в примере кое-что похожее, но другое.

// handle_with_metric — это анонимная структура с
// определённым operator().
auto handle_with_metrics =
  [metrics = MetricEmitter { "batch_processor"} ](auto request) ->
    awaitable<void>
{
  auto start = steady_clock::now();
  co_await handle_request(std::move(request));
  auto finish = steady_clock::now();
  // Корутина неявно захватывает this...
  // A this в этом случае - указатель на лямбда-функцию! 
  metrics.emit(duration_cast<milliseconds>(finish - start));
};
// Если лямбда-функция умрёт раньше, чем завершится исполнение корутины,
// и будет use-after-free.

Смотрим, как мы её вызываем:

for (auto&& r: std::move(reqs)) {
    // Запускаем конкурентное исполнение для каждого реквеста...
    // В ФОНЕ!!! Результат вызова handle_with_metrics — корутина —
    // сохраняется куда-то внутрь функции boost::asio::co_spawn.
    co_spawn(executor, handle_with_metrics(std::move(r)), detached);
    // И будет обработана позже.
}
co_return; // А вот тут наша лямбда и умрёт.

Под такие неприятные случаи у co_spawn есть перегрузка, принимающая напрямую функцию, а не awaitable<T>. Но у функции тогда не должно быть аргументов.

Ошибку можно исправить разными способами, следуя рекомендации: все параметры корутины нужно передать явно и переместить внутрь её тела. В C++23 для методов классов с этим может помочь deduced this:

struct MetricEmitter {
  std::string metric_class;
  void emit(std::chrono::milliseconds elapsed) const {
    std::cout << metric_class << " " << elapsed << "\n";
  }

  awaitable<void> wrap_request(this auto self, Request r) {
    // Self скопирован по значению!
    auto start = steady_clock::now();
    co_await handle_request(std::move(r));
    auto finish = steady_clock::now();
    // Корутина также неявно захватывает указатель this!
    self.emit(duration_cast<milliseconds>(finish - start));
  }
};

А из stateful-лямбд возвращать корутины не рекомендуется. Убирайте список захвата!

template <std::ranges::range Requests>
awaitable<void> process_requests_batch(Requests reqs)
requires
std::convertible_to<std::ranges::range_value_t<Requests>, Request>
{
  auto executor = co_await this_coro::executor;
  // Добавляем к обработке запроса метрики времени выполнения.
  auto handle_with_metrics = [](auto request) -> awaitable<void> {
    auto metrics = MetricEmitter { "batch_processor"}; 
    auto start = steady_clock::now();
    co_await handle_request(std::move(request));
    auto finish = steady_clock::now();
    metrics.emit(duration_cast<milliseconds>(finish - start));
  };
  for (auto&& r: std::move(reqs)) {
    // Запускаем конкурентное исполнение для каждого реквеста.
    co_spawn(executor, handle_with_metrics(std::move(r)), detached);
  }
  co_return;
}

Вот теперь всё работает.

Hello Adam
batch_processor 15ms
Hello Helen
batch_processor 15ms
Hello Bob
batch_processor 15ms

Отслеживать время жизни ссылок в асинхронном коде вручную крайне тяжело. Автоматика, как в Rust, делает это намного лучше, но при этом может выдавать совершенно непонятные репорты, в которых можно разобраться только если знаешь, что именно могло пойти не так — за это async в Rust не любят и критикуют. А в качестве самого простого и продуктивного решения, чтобы ублажить borrow checker, выбирается копирование всего подряд (.clone(), везде .clone()).

С++ отдаёт вам полный контроль с невероятной кучей неявных захватов ссылок! Делайте с ними что хотите и как хотите, скомпилируется без проблем и проверок. Вы можете приложить колоссальные усилия, отследить все ссылки и убедиться, что объекты не умрут не вовремя. Либо можно отчаяться, прочитать гайдлайны и передавать всё и всегда by value, копируя и перемещая. Никаких ссылок.

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

  • Если вы захватите блокировку с помощью std::unique_lock и продержите ее на время исполнения co_await, то готовьтесь получить неопределенное поведение — unique_lock может быть перемещен в другой поток, не владевший блокировкой! Только внимательность, опыт и, может быть, статический анализатор помогут вам избежать этих граблей.
  • Разумеется, ещё больше возможности для race conditions. Особенно если забыть что-то скопировать внутрь тела корутины.

Ах да, совсем забыл: в зависимости от реализации корутины могут быть ленивыми или не очень ленивыми (смотри promise::initial_suspend()). Boost::asio::awaitable — ленивые, и поэтому мы сразу получали прекрасные use-after-free. Для корутин, у которых promise::initial_suspend() возвращает suspend_never, код вида:

awaitable<void> process_request(const std::string& r) { 
  std::cout << r; 
  co_await something();
  /* r не используется */
  co_return;
}

Может продолжать успешно работать и долгое время не вызывать проблем.

Корутины C++ гибки, мощны и совершенно небезопасны. Надеюсь, вы настроили сборку и тесты с санитайзерами, прежде чем решили ими воспользоваться.

По состоянию на 2024 год статические анализаторы C++ частично подсвечивают подобные проблемы:

Но в общем случае это не ошибки. Можно успешно использовать ссылки и не платить за копии. Можно также избегать лишнего обёртывания в слой корутины и делать return foo() напрямую.

Полезные ссылки

Послесловие: статический анализ и UB

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

Для разработчиков анализаторов поиск UB — это одновременно сложная и интересная задача. Здесь недостаточно использовать какую-то одну технологию. Анализ потока данных поможет выявить использование невалидного указателя, но перпендикулярен задаче выявления использования зарезервированных имён. Поиск UB — это выявление множества отдельных модельных вариантов ошибок.

Другими словами, поиск UB разветвляется на поиск разнородных паттернов, закономерностей и частных случаев. Именно поэтому Андрей Карпов и команда PVS-Studio с таким интересом поучаствовали в подготовке это книги. В анализаторе уже много сделано для поиска UB, но и ещё столько же предстоит сделать. Эта книга станет путеводной звездой и источником вдохновения.

Спасибо Дмитрию от команды PVS-Studio!

И ещё кое-что

На эту книгу, опубликованную в виде цикла статей, можно ссылаться. Можно приводить примеры из неё со ссылками, конечно же. Для копирования и иного воспроизведения необходимо получить согласие автора. Мой контакт: dmisvrl1@gmail.com

Нельзя использовать в платных сервисах или взимать плату за обучение по этим материалам.

Автор — Дмитрий Свиридкин

Более восьми лет работает в сфере коммерческой разработки высокопроизводительного программного обеспечения на C и C++. С 2019 по 2021 год преподавал курсы системного программирования под Linux в СПбГУ и практики C++ в ВШЭ. В настоящее время — Software Engineer в AWS (Cloudfront), занимается системной и embedded-разработкой на Rust и C++ для edge-серверов. Основная сфера интересов — безопасность программного обеспечения.

Редактор — Андрей Карпов

Более 15 лет занимается темой статического анализа кода и качества программного обеспечения. Автор большого количества статей, посвящённых написанию качественного кода на языке C++. С 2011 по 2021 год удостаивался награды Microsoft MVP в номинации Developer Technologies. Один из основателей проекта PVS-Studio. Долгое время являлся CTO компании и занимался разработкой С++ ядра анализатора. Основная деятельность на данный момент — управление командами, обучение сотрудников и DevRel активности.

Время попробовать, какие UB найдёт PVS-Studio в вашем коде!

Последние статьи:

Опрос:

Попробуйте PVS-Studio бесплатно

Unicorn with gift
Популярные статьи по теме


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

Следующие комментарии next comments
close comment form
close form

Заполните форму в два простых шага ниже:

Ваши контактные данные:

Шаг 1
Поздравляем! У вас есть промокод!

Тип желаемой лицензии:

Шаг 2
Team license
Enterprise license
** Нажимая на кнопку, вы даете согласие на обработку
своих персональных данных. См. Политику конфиденциальности
close form
Запросите информацию о ценах
Новая лицензия
Продление лицензии
--Выберите валюту--
USD
EUR
RUB
* Нажимая на кнопку, вы даете согласие на обработку
своих персональных данных. См. Политику конфиденциальности

close form
Бесплатная лицензия PVS‑Studio для специалистов Microsoft MVP
* Нажимая на кнопку, вы даете согласие на обработку
своих персональных данных. См. Политику конфиденциальности

close form
Для получения лицензии для вашего открытого
проекта заполните, пожалуйста, эту форму
* Нажимая на кнопку, вы даете согласие на обработку
своих персональных данных. См. Политику конфиденциальности

close form
Мне интересно попробовать плагин на:
* Нажимая на кнопку, вы даете согласие на обработку
своих персональных данных. См. Политику конфиденциальности

close form
check circle
Ваше сообщение отправлено.

Мы ответим вам на


Если вы так и не получили ответ, пожалуйста, проверьте, отфильтровано ли письмо в одну из следующих стандартных папок:

  • Промоакции
  • Оповещения
  • Спам