Сама тема была обозначена здесь.
После штудирования найденной в Интернете информации сложилось следующее впечатление: если нам нужно передавать через 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.
Если кто-то накидает еще ссылок для изучения, то буду признателен.
Вот человек описывает 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."
@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