Создавая объект за объектом, мы часто не обращаем внимания на такую «мелочь», как динамическое выделение памяти. Наравне с копированием и сериализацией, выделение памяти из кучи через new постепенно сводит на нет преимущества C++ в скорости. Чем интенсивнее мы пользуемся заветным new, тем сложнее становится приложению, поскольку память кончается, фрагментируется и всячески стремится утекать. Эта участь уже постигла удобные, но неявно опасные для производительности контейнеры STL: vector, string, deque, map. Особенно обидно терять скорость на выделении небольших объектов в больших количествах. Но есть способ обработать размещение памяти таких объектов на стеке, при этом скрывая детали реализации в специальный класс данных. В этом нам поможет механизм размещающего new — непревзойденный способ оптимизации приложения, полного частых и мелких выделений памяти из кучи.

В прошлом уроке мы делали поразительные вещи: работали с объектами C++ как с контейнерами, содержащими значения типа, вычисленного на этапе выполнения и заполненного динамически. Мы активно использовали надстройку Copy-on-Write над std::shared_ptr, которым ссылались на реальный тип данных, при заполнении объекта. При этом подразумевалось, что память под любую инициализацию данных мы будем выделять также динамически, вызывая new каждый раз, как только нам понадобятся новые данные произвольного типа.

Такой подход имеет свои преимущества. Данные можно разделять между несколькими объектами, откладывая копирование. Можно, в принципе, ничего не знать заранее о типе данных. Однако есть у этого метода и ряд недостатков, из-за которого Copy-on-Write используется, как правило, для объектов, потенциально довольно больших.

Первый недостаток выясняется сразу же. Массовое динамическое выделение памяти серьезно замедляет выполнение программы, особенно массовое неявное выделение памяти через new. Да, я в курсе и про std::string, и про std::vector, которые зачастую, не спрашивая программиста, начинают перераспределять память, вызывая один new за другим (причем про переразмещение данных в std::vector мы еще поговорим). Хороший специалист в C++ разработке всегда знает об этих забавных особенностях стандартных контейнеров и понимает, как избежать лишних затрат на выделение новых сегментов памяти. Чем всегда был хорош чистый си, так это именно тем, что любая работа с памятью выполнялась прозрачно, в C++ всегда нужно держать в голове целый ряд случаев неявной работы с памятью.

Второй недостаток является следствием первого. Частое выделение небольших сегментов памяти в больших количествах приведет к жуткой фрагментации памяти и невозможности выделить даже довольно небольшой блок памяти единым куском, например для инициализации того же std::vector или std::string. В результате мы получаем bad_alloc безо всяких видимых причин. Памяти намного больше, чем нужно, а выделить непрерывный блок даже небольшого размера в условиях сильно фрагментированной памяти не получится.

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

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

Первый класс


Внешне почти ничего не меняется. Все тот же класс, обеспечивающий API объектов. Класс содержит ссылку на данные, класс которых объявлен через forward declaration и будет вынесен в детали реализации. Из-за этого поле класса нельзя объявить объектом данного типа, однако на тип данных можно сослаться простым указателем и заранее завести буфер для хранения данных объекта в самом же объекте. Если объект будет создан, например, на стеке, то и все данные будут храниться на стеке как часть объекта. Теперь рассмотрим пример, чтобы все встало на свои места:

class object
{
public:
    ...
protected:
    // Объявление класса данных
    class data;
    // Заранее известное количество байтов под данные
    static const size_t max_data_size = N;
private:
    // Указатель на данные
    data* m_data;
    // Буфер памяти, где будут храниться данные
    char m_buffer[max_data_size];
};

В этом фрагменте кода мы продолжаем идеологию сокрытия данных в реализации, все, что мы знаем о данных класса, — это имя класса и наличие указателя на данные. Однако теперь у нас есть возможность не лезть за памятью в heap. Класс в терминологии C++ все так же хранит данные в виде своих полей. По сути, данные разместятся в буфере m_buffer, память под который выделена уже при создании класса. Осталось лишь объяснить детали, как разместить данные в буфер байт.

Размещающий new


