четверг, 7 апреля 2022 г.

[prog.c++] Продолжение темы про передачу C++объектов через shared memory. Промежуточные выводы

Сама тема была обозначена здесь.

После штудирования найденной в Интернете информации сложилось следующее впечатление: если нам нужно передавать через shared-memory объекты небольшого фиксированного размера, то самый простой и надежный способ -- это использовать на стороне процесса-консьюмера memcpy чтобы скопировать содержимое объекта из shared memory в локальную память процесса-консьюмера.

Т.е., допустим, у нас есть некий тип Data:

struct Data {
  int a_;
  long b_;
  short c_;
};

В процессе-продюсере мы используем placement new для размещения Data в shared memory:

auto * memory_block = allocate_block_in_shmem(sizeof(Data));
auto * d = new(memory_block) Data();
d->a_ = 0;
...

На стороне же процесса-консьюмера мы используем memcpy:

auto * memory_block = obtain_valid_pointer_to_block_in_shmem(...);
Data d;
std::memcpy(d, memory_block, sizeof(Data));
std::cout << d.a_ << std::endl; // Тут нет UB.

Очевидно, что таким способом через shared memory могут передаваться только trivially copyable объекты.

Это то, в чем я сейчас на 100% уверен (с поправкой на то, что могу и ошибаться).

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

Итак, причина, которая заставила погрузиться в тему передачи информации через shared memory.

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

Но вот передаваемые блоки сопровождаются небольшими trivially copyable объектами. Это уже моя зона ответственности. И тут не хочется нашпиговать код UB, который рано или поздно вылезет боком.

Пока передаются только маленькие trivially copyable объекты.

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

Итак, нужно подумать о том, как передавать через shared memory C++ объекты со строками переменной длины внутри.

Простейший вариант -- это ограничить строки по длине. Т.е. сделать как-то так:

constexpr std::size_t first_max_length = 512u;
constexpr std::size_t second_max_length = 1024u;

class Data {
  std::uint16_t first_length_;
  char first_[first_max_length];

  std::uint16_t second_length_;
  char second[second_max_length];

  ...
public:
  ...
  std::string_view first() const { return {first_, first_length_}; }

  std::string_view second() const { return {second_, second_length_}; }
  ...
};

Не нравится мне здесь то, что если максимальная длина строкового поля -- это где-то 512 или 1024 байта, а самих строковых полей наберется с десяток, то размер объекта будет составлять уже от 5 до 10 килобайт. Т.е., если использовать гарантированно работающий без UB подход с memcpy, то придется гонять туда-сюда сотни килобайт в секунду. Что, наверное, не есть большая проблема. Но, имхо, все-таки не хорошо, особенно с точки зрения вымывания действительно полезных данных из кэша процессора.

По факту же реальные объемы передаваемых данных будут в разы меньше. Т.е., если максимальный размер строки 512 байт, то в реальности там будет ну байт 80 или 100, а то и вообще байт 20-30.

Поэтому есть идея делать типы вроде вот такого:

class Data {
  std::uint16_t first_offset_;
  std::uint16_t first_length_;
  std::uint16_t second_offset_;
  std::uint16_t second_length_;

public:
  static std::size_t necessary_size(std::string_view first, std::string_view second) {
    return sizeof(Data) + first.size() + second.size();
  }

  Data(std::string_view first, std::string_view second)
    : first_offset_{sizeof(Data)}, first_length_{first.size()}
    , second_offset_{first_offset_ + first_length_}, second_length_{second.size()}
  {}

  std::string_view first() const {
    return {reinterpret_cast<const char *>(this) + first_offset_, first_length_};
  }

  std::string_view second() const {
    return {reinterpret_cast<const char *>(this) + second_offset_, second_offset_};
  }
};

На стороне продюсера он будет использоваться так:

auto first = "Hello, "sv;
auto second = "World!"sv;

auto * memory_block = allocate_block_in_shmem(Data::necessary_size(first, second));
new(memory_block) Data(first, second);

И вопрос упирается в то, как поступать на стороне консьюмера.

Подозреваю, что в C++20 без UB должен работать вот такой подход:

auto [block_ptr, block_size] = obtain_valid_pointer_to_block_in_shmem(...);
std::unique_ptr<char[]> local_copy_storage{new char[block_size]};
std::memcpy(local_copy_storage.get(), block_ptr, block_size);
auto * d = reinterpret_cast<Data *>(local_copy_storage.get()); // (1)

Здесь в точке (1) нет UB т.к. в C++20 есть implicit object creation (подробнее в P0593): когда создается вектор из char, unsigned char или std::byte, то компилятор в C++20 считает, что там же создаются и нужные пользователю объекты.

Но есть две проблемы.

Во-первых, пока что нужно оставаться в рамках C++17. А в C++17 код в точке (1) -- это UB.

Во-вторых, как-то жаба душит делать в консьюмере аллокацию памяти под локальный буфер. В реальности этот лишний new/delete погоды не делает, но все-таки неприятно.

Есть соблазн задействовать std::launder:

auto * block_ptr = obtain_valid_pointer_to_block_in_shmem(...);
auto * raw_ptr = reinterpret_cast<Data *>(block_ptr); // (2)
auto * p = std::launder(raw_ptr); // (3)

Типа в точке (2) у нас появляется указатель, использовать который мы не можем т.к. это UB (ведь в консьюмере нет объекта, чье время жизни бы начиналось с появлением указателя block_ptr).

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

Однако, есть у меня подозрение, что это будет неправомочное использование std::launder, т.к. предназначался он для совсем других целей.

В общем, с std::launder я пока что не разобрался. Буду курить бамбук дальше. Пока в режиме праздного любопытства, но кто знает когда жареный петух начнет клевать ;)

В качестве промежуточного решения, которое, вроде как, должно работать в C++17, рассматривается вот такое:

auto [block_ptr, block_size] = obtain_valid_pointer_to_block_in_shmem(...);
std::unique_ptr<char[]> local_copy_storage{new char[block_size]};
auto * d = new(local_copy_storage.get()) Data;
std::memcpy(d, block_ptr, block_size);

Но здесь есть этот самый new/delete... :(


Полезные ссылки для изучения:

Objects, Their Lifetimes And Pointers.

On launder()

Если кто-то накидает еще ссылок для изучения, то буду признателен.

2 комментария:

fukanchik комментирует...

Вот человек описывает boost interprocess https://members.accu.org/content/conf2013/Frank_Birbacher_Allocators.r210article.pdf

на стр. 12 он говорит "If a segment has already been created with that identifier we just start to participate."

eao197 комментирует...

@fukanchik

Тут гораздо интереснее, что у boost.interprocess под капотом. И, насколько я вижу, все тот же старый добрый UB.

Номер раз: https://github.com/boostorg/interprocess/blob/8322bae2f657145a0c1a8946f7c7f6c2b40c80cd/include/boost/interprocess/detail/segment_manager_helper.hpp#L148

Номер два: https://github.com/boostorg/interprocess/blob/c4a046793e07ac171ffba395402f8c9a6011cfc2/include/boost/interprocess/segment_manager.hpp#L879

Номер три: https://github.com/boostorg/interprocess/blob/c4a046793e07ac171ffba395402f8c9a6011cfc2/include/boost/interprocess/segment_manager.hpp#L742

Номер четыре: https://github.com/boostorg/interprocess/blob/c4a046793e07ac171ffba395402f8c9a6011cfc2/include/boost/interprocess/segment_manager.hpp#L450