Приветствую, читатель! Хотелось бы осветить свою небольшую библиотеку для C++, которая призвана помочь Вам создавать динамические структуры в shared-памяти. Далее - под катом.

Зачем это?

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

Первый кейс

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

Каким образом бы можно было это организовать?

Можно, например, сохранять данные в файле. Но как тогда обеспечить синхронизацию чтения и записи? Скорее всего, с этим возникнут сложности.
Есть более удобный и практичный способ - использовать shared-память. В такой памяти можно сохранить и mutex'ы, которые можно инициализировать как shared и синхронизовать операции чтения через них.
Но как тогда удобно описать структуру стека?

Можно, например, вот так:

struct shared_stack {
	int top;
	int& top(int x) {
		return *(int*)((std::size_t)this + sizeof(shared_stack) + top * sizeof(int);
	}
	void push(int x) { /**/ }
	void pop() { /**/ }
};

Да, неудобная арифметика указателей. Люди просвещенные могут сделать то же самое без арифметики:

struct shared_stack {
	int top;
	int data[];
	int top() {
		return data[top];
	}
	/**/
};

Да, ISO C++ говорит что так делать нехорошо, зато выглядит сносно.

Хорошо, теперь мы умеем размещать стек в shared-памяти, но что, если наша структура имеет куда более сложную модель?

Второй кейс

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

Получается, используя подход, похожий на подход из первого кейса мы получаем что-то уже очень страшное:

struct shared_stack {
	int max_size;
	int top;
	int data[];
	int top() {
		return data[top];
	}
	/**/
};

struct two_stacks {
	shared_stack first;
	shared_stack& second() {
		return *(shared_stack*)
      ((std::size_t)this + sizeof(shared_stack) + first.max_size * sizeof(int));
	}
};

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

struct two_stacks {
	std::size_t second_offset;
	void init(std::size_t first_size, std::size_t second_size) {
		first().init(first_size);
		// теперь стек умеет вычислять сколько памяти он использует
		second_offset = first().get_size();
		second().init(second_size);
	}

	template<typename T>
	T& get_at_offset(std::size_t offset) {
		return *(T*)((std::size_t)this + sizeof(two_stacks) + offset);
	}

	shared_stack& first() {
		return get_at_offset<shared_stack>(0);
	}

	shared_stack& second() {
		return get_at_offset<shared_stack>(second_offset);
	}
};

Но есть и вторая проблема, которую я не упомянул. Догадались?

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

А если мы захотим реализовать какие-то вложенные структуры, мы совсем закопаемся в вычислениях.

Что я предлагаю?

UPD: все действия с shared-памятью вам необходимо производить самим. Вам понадобится аллокатор, похожий на тот, что использован в коде ниже, только который по-настоящему выделяет shared-память/mmap'ит файлы в память.
При помощи моей небольшой библиотеки можно удобно создавать структуры любой сложности. К примеру, первый кейс выглядел бы вот так:

// Наследуемся от can_be shared,
// чтобы показать, что наша структура может быть
// размещена в shared-памяти
struct shared_stack : can_be_shared {
	// Указатели на наши данные
	int* size; // размер стека
	int* top_; // вершина стека
	int* data; // массив данных
	
	// Конструктор: max_size - максимальный размер стека
	// этот конструктор используется чтобы создавать новые
	// стеки, чтобы использовать существующий надо написать
	// второй конструктор - он ниже
	shared_stack(int max_size) : 
		can_be_shared({ 
			// Поля структуры
			field(&size, max_size), 
			field(&top_, 0), 
			// массив размера max_size, init_data 
			// инициализирует значения массива
			array(&data, max_size, init_data) 
			}) {}

	// Инициализует массив data:
	// what - что проинициализовать, idx - индекс элемента
	static void init_data(int& what, std::size_t idx) {
		what = 0;
	}