Как правило, немногие вспоминают про такое полезное свойство оператора new, как возможность указать готовую область памяти для размещения создаваемого объекта. Все, что нам потребуется, — это написать new(m_buffer) для создания любого типа объекта в выделенном буфере. Звучит просто, однако нужно помнить, что платим мы высокую цену: заранее указывая максимальный размер буфера. Мало того, размер буфера попадает в заголовочный файл и явно участвует в объявлении API.

Зато мы выигрываем в скорости. Если, выделяя данные в куче на каждую инициализацию, мы рискуем отстать от Java, то, размещая все данные в стеке, мы имеем скорость чистого си, недостижимую скорость для почти любого языка высокого уровня, кроме C++. При этом уровень абстракции крайне высок, мы выстраиваем API на обычных объектах C++, скрывая детали реализации. Единственное ограничение — размер, который мы задаем; мы уже не можем запросто менять в реализации набор полей у класса данных, всегда нужно помнить о размере. Мало того, нам необходимо проверять размер данных, описанных в реализации, на соответствие с указанным в заголовочном файле. Просто потому, что сборка библиотеки может расходиться с версией заголовочных файлов, например при получении из различных источников. Рассмотрим пример, как должна выглядеть подобная проверка, как и создание объекта в подготовленной памяти размещающим new.

object::object()
    : m_data(new(m_buffer) object::data)
{
    static_assert(sizeof(object::data) <= max_data_size, "...");
}

Здесь static_assert фактически выполнится на этапе компиляции, поэтому инициализация m_data будет выполнена, только если для object::data достаточно памяти в буфере m_buffer. Аналогично у класса-наследника, например flower, класса object данные также не должны превышать заданную планку, поскольку данные мы храним в реализации базового класса.

flower::flower(std::string const& name)
    : object(new(get_buffer()) flower::data(name))
{
    static_assert(sizeof(flower::data) < max_data_size, "..." );
}

Очевидно, что для этого нужен protected-метод get_buffer() для получения адреса m_buffer в базовом классе, а также protected-конструктор object от object::data*. Так же, как и в прошлом выпуске, мы наследуем данные наследников от данных базового класса, поэтому flower::data* совместим с object::data*. Для безопасности стоит в базовый конструктор от object::data* добавить проверку на то, что передан адрес именно заранее выделенного буфера:

object::object(object::data* data_ptr)
{
    if (static_cast<void*>(data_ptr) != static_cast<void*>(m_buffer))
        throw some_exception(...);
    m_data = data_ptr;
}

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

object rose = flower("rose");

Объекты с данными большого размера


Осталось выяснить, что делать с объектами, чей размер данных выходит за рамки обозначенного максимума. На самом деле и здесь все довольно просто. Достаточно, чтобы в лимит вписывался размер copy_on_write<data::impl>, который по сути является надстройкой над std::shared_ptr<data::impl>, где impl — реализация класса данных произвольного размера. Поскольку размер std::shared_ptr<data::impl> не зависит от размера самих объектов класса data::impl, мы получаем универсальный способ хранения данных с переходом от хранения по значению к хранению по ссылке.

class huge
{
public:
    ...
protected:
    class data;
};

class huge::data
{
public:
    ...
protected:
    class impl;
private:
    copy_on_write<impl> m_impl;
};

Однако отвлечемся от решения проблемы единого API для объектов с динамической типизацией и рассмотрим другой пример оптимизации через размещающий new.

copy_on_write::flashback


Если кто-то пропустил прошлый выпуск, то класс copy_on_write — это шаблонный класс для хранения данных с оптимизацией копирования. Эмулируя указатель, этот класс имеет хитрую перегрузку operator-> для const и non-const случаев. При копировании объектов мы ссылаемся на одни и те же данные, не вызывая дорогостоящего копирования. Однако, как только мы вызываем неконстантный метод класса данных, потенциально изменяющий данные, мы отцепляем для текущего объекта свою копию данных. Упрощенно реализация выглядит примерно так:

template <typename impl_type>
class copy_on_write
{
public:
    copy_on_write(impl_type* pimpl)
        : m_pimpl(pimpl) {
    }
    impl_type* operator -> () {
        if (!m_pimpl.unique())
            m_pimpl.reset(new impl_type(*m_pimpl));
        return m_pimpl.get();
    }
    impl_type const* operator -> () const {
        return m_pimpl.get();
    }
private:
    std::shared_ptr<impl_type> m_pimpl;
};

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

Поля выборки данных


Самый мощный способ оптимизации через размещающий new — это поля записей выборки в результате SQL-запроса. Выборка запрашивает набор данных самых разнообразных типов, от целочисленных и вещественных до строк и массивов. Хотя сами данные получаются динамически и типы полей, полученные со стороны базы данных, приходится инициализировать с эмуляцией динамической типизации, но зато все записи содержат один и тот же набор типов полей, по которому можно определить общий размер данных для каждой записи. Это позволяет нам выделить память под поля записи лишь однажды, вычислив размер по типам полей, входящим в каждую запись выборки. Можно также выделить память однажды для всех записей единым блоком, однако, как правило, после выборки над записями производят всевозможные операции, в том числе фильтруя и сортируя их, поэтому сами записи имеет смысл описать в виде Copy-on-Write объектов для удобства последующих операций. Выделять же для каждого поля память из кучи неоправданно дорого.

Так будет выглядеть наш класс запись, если упростить объявление и использовать copy_on_write напрямую от класса данных:

class record
{
public:
    record(std::vector<field::type> const& types);
    ...
protected:
    class data;
private:
    copy_on_write<data> m_data;
};

class record::data
{
public:
    data(std::vector<field::type> const& types);
    ...
private:
    std::vector<char> m_buffer;
    std::vector<field*> m_fields;
};

Здесь для упрощения пояснения введен вектор типов полей std::vector<field::type>, массив enum-значений. На самом деле этот массив следует набрать из аргументов через boost::fusion либо, используя Boost.Preprocessor, набрать массив из обобщенных объектов типа object от любого типа аргументов. Нам сейчас важен сам механизм однократного выделения памяти из кучи для каждой записи.

record::data::data(std::vector<field::type> const& types)
    : m_buffer(field::calc_size(types)),
      m_fields(types.size())
{
    size_t offset = 0;
    std::transform(types.begin(), types.end(), m_fields.begin(),
        [&offset](field::type type, field*& field_ptr) {
           field_ptr = new(m_buffer + offset) field(type);
           offset += field::size(type);
        }
    );
}

где field::size вычисляет размер данных по переданному field::type, а field::calc_size вычисляет уже суммарный размер, необходимый под весь набор типов записи, переданный как std::vector<field::type>.

Поле field реализуется аналогично типу object, по сути контейнер динамического содержимого. Большая часть типов: int64_t, bool, double — скаляры и хранятся по значению. Тип std::string также может храниться по значению, однако стоит учитывать то, что почти наверняка данные строки будут храниться в куче и выделяться динамически. Если хочется поддержать некий varchar определенной длины, то здесь, скорее всего, нужен будет свой тип copy_on_write с массивом символов фиксированной длины.

Различные типы полей аналогичны различным типам объектов, унаследованных от класса object. Можно даже не использовать enum, а завязаться напрямую на типы, но, как правило, разбор результата SQL-запроса влечет за собой десериализацию пакета байтов с данными, где все типы полей заранее известны, поэтому enum для удобства здесь никаких ограничений не влечет. Тем более что метапрограммирование — стезя не для слабонервных, и MPL и Boost.Fusion мы здесь рассматривать не будем.

Осталось затронуть последний важный аспект использования размещающего new — пул однотипных объектов в C++.

Пул однотипных объектов