	// Конструктор по стеку, который уже лежит по адресу ptr
	shared_stack(void* ptr) : 
		// Указываем, что создаём объект из уже созданного
		from_existing(ptr),
		// и передаём поля для подстановки указателей
		can_be_shared({
			existing_field(&size),
			existing_field(&top_),
			existing_array(&data)
			}) {}
	// Стандартная реализация стека
	int& top() {
		return data[*top_ - 1];
	}
	
	void pop() {
		(*top_)--;
	}

	void push(int x) {
		data[(*top_)++] = x;
	}

	bool empty() {
		return !(*top_);
	}
};

// Аллокатор "shared"-памяти (тут используем свой)
std::vector<void*> to_be_free;
void* shared_allocator(std::size_t size) {
	assert(size != 0);
	void* res = malloc(size);
	to_be_free.push_back(res);
	std::cout << "Allocated " << size << " bytes at " << res << "\n";
	return res;
}

int main() {
	shared_stack stack(5);
  // make_shared вернет адрес выделенной памяти
	void* save_ptr = stack.make_shared(shared_allocator);
	stack.push(10);
	shared_stack ref(save_ptr);
	ref.push(12);
	while (!stack.empty()) {
		std::cout << stack.top() << ":" << ref.top() << "\n";
		stack.pop();
	}
	for (auto& e : to_be_free) {
		free(e);
	}
	return 0;
}

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

struct many_stacks : can_be_shared {
	shared_stack* stacks;
	
	static void init_fn(shared_stack& what,
		std::size_t idx,
		std::vector<size_t>& orig_sizes) {
		// Так как память, лежащая по адресу &what еще 
		// не инициализирована, необходимо вызывать конструктор
		// вот таким вот незатейливым образом
		new(&what) shared_stack(orig_sizes[idx]);
	}

	many_stacks(std::vector<std::size_t> sizes) :
		can_be_shared({
			array(&stacks, sizes.size(), init_fn, sizes)
			}) {};

	many_stacks(void* ptr) :
		from_existing(ptr),
		can_be_shared({
			existing_array(&stacks)
			}) {};
};

Заметим, что нам не пришлось трогать код написанный ранее - это хороший знак!

https://github.com/MrLolthe1st/shrared_structures - тут можно посмотреть код библиотеки, ну, и конечно же, скачать его для использования в своих проектах. Успехов!

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


  1. Yak52
    30.03.2022 06:42

    Да, удобно, спасибо. А вот у меня задача разместить в разделяемой памяти key-value хранилище, да еще сделать так что бы это хранилище синхронизировалось между несколькими хостами.


    1. mrlolthe1st Автор
      30.03.2022 06:45
      +1

      А я вот писал (еще два года назад) NoSQL БД для работы (нужен доступ из нескольких процессов + обновление), решил тут переделать почти весь бэкенд с PHP на CPP - а, значит, и заменить БД (тк та реализация была написана для использования из-под PHP) - решил сделать такую вот удобную (по моему мнению) библиотеку - чтобы и себе удобно было, и кому-то еще пригодилось. Так что если будут какие-то вопросы - можете написать, уже есть опыт в подобных штуках :)


  1. eao197
    30.03.2022 11:28
    +1

    Вот в этом фрагменте:

    	many_stacks(std::vector<std::size_t> sizes) :
    		from_existing(ptr),
    		can_be_shared({
    			array(&stacks, sizes.size(), init_fn, sizes)
    			}) {};

    Откуда берется ptr для передачи в from_existing?

    И в чем смысл передачи sizes по значению?

    Вот это: (std::size_t)this, если я правильно понимаю, не есть хорошо, т.к. нет гарантий, что значение указателя, преобразованное в беззнаковое целое, уместится в std::size_t. Для таких целей, если не ошибаюсь, std::uintptr_t должен использоваться. Надеюсь, более знающие люди поправят меня, если я не прав.

    Общее впечатление от кода вашей библиотеки (и ваших примеров здесь) стремнстранные. Приведение типов в стиле чистого Си вместо reinterpret_cast/static_cast, typedef вместо using-ов (при этом код на C++17), практически полное отсутствие комментариев.

    Еще очень сильно смущает принятое вами решение по дизайну вашей библиотеки: вы заставляете программиста перечислять поля типа два раза:

    	shared_stack(int max_size) : 
    		can_be_shared({ 
    			// Поля структуры
    			field(&size, max_size), 
    			field(&top_, 0), 
    			// массив размера max_size, init_data 
    			// инициализирует значения массива
    			array(&data, max_size, init_data) 
    			}) {}
          
      shared_stack(void* ptr) : 
    		// Указываем, что создаём объект из уже созданного
    		from_existing(ptr),
    		// и передаём поля для подстановки указателей
    		can_be_shared({
    			existing_field(&size),
    			existing_field(&top_),
    			existing_array(&data)
    			}) {}

    Такое дублирование не есть хорошо, оно чревато ошибками в будущем, когда на проект приходит Вася Пупкин и правит код Феди Иванова. Вася наверняка забудет перечислить новое поле в каком-то из этих мест.

    Может можно сделать так, чтобы разработчик указывал перечень полей всего лишь один раз?

    Ну и по тексту статьи: у меня после прочтения сложилось ощущение, что ваша библиотека берет на себя еще и заботу по работе с shared-памятью. Но оказалось, что это не так, вы лишь помогаете "размечать" блоки, которые кто-то в shared-памяти уже каким-то образом создал (как, собственно, и саму shared-память). ИМХО, это следовало бы описать в тексте.


    1. mrlolthe1st Автор
      30.03.2022 12:30

      1. Тут описался, тут не должен быть вызван этот конструктор.

      2. Чтобы можно было делать так: many_stacks x({3, 4, 5}). Можно сделать передачу по ссылке - но тогда уже так удобно нельзя будет написать.

      3. Покурил доки - переделаю на uintptr_t.

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

      5. Васе Пупкину поможет IDE, которая подскажет, что какое-то поле не инициализировано.

      6. Вот тут неправда. В функцию make_shared передаётся аллокатор, который и должен выделять эту shared-память.


      1. eao197
        30.03.2022 12:37

        Чтобы можно было делать так: many_stacks x({3, 4, 5}). Можно сделать передачу по ссылке - но тогда уже так удобно нельзя будет написать.

        По константной ссылке?

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

        Как будто с reinterpret_cast вы такой возможности не имеете.

        Вообще посыл был в том, что когда в C++ном коде встречается приведение в стиле чистого Си, то это дурно пахнет и от такого кода лучше держаться подальше. Если вы пишите библиотеку только для себя, тогда нет проблем. Если хотите, чтобы пользовался кто-то еще, тогда хорошо бы следовать принятым именно в C++ практикам. ИМХО, конечно.

        Васе Пупкину поможет IDE

        Вася Пупкин может написать:

        int data{};

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

        Вот тут неправда. В функцию make_shared передаётся аллокатор, который и должен выделять эту shared-память.

        И что, в вашей библиотеке есть такой готовый allocator?


        1. mrlolthe1st Автор
          30.03.2022 12:46

          Константная ссылка - ок.

          По поводу того, что не надо два раза писать конструктор - у меня пока нет идей как сделать это удобнее, чем есть сейчас.

          Нет, аллокатора нет - каждый может написать его по-своему, используя какие ему нужно библиотеки и функции (можно создавать System V-сегменты, можно mmap'ить файл в память - и тд и тп) - моя библиотека всего лишь его вызовет. Пример аллокатора был приведен в статье.


          1. eao197
            30.03.2022 12:50

            Нет, аллокатора нет - каждый может написать его по-своему

            Как раз к этому и относился мой комментарий. После прочтения статьи у меня сложилось ощущение, что подобная функциональность уже реализована в вашей библиотеке и программисту не придется париться самостоятельно с memory-mapped-files или еще чем-то. Но оказалось, что этого из коробки нет. ИМХО, если вы дополните статью таким дисклаймером, то тогда отсутствие готовых аллокаторов для shared-memory не будет сюрпризом для тех, кто заинтересуется вашей библиотекой.


  1. Urub
    30.03.2022 13:00

    поясните еще раз - shared память выделить нужно извне и синхронизировать доступ тоже ?

    в чем смысл библиотеки - ускользает от понимания

    много-процессорный пример был бы полезен )


    1. mrlolthe1st Автор
      30.03.2022 13:05

      Смысл библиотеки в том, чтобы удобно описывать и взаимодействовать со структурами. Память выделяет аллокатор, который вы передали в .make_shared(), синхронизировать доступ тоже надо извне. Потому, что каждому разработчику для каждого типа данных нужны свои гарантии. Может, не всегда надо блокировать всю структуру и прочее.


  1. eao197
    30.03.2022 13:20

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

    Нужно из одного процесса в другой передать объект, состоящий из двух строк. Размеры и содержимое этих строк известно в процессе конструирования значения в процессе-продюсере.

    Грубо говоря, в продюсере мне нужно что-то вроде:

    class package {
      char topic_[];
      char payload_[];
    public:
      package(std::string_view topic, std::string_view payload) {
        ... // Какое-то перемещение topic в topic_ и payload в payload_.
      }
    };

    А в процессе-консумере мне хочется получить что-то вроде:

    class package {
      ... // Не очень понимаю что.
    public:
      package(void * ptr) {... /* Какая-то магия */ }
      
      std::string_view topic() const { ... }
      std::string_view payload() const { ... }
    };


    1. mrlolthe1st Автор
      30.03.2022 21:53

      Будет что-то такое:

      class package : public can_be_shared {
      	char* topic_;
      	char* payload_;
      public:
      	inline static void init_view(char& c, std::size_t idx, const char* place) {
      		c = place[idx];
      	}
      
      	package(std::string_view topic, std::string_view payload) :
      		can_be_shared({
      			array(&topic_, topic.size() + 1, init_view, topic.data()),
      			array(&payload_, payload.size() + 1, init_view, payload.data())
      			}) {}
        
      	package(void* ptr) : from_existing(ptr),
      		can_be_shared({ existing_array(&topic_), existing_array(&payload_) }) {}
      
      	std::string_view topic() const { return topic_; }
      	std::string_view payload() const { return payload_; }
      
      };


      1. eao197
        30.03.2022 22:47

        	std::string_view topic() const { return topic_; }
        	std::string_view payload() const { return payload_; }

        Вопрос №1: откуда здесь возьмется размер для topic и payload. Вы предполагаете, что размер будет высчитываться поиском 0-символа, как в обычных Си-ных строках?

        Вопрос №2: откуда возьмутся значения указателей topic_ и payload_ в процессе-консумере? Да и, честно говоря, не очень понятно на что они будут указывать в процессе-продюсере.


  1. qw1
    30.03.2022 14:51

    Впечатление по первому примеру — автору нужно, чтобы объекты корректно работали на shared-памяти, то есть оба процесса могли одновременно класть значения в стек и извлекать…

    Однако дальше я потерял нить рассуждений. Понятно, что классы, которые пишет автор в дальшейших примерах, уже не могут работать в совместном режиме, т.к. в разных процессах shared-память маппится на разные адреса, а потому у классов не может быть полей-указателей (в том числе в объектах членах, как например есть std::vector pointers_to_init в can_be_shared).

    Далее, посмотрев на make_shared, у меня сложилось впечатление, что автор пытается скопировать объект в другой адрес, подправив указатели, чтобы он корректно прочитался в другом месте (позабыв при этом про указатели внутри std::vector?)

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


    1. mrlolthe1st Автор
      30.03.2022 16:05

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

      Цель вовсе в другом. Копировать данные через shared-память можно и при помощи очередей (из того же System V) и как их сериализовать - уже другая проблема. Там в тегах указано NoSQL. Например, эта библиотека лично мне нужна для того, чтобы переписать уже существующую NoSQL БД (которая должна работать достаточно быстро -> даже скорости просто mmap'нутого файла не достаточно) более простым способом. Там надо хранить колонки, индексы, значения. И это куда более трудоёмкая задача нежели сохранение объектов на стеке.

      Кстати, я не писал цель создания этих стеков -- это были синтетические примеры (некоторая часть задачи из реальной жизни). Почему Вы вдруг решили, что я хочу передавать объекты другому процессу?


      1. eao197
        30.03.2022 17:45
        +1

        Почему Вы вдруг решили, что я хочу передавать объекты другому процессу?

        Потому что в заголовке статьи есть "shared-память".


        1. mrlolthe1st Автор
          30.03.2022 21:05

          Передавать объекты другому процессу можно через System V очереди, зачем для этого использовать shared-память? Она используется для хранения структур данных в памяти.


          1. eao197
            30.03.2022 22:44

            зачем для этого использовать shared-память?

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

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

            У меня есть ощущение, что у вас какое-то собственное понятие "shared-память". Вроде бы когда говорят про shared memory, то имеют в виду именно общую для нескольких процессов память.


            1. mrlolthe1st Автор
              30.03.2022 23:10

              Так я всё равно не понял, почему нельзя использовать System V очереди? https://www.softprayog.in/programming/interprocess-communication-using-system-v-message-queues-in-linux


              1. eao197
                31.03.2022 07:19

                Так я всё равно не понял, почему нельзя использовать System V очереди?

                Как на счет вот этого: "нужно передавать блоки данных размером в сотни мегабайт." Очереди System V же копируют данные из адресного пространства одного процесса в адресное пространство другого. Копирование сотни мегабайт так себе удовольствие.

                Кроме того, одним Linux-ом мир не ограничивается, есть еще, как минимум Windows.

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

                Теперь, пожалуйста, удовлетворите мое любопытство: что вы сами понимаете под термином "shared-память"?


      1. qw1
        30.03.2022 19:15

        Если цель — файловая БД, и объектики 'can_be_shared' кладутся в отмапленный на память файл, то вот это нужно бы переписать:

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

        Особенно, когда рядом заявляется
        даже скорости просто mmap'нутого файла не достаточно


        1. mrlolthe1st Автор
          30.03.2022 21:31

          Нет, не файловая, я же писал, что используется System V shared-сегменты. Во-первых, достаточно одной инициализации во время запуска. (Те создать объекты всех нужных мне таблиц можно перед началом выполнения кода). Во-вторых, копировать объекты запрещено (зачем их копировать, зачем вам вообще в рамках одного процесса несколько объектов, указывающих на одну таблицу, если можно передавать по ссылке уже существующие?) Так что потратить один раз несколько десятков миллисекунд на инициализацию объекта не так страшно, ведь потом не надо делать никаких вычислений над указателями.
          Так что не вижу тут никакого оверхеда. https://pastebin.com/ZjyRxRsJ


          1. qw1
            31.03.2022 00:10

            Я то думал, что объекты-наследники `can_be_shared` — объекты бизнес-логики, и их в приложении может быть миллионы. И в каждом — вектор с метаданными, и его инициализация.


            1. mrlolthe1st Автор
              31.03.2022 00:22

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


  1. ksergey01
    30.03.2022 18:38
    +1

    А boost::interprocess не тоже самое делает? Там и аллокаторы уже реализованы


    1. mrlolthe1st Автор
      30.03.2022 21:31

      boost явно тяжелее, чем файл в 300 строк.


      1. Urub
        31.03.2022 10:38
        +1

        boost явно более отлажен, чем эти 300 строк

        кстати, какая цель была для написанию - просто интересно или альтернативы не устроили ?


  1. Kelbon
    01.04.2022 10:32
    +1

    Если это не первоапрельское, то годный сборник undefined behavior в стиле си с множеством примеров как НЕ надо делать