Как и прежде, мы оптимизируем динамическое выделение памяти. Что такое пул объектов? Это заранее выделяемый большим скопом массив заготовок для инициализации определенного типа. В некотором смысле record выше был пулом для объектов field. Также ты наверняка встречал пул объектов, если работал с высокоуровневыми языками (C#, Python, Java), ведь для выделения новых объектов они используют заготовленные сегменты памяти, в которых размещают объекты, по сути тип object. После того как один из объектов пула становится не нужен, иными словами на него перестали ссылаться, он либо сразу деинициализируется, либо ждет своей печальной участи в виде очередного обхода Garbage Collector’а — сборщика мусора — специального механизма удаления бесхозного добра. Вообще говоря, деинициализация объектов в пуле — его слабое место. Зато мы получаем скоростное выделение объектов, как правило либо уже инициализированных, либо подготовленных для инициализации. Если делать на основе нашего типа object полноценный пул объектов с деинициализацией по счетчику ссылок и с Garbage Collector’ом, то мы получим Java или Python. Если тебе потребовалось что-то подобное, может, не стоит городить огород и взять готовый язык со сборкой мусора? Однако если для оптимизации однотипных объектов потребовалось выделить заранее большой сегмент памяти и задача действительно требует массовой инициализации большого числа объектов с неким базовым классом, то пул объектов позволит избежать массы динамических выделений памяти.

Чтобы разобраться, нам потребуется понятное прикладное объяснение. Как насчет собственно выборки в результате SQL-запроса с пулом для записей? Это позволит оптимизировать массу выделений памяти для построения объектов записей выборки.

class selection
{
public:
    selection(std::vector<field::type> const& types,
              size_t row_count);
    ...
protected:
    class data;
private:
    copy_on_write<data> m_data;
};

class selection::data
{
public:
    data(std::vector<field::type> const& types,
         size_t row_count);
    ...
private:
    std::vector<field::type> m_types;
    std::vector<char> m_buffer;
    std::vector<record> m_rows;
};

selection::data::data(std::vector<field::type> const& types,
                      size_t row_count)
    : m_types(types)
{
    if (!row_count) return;
    m_rows.reserve(row_count);
    size_t row_data_size = field::calc_size(types);
    m_buffer.resize(row_count * row_data_size);
    char* offset = m_buffer
    for (size_t i = 0; i < row_count; ++i)
    {
        m_rows.push_back(record::inplace(offset, types));
        offset += row_data_size;
    }
}

Где record::inplace по сути создает данные записи не в куче, а по заданному адресу.

record record::inplace(void* address,
                       std::vector<field::type> const& types)
{
    return record(new(address) record::data(types));
}

Нам потребуется конструктор record с инициализацией и специальный деструктор, об этом далее. Данный вариант инициализации record делает невозможным использование его в предыдущем варианте, то есть в виде класса, содержащего лишь поле copy_on_write. Мы не сможем, спокойно понадеявшись на динамическое выделение данных в куче, ворочать записями как хотим. С другой стороны, мы получаем сумасшедший прирост производительности при большом наборе данных. Однако есть в размещающем new подвох, о котором следует знать.

Явный вызов деструктора


WARNING


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

Есть еще одно «но» при использовании размещающего new — придется вызывать деструктор самим, вручную, поскольку delete не сделает ровным счетом ничего. Поэтому класс, содержащий данные, выделяющиеся в заранее подготовленную память, должен в деструкторе явно вызвать деструктор созданного в памяти класса. Так, деструктор класса object::~object должен явно вызвать деструктор object::data::~data, а деструктор record::data::~data должен будет позвать целый ряд деструкторов field::~field — по одному на каждое поле. Для того чтобы наглядно показать, как это должно происходить, я более детально распишу класс object.

class object
{
public:
    object();
    virtual ~object();
    ...
protected:
    class data;
    char* get_buffer();
    object(data* derived_data);
    static const size_t max_data_size = N;
private:
    data* m_data;
    char m_buffer[max_data_size];
};

object::object()
    : m_data(new(m_buffer) data)
{
    static_assert(sizeof(data) <= max_data_size, "...");
}

object::~object()
{
    m_data->~data();
}

Поскольку деструктор у класса данных должен быть описан как virtual, то и деинициализация данных пройдет успешно, какой бы наследник object::data ни использовался.

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

object::object(object const& another)
    : m_buffer(max_data_size),
      m_data(another.clone_data_at(m_buffer))
{
}

object& object::operator = (object const& another)
{
    destruct_data(); // здесь нужно вызвать деструктор
    m_data = another.clone_data_at(m_buffer);
    return *this;
}

object::data* object::clone_data_at(void* address)
{
    return m_data->clone_at(address);
}

// Этот метод должен быть перегружен
// для каждого наследуемого типа данных
object::data* object::data::clone_at(void* address)
{
    return new(address) data(*this);
}

void object::destruct_data()
{
    m_data->~data();
}

Здесь наш новый метод desctuct_data() так и просится в деструктор object::~object. Раз просится, значит, там ему самое место. Для конструктора и оператора перемещения поведение похожее:

object::object(object&& another)
    : m_data(another.move_data_to(m_buffer))
{
}

object& object::operator = (object const& another)
{
    destruct_data(); // здесь нужно вызвать деструктор
    m_data = another.move_data_to(m_buffer);
    return *this;
}

object::data* object::move_data_to(void* address)
{
    return m_data->move_to(address);
}

// Этот метод должен быть перегружен
// для каждого наследуемого типа данных
object::data* object::data::move_to(void* address)
{
    return new(address) data(std::move(*this));
}

object::~object()
{
    destruct_data();
}

Итак, опасность memory leak’ов ликвидирована. Пользователи твоего API могут разрабатывать спокойно.

Размещающий new против new в куче


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

Выгода в скорости. Сила C++ по сравнению с более удобными C#, Java и Python — в скорости выполнения. Здесь мы достигаем наивысших скоростей, поскольку не идем в кучу за новыми объектами. И не замедляем приложение в дальнейшей перспективе, избегая фрагментации памяти. Фрагментированная память как сыр: полна дырок, и в сумме размер этих дырок позволяет запихать туда апельсин, но на самом деле апельсин не поместится ни в одну из дыр, каждая из них слишком мала. Так и std::vector, как и std::string, требующие сегмент непрерывной памяти, могут в один прекрасный момент получить std::bad_alloc при перераспределении элементов.

Размещающий new в стандартной библиотеке


Помнишь, я обещал тебе рассказать про размещающий new в std::vector в начале статьи? Так вот, все конструкторы элементов в std::vector вызываются в подготовленной памяти. И так же активно для элементов вызываются деструкторы. Это не принципиально для векторов от простых POD-типов вроде int или char, но если мы хотим выделить std::vector, причем custom обладает нетривиальным и тяжелым конструктором по умолчанию и не менее тяжелым конструктором копирования, то мы получим массу неприятностей, если не будем знать, как работает std::vector со своими данными.

Итак, что же происходит, когда мы просим вектор изменить размер? Для начала вектор смотрит, что еще не зарезервировал нужное число байтов (буфер вектор всегда выделяет с запасом), после чего выделяет новый буфер. Все существующие элементы переносятся в новый буфер конструктором перемещения через размещающий new по соответствующему адресу. В результате все элементы стоят на своих местах. После чего вектор добирает нужное число элементов в конец массива, создавая каждый размещающим new и конструктором по умолчанию. Так же и в обратную сторону — уменьшение количества элементов вызовет деструкторы «вручную» при удалении элементов.

В отличие от std::vector, контейнер std::string не занимается placement new просто потому, что хранит всегда char, не нуждающийся в конструкторах или деструкторах. Зато целый ряд контейнеров стандартной библиотеки: deque, list, map и другие шаблоны классов для хранения произвольных данных — активно используют размещающий new в своей реализации.

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

Что в итоге?


Конечно, умение пользоваться размещающим new там, где надо, и только тогда, когда это действительно нужно, эффективно и оправданно, приходит не сразу. Одни до последнего отбиваются вредом предварительной оптимизации, другие, наоборот, только прочитав статью, бросятся встраивать new(m_buffer), где надо и где не надо. Со временем и те и другие приходят к золотой середине.

Суть метода проста — если есть возможность и необходимость разместить объект класса в заранее приготовленную память, сделать это относительно просто, если помнить пару несложных правил:

  • память должна жить все время, пока в ней живет объект, если память потрут, то объект начнет ссылаться на битый сегмент памяти;
  • деструктор класса для объекта, выделенного размещающим new, должен быть вызван вручную, это печально, но delete не делает с памятью по указателю ровным счетом ничего.

Все остальное ограничивается лишь аккуратностью и безграничной фантазией разработчика. То есть тебя.

image

Впервые опубликовано в журнале Хакер #190.
Автор: Владимир Qualab Керимов, ведущий С++ разработчик компании Parallels


Подпишись на «Хакер»

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


  1. QtRoS
    18.05.2015 11:10
    +1

    то, размещая все данные в стеке, мы имеем скорость чистого си

    Разрешите уточнить — без применения размещающего new у Вас возникала ситуация, когда независимо от расположения объекта (в стеке или куче) реальные данные всегда были в куче? Такой подход в Qt'e активно используется, концепция d-pointer'ов, и определенные преимущества есть — бинарная совместимость не ломается, данные качественно сокрыты и т.д.


    1. Qualab
      18.05.2015 13:28

      Тут тоже не ломается бинарная совместимость и о классе с данными мы знаем только то, что он есть, в API вынесен только указатель на него. Мало того, этот подход позволяет не принудительно использовать D-pointer'ы, а применять их только для больших объектов.


  1. FoxCanFly
    18.05.2015 11:43

    Как быть с тем, что размер стека обычно очень ограничен?


    1. Qualab
      18.05.2015 13:26

      Большие объекты не обязательно пихать в стек, для этого есть куча и copy-on-write из прошлой статьи. А вот множество операций над маленькими объектами, теми же скалярами и небольшими составными типами, лучше всего делать на стеке, на это его хватает.


    1. monah_tuk
      19.05.2015 05:34

      Больше всего с проблемой нехватки стека приходится сталкиваться в embedded, на одном нашем проекте просто память была поделена на несколько пулов. Среди которых был блочный, байтовый (для generic аллокаций) и два варианта small-пулов для размещения маленьких объектов. Где аллоцировать память решала реализация аллокатора. Да временами приходилось перекраивать пулы, но проблем с фрагментацией стало значительно меньше.


      1. Qualab
        19.05.2015 13:36

        Можно использовать не обязательно память стека. Память можно изначально подготовить в куче, как это делает std::vector для своих элементов.


        1. monah_tuk
          19.05.2015 16:07

          Собственно примерное про это же я и написал. Просто со стековой памятью проще.


  1. MaxFactor
    18.05.2015 12:04
    -7

    А в Delphi давно (этак с версии Delphi 7 точно) нет проблем с частыми мелкими выделениями частей памяти, там свой менеджер памяти, а многие ругают его за это. Т.е. я так понимаю, что превращают C++ в Delphi


  1. DmitryMe
    18.05.2015 14:11
    +1

    object& object::operator = (object const& another)
    {
        destruct_data(); // здесь нужно вызвать деструктор
        m_data = another.move_data_to(m_buffer);
        return *this;
    }
    

    Неправильно отработает, если попытаться присвоить объект самому себе (явно написать a=a или, что потом сложнее искать, использовать два указателя, привязанные к одному и тому же объекту).


    1. Qualab
      18.05.2015 18:10

      Ну страшного ничего не будет, поскольку std::move выполняет перенос поэлементно. Другое дело, что по-хорошему нужен другой метод для оператора копирования copy_data_to, а move_data_to должен переехать в оператор переноса operator = (object&& temporary), ну и обычная проверка на &another != this явно не помешает.


      1. DmitryMe
        18.05.2015 22:02
        +2

        страшного ничего не будет
        destruct_data() приведет к вызову m_data->~data(), в результате объект по адресу m_data будет разрушен, далее вызовется another.move_data_to(m_buffer), а это приведет к вызову m_data->move_to(address). Поскольку объект один и тот же, m_data содержит адрес только что разрушенного объекта. Вызов нестатического метода ранее разрушенного объекта приводит к неопределенному поведению (за редкими исключениями, например, случая, когда деструктор тривиальный, но тут, очевидно, не тот случай).

        Не стоит характеризовать неопределенное поведение словами «ничего страшного не будет»? Вот пример того, как компиляторы используют неопределенное поведение для оптимизации и в результате «ломают» (на самом деле — доламывают изначально сломанный) код.


        1. Qualab
          19.05.2015 13:08
          -2

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


          1. DmitryMe
            19.05.2015 13:34
            +1

            Категорически с вами не согласен, что нужно в «академическом» коде оставлять типичные ошибки вроде неправильной работы оператора присванивания при присваивании объекта самому себе. Тем более не стоит оправдывать их сферическими юнит-тестами в вакууме, которые вряд ли будут отдельно проверять, что присваивание объекта самому себе правильно работает, — хотя бы потому что в случае непрохождения более сложного теста поиск ошибки может занять довольно много времени. Наконец, если вы так легко относитесь к неопределенному поведению, компилятор может с одинаковым удовольствием доломать и ваш код, и юнит-тест, который вы написали к этому коду.


            1. Qualab
              19.05.2015 13:40

              Никто не относится легко к неопределённому поведению. Фактически после уничтожения объекта считывать его нельзя. Вы правы. Посыпаю голову пеплом, за косяк к коде Академии C++. Фактически в operator = (object const&) должен быть принципиально другой код, копирующий, а не мувающий, с проверкой на this, как я и писал выше.


  1. OlegMax
    18.05.2015 15:43
    +3

    Пока в статье нет сравнения производительности предложенного примера в вариантах с placement new и с достаточно современным memory manager, эта статья скорее вредна, чем полезна.


    1. Qualab
      18.05.2015 18:00
      -1

      Это что такой за современный memory manager, что сгоняет в кучу за памятью, а потом заполнит эту память объектом, который делает это быстрее чем просто заполнение памятью объектом без первого шага?


      1. OlegMax
        18.05.2015 22:08

        А зачем быстрее? Не достаточно, чтобы было одинаково? Заодно посмотрим на «сумасшедший прирост производительности» (tm), посмеемся вместе.


        1. Qualab
          19.05.2015 13:11
          -1

          Какие милые детские мечты об идеальном memory manager, даже жаль разбивать веру человека в высшие силы, но мы представители точных наук. Вот тебе тест, можешь баловаться на любой машине:
          http://pastebin.com/GxvMkeEt
          Не забудь выставить оптимизацию. Получается вот такое соотношение:
          TEST STARTED WITH COUNT = 1234567

          placement new: 0.011
          heap new: 0.082
          placement free: 0.005
          heap free: 0.048

          P.S. Вообще странно доказывать такие вещи программисту C++, обычно про идеально быстрое выделение памяти пишут программисты Java, у них это какая-то навязчивая идея.


          1. OlegMax
            19.05.2015 14:10

            Вообще-то я имел в виду сравнение чуть более комплексных задач. Например, упомянутые в статье записи SQL, где сама запись выделяется в куче, а поля — в буфере.
            Но даже на таком синтетическом тесте получается интересный результат. Практически ничего (placement new) сравнивается со сложным алгоритмом (полномасштабный heap с thread safety и т.д.) и получется разница на один порядок. 10 раз, Карл!
            Вот теперь читатель статьи может прикинуть, стоит ли геморрой выделки.


            1. Qualab
              19.05.2015 14:22
              +1

              То есть для тебя 13 секунд простоя на каждые 100 млн объектов вообще не показатель? Я как бы напомню, что результат выборки SQL-запроса может быть весьма немалой таблицей разнотипных данных с кучей скаляров и NULL-значений. Это ещё однопоточный тест, здесь куча ещё более-менее в комфортной ситуации. Про Debug-режим, где каждый delete отзывается резкой болью и приводит к висякам при отладке, я даже не упоминаю. Если учесть, что рабочее высоконагруженное приложение в несколько потоков параллельно перелопачивает многие миллионы объектов на каждую операцию, то эти лишние секунды приведут к покупке новых серверов, вместо того, чтобы взять одного толкового программиста.


              1. OlegMax
                19.05.2015 14:58
                -1

                Нет, 13 секунд простоя на каждые 100 млн объектов для меня вообще не показатель. Объяснить почему или догадаешься?
                Пример системы (взят с потолка): в malloc/free тратится 5% времени, из всех malloc/free 50% более-менее можно переделать на placement new/delete. Итого 2.3% экономии. И куча проблем с отладкой. «Сумасшедший прирост производительности», однако!


                1. Qualab
                  19.05.2015 15:24

                  Пример из статьи сильно упрощённый вариант реально произведённой оптимизации кода, который за каждым полем для хранения лазил в кучу, на продакшн версии. В результате оптимизации выделений памяти код обработки результатов запроса стал работать в среднем в 5 раз быстрее, так как львиную долю съедала именно работа с кучей из нескольких потоков одновременно.
                  P.S. Посмотри в конце концов на std::vector, его реализация не лазит за каждым элементов в кучу — он выполняет placement new. Ведь ты утверждаешь, что зря он это делает.


                  1. OlegMax
                    19.05.2015 15:28

                    Какой использовался heap manager и были ли испробованы другие реализации heap manager?


                    1. Qualab
                      19.05.2015 15:35
                      +1

                      Для std::vector? Никакой не используется. Этот код одинаково алгоритмически эффективен для любой системы. Равно как и после оптимизации число выделений памяти в коде стало столь мало, что перестало иметь смысл искать «серебрянный» heap manager. Код просто стал работать быстро, независимо от менеджмента памяти в куче. Так и должен работать программист C++, его код либо эффективен, либо ему пора в Java.


                      1. OlegMax
                        19.05.2015 16:05

                        Убедительно)
                        Нам же платят за то, что мы пишем код, а не за то, что мы не пишем код.


    1. aclex
      03.06.2015 13:14

      По опыту своих недавних экспериментов с похожим примером склонен с Вами согласиться. В общем случае, если мы не говорим о хранении в выделенном буфере значений только тривиально копируемых типов, выигрыш при выделении памяти, как это ни странно, с запасом компенсируется потерями на обслуживание нетривиально копируемых значений вместо дешёвых манипуляций с указателем. По моим наблюдениям, наибольшую проблему составляет потеря дешевизны сдвигающих конструктора и присваивания, да и swap превращается в довольно громоздкую операцию (частично этот вопрос поднимается Скотом Мейерсом здесь: std::string, SSO, and Move Semantics).

      Чтобы не быть голословным и проиллюстрировать выводы примером, с которым я экспериментировал, приведу ссылку на код, благо он открыт. Здесь в ветке master реализовано type erasure-хранение в динамической памяти, а в compact_storage — во внутреннем буфере с использованием std::aligned_storage. Эквивалентный тест в этом файле (собирается при сборке проекта). Производительность и профили можете сравнить в своих условиях, на моей машине (GCC 4.8.2, GNU/Linux, x86_64) версия с внутренним хранением несколько менее производительна как в отладочной, так и в оптимизированной сборках.


  1. tridemax
    18.05.2015 19:02
    +2

    Потратив достаточно большое количество времени на борьбу с фрагментацией в процессе работы в игровой индустрии, я бы порекомендовал вместо этого решения просто использовать TBB scalable allocator (https://www.threadingbuildingblocks.org/docs/help/tbb_userguide/Memory_Allocation.htm#tutorial_Memory_Allocation).

    Эффективно борется с фрагментацией, масштабируется (100 потоков, постоянно выделяющие память, поставят msvcr120.dll:malloc на колени из-за мьютекса). При этом не нужно ничего менять в своем коде. =)

    Для дебага я порекоммендовал бы остаться на дефолтном аллокаторе (диагностика leaks and overrun), прибегая к помощи Александреску и его small object allocator в особо запущеных случаях, когда хочется скорости даже в дебаге.


    1. Qualab
      19.05.2015 13:15

      Зато приведённое решение позволяет сочетать динамическую типизацию и лёгкую замену имплементации (все данные спрятаны). Причём внешне программист работает с объектами по значению.


      1. tridemax
        19.05.2015 13:52

        Динамическая типизация ведет к лишнему cache miss при доступе к данным. Не то, чтобы это сильно пугало для имплементации логики (cache miss виртуальной таблицы нас ведь не смущает), но при работе с гигабайтами данными может вполне ощутимое количество циклов съесть, потому как каждый мисс может стоить очень дорого.


    1. OlegMax
      19.05.2015 14:16

      поставят msvcr120.dll:malloc на колени из-за мьютекса

      У меня было впечатление, что CRT использует напрямую системный heap, который реализован довольно прилично в Win7. Меня TBB malloc как-то особо не впечатлил. Может для WinXP имеет смысл, так как там системный heap тупее.


  1. yomayo
    20.05.2015 09:23

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