Pull to refresh

Comments 46

Именно по этой причине я не использую std::shared_ptr если слабые ссылки не нужны.
Самописанный умный указатель на атомарном счетчике (лежи в объекте) работает гораздо быстрее чем std::make_shared и кушает меньше памяти…
UFO just landed and posted this here
А можете объяснить подробнее, как это счётчик «лежит в объекте» и при этом разделяется между несколькими копиями умного указателя?

А в чём, собственно, проблема-то? Так все умные указатели делали, пока авторы shared_ptr не изобрели отдельный control block.

Каждый объект содержит в себе счетчик. А умный указатель управляет этим счетчиком. В отличии от универсального решения — счетчик можно положить в любое место и уменьшить размер объекта в памяти. Из-за выравнивания часто получаем небольшой оверхед при наследовании или композиции. Этот небольшой оверхед может раздуть объект например с 16 байт до 24 (~30%)…
Т.е. этот умный указатель предъявляет требования к хранимым типам? И совсем не способен хранить без обёртки простые типы, контейнеры стандартной библиотеки, классы сторонних библиотек?
Да. Требует 2 функции — увеличить счетчик и уменьшить счетчик. Я такой подход применяю только в тех местах где нужна максимальная скорость и/или минимальный объем использованной памяти (а это почти всегда пишется на c++:)
Иными словами зачастую стандартный std::shared_ptr — содержит избыточные функции за которые платим скоростью и памятью…
Универсальностью и архитектурным изяществом этот подход не блещет, но для ограниченного специального применения действительно может сгодиться.
Этот подход лет 10 назад назывался intrusive pointer и в том или ином виде применялся много где. Даже в MFC для CString хотя и не в классическом виде.

Давно уже не пишу на С++ и могу ошибаться, но был такой костыль для shared_ptr — базовый класс enable_shared. Не засовывал ли он блок контроля в класс, тем самым превращая всю эту конструкцию в intrusive pointer?
UFO just landed and posted this here
Однако, использование std::enable_shared_from_this не является обязательным при использовании std::shared_ptr.
но ведь стоит задумываться об эффективности того, что ты делаешь :)

Аж COM пахнуло, AddRef, Release, IUnknown.
Не от хорошей жизни это делали, и не зря перестали.
Вы не занимаетесь ли преждевременной оптимизацией? Ведь надо еще и сложность поддержки учитывать, и общую многословность решения.
У меня есть некоторые сомнения, что правильно используемые shared_ptr хоть сколько-то значимо проиграют COM-like подходу, особенно при современных оптимизациях.

Если вам интересно — подобный механизм управления памятью я использую в мобильном приложении Guru Maps. А конкретнее в подготовке векторных тайлов для прорисовки. Исходные данные такие: есть тайлы в которых может быть около 100000 объектов и стиль который определяет как это все рисовать. Если использовать стандартные подходы то на стареньком телефоне прожевать такое будет очень проблематично. В итоге приходится использовать всякие хитрые оптимизации:
— кастомный аллокатор при загрузке объектов. Никаких счетчиков тут не используется ибо даже они приводят к ощутимым педалям. А так как время жизни четко известно то и удалять все можно одной пачкой.
— те данные что должны получится на выходе (геометрия для прорисовки, высчитанные параметры и прочее) используют счетчики. Тут уже критичен оверхед по памяти. Ибо +-мебагайт памяти на тайл это уже не мало.
Мне вот не совсем понятно.
Почему не использовать std::unique_ptr? Неужели вам в самом деле надо разделять владение? А если и так — почему надо разделять владения 100 000 объектов, а не одного объекта, содержащего 100 000 элементов?
Немного практических примеров когда такое нужно:
1) Объект состоит из геометрии (ее не надо хранить всегда, после объединения в батчи выкидывается) и из атрибутов. Атрибутов может быть много они часто повторяются и некоторые нужны после обработки. Сами атрибуты могут быть не большими (особенно числа) и оверхед от shared_ptr существенно увеличит использование памяти.

2) вычисление стиля прорисовки объекта подразумевает много работы над атрибутами. Причём многие действия не создают новые атрибуты а выбирают из уже существующих (например выбрать из возможных локализаций нужный текст или выбрать из набора цветов один нужный). И тут мои замеры показывали что shared_ptr копирует себя медленнее чем вариант без поддержки слабых ссылок. И причина очевидна — shared_prt для копии надо записать 2 указателя и увеличить счётчик, тогда мое самописанное без поддержки слабых ссылок — 1 указатель и увеличить счётчик…

3) Есть набор пользовательских букмарок. Выбрав из всех букмарок те что видны — сделали кластеризацию. Букмарка по сути представляет из себя 2 координаты, ссылку на стиль прорисовки и ещё пару мелких флагов. Поэтому в памяти занимает не много но если для менеджмента использовать shared_ptr возникает очень большой оверхед. Эту самую кластеризацию нужно держать и на случай если надо проверить нажатие и для подготовки прорисовки и для поиска.

Как видите во всех случаях идёт работа с большим набором простых данных. Причём когда объект станет не нужным неизвестно…
Если у вас 100к шаред пойнтеров, возможно проблема в дизайне, а не в мейк_шаред. Особенно, если данные иммутабельные. ТС это тоже относится
А что, если выделять объекты в пулах (до 65536 штук в пуле) и ограничить значение счетчика 16 битами?

Тогда, за счет знания границ пула, первые 16 битов указателя на x64 можно использовать, как счетчик, а следующие — как смещение до следующего свободного/занятого блока для выделения/освобождения за О(1).

Это же позволит избежать вызова апи оси или rtl на этих ваших выделениях по 50000 отдельных объектов на куче.

Можно, конечно, придумать альтернативу: использовать для этих целей вектора объектов вида {указатель, счетчик, смещение-до-следующего-свободного} и дескрипторы (индекс в векторе), но это — не путь самурая, который не боится 100000 shared_ptr-ов…
Кастомные аллокаторы мы используем тоже.
Особенно весело получилось в триангуляции (когда надо полигон покрыть треугольниками чтоб можно было рисовать на GPU).
Вообще зная некоторые особенности данных (одинаковый размер, одинаковое время жизни, малое количество результатов на выходе итд) можно придумать решение с менеджментом памяти которе будет работать значительно быстрее стандартного. Этим и крут c++.
И да многие при оценке сложности работы алгоритма не учитывают, что работа с памятью не бесплатная…

За конкретной реализацией можно посмотреть boost::intrusive_ptr. В некоторых случаях достаточно часто используется.

Отличная штука. Давно уже используем. И особенно потоко-небезопасную версию. Позволяет делать умные указатели с подсчетом ссылок в пределах одного потока. Ну и избежать оверхеда от shared_ptr (да и local_shared_ptr)

С boost::intrusive_ptr (и скорее всего с самописными версиями онного, как у destman, не получится реализовать аналог weak_ptr. Он конечно не всем нужен, так что нужно просто учитывать эту особенность.

Честно говоря, я очень долгое время думал, что основное назначение std::make_... — возможность не писать тип в контейнере два раза.


  • new Bar;
  • foo();
  • конструктор std::shared_ptr.

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

Возможность не писать тип два раза — да, есть такое. Но это просто мелочь по сравнению с остальными вопросами, рассмотренными в статье.
Если я не ошибаюсь, шаблон std::enable_shared_from_this будет работать неправильно, если объект был создан не через make_shared.
Я бы точно не сказал что это проблема ( хранение control block вместе с объектом на одном куске памяти ),
я бы сказал это особенность которая может не всем подойти.
1. Не везде где есть shared_ptr есть weak_ptr.
2. Выделение одного куска — меньшая фрагментация
3. Выделение одного куска — большая локальность данных (cache friendly).
Это довольно странная и неочевидная особенность, что способ создания существенно влияет на поведение объекта, при этом спросить объект в рантайме «ты чьих будешь» никак нельзя.

Ну тут ещё нужно сказать, что два выделения памяти всё же медленнее одного. Как и две деаллокации. Зависит от реализации и состояния кучи, конечно, но как правило заметно. И если weak нет или используются редко, то это ещё один плюс к перечисленным выше. И даже когда weak используется, то часто он ненадолго переживает shared_ptr. Например, при выполнении временной задачи параллельно с разрушением объекта. Или в кэше — до следующей проверки.
Вы же как раз говорите про более редкий сценарий (по крайней мере в моей практике), когда всегда есть weak на жирный объект, который надолго переживает shared_ptr. Ну очень специфическая ситуация.
Если не нравится текущее описание в cpp core guidelines, то туда можно контрибьютить. Можете попытаться там добавить комментарий вида "за исключением ситуаций, когда вы используете долгоживущие weak на толстые объекты и вам не критично замедление, но критична память". Впрочем, сомневаюсь, что примут, т.к. в cpp core guidelines прямым текстом сказано, что из правил всегда есть исключения, а правила предлагают разумные рекомендации для большинства случаев.


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

Одна аллокация и деаллокация вместо двух — это положительная сторона медали (но, заметьте — она не гарантирована). Но она же обязательно влечёт вторую сторону, отрицательную.
Будет ли для вас отрицательная сторона заметна — это вопрос.
Как впрочем и положительная.
Но это совсем не повод утверждать, что отрицательное — это не отрицательное, или что его нет.

Просто документацию читать надо перед тем, как что-то использовать

Статьи на хабре вообще не нужны. Учебники тоже. И комментарии. И обсуждения. И форумы. Это всё для слабаков, которые не умеют читать справочники.

Просто статья получилось очень уж однобокой. Взять мелочь, пропустить всё про частотность сценария, уменьшить влияние двойных аллокаций и деаллокаций на скорость, и объявить всему миру про открытие. Типичная статья в жёлтой прессе.

Нет, не так.
Я начал с истоков — почему std::make_shared был прямо жизненно необходим. Потому что утечки — это серьёзная проблема. И с утечками sdt::make_shared действительно борется успешно. Или боролся. До c++17. И до c++17 действительно можно было просто применять std::make_shared, даже толком не вникая в подробности. Потому что это решало критичный, первостепенный вопрос. Не читая документации, не разбираясь — просто применять готовый рецепт.

А Вы сейчас — про производительность.
Так вот, производительность — вопрос вторичный. Кому-то может её хватать, а кому-то — нет. Способов оптимизации — масса. Критичность недостаточной производительности как правило намного ниже, чем текущей памяти, которая может заканчиваться крэшами.
Вы говорите про утечки в ситуациях, когда было выброшено исключение параллельно с выделением памяти. ИМХО, тоже очень редкий сценарий. Если мы пишем серверное ПО, то вообще к динамическому выделению памяти нужно относиться очень аккуратно. Если пишем мобильное приложение, то такого рода утечки на самом деле не критичны. А вот скорость запуска — критична, т.к. напрямую влияет на retention.
К тому же проблема, которая приводила к утечкам, на самом деле является общей — вычисление аргументов функций не определено. И проблема осталась даже в c++17, хотя и исчезла в частных случаях (например, в операторах). Поскольку это общая проблема, про неё нужно знать всем разработчикам на c++.
Вы можете себе представить задание типа «давайте сделаем очень быструю программу, а если будет падать — ну ничего страшного, пользователи переживут»?

Про особенности вычисления аргументов тему я тоже поднял. Именно потому, что это важно и в данном контексте, и вообще для разработчика c++.
Я могу представить себе «мы сделаем быстрое мобильное приложение, но будем раскатывать по частям и будем смотреть на Firebase и останавливать раскатку, разбираться с падениями и чинить их, если их будет заметное количество». При этом если приложение падает через месяц непрерывного использования, то это лучше бы и посмотреть, но не критично. И если падение было один раз у одного пользователя из миллионов, то точно смотреть не нужно.

Это совершенно точно не типичная статья в желтой прессе, ибо ценность информации в типичной статье желтой прессе стремится к 0.


Лично я не знал такие подробности, поэтому данная статья позволит будущему мне сэкономить время (если вдруг придется копать в эту сторону).

Я не про отсутствие полезного содержания — оно безусловно присутствует. Я про форму подачи. У меня в голове возникают заголовки «C++ core guidelines дают плохие советы» и «make_shared вреден». Второй, впрочем, недалеко ушёл от реального заголовка. Я боюсь, что новички прочитают такую статью и не будут читать core guidelines, т.к. там «всё равно плохого насоветуют». Или будут бездумно избегать make_shared, заметно замедляя какие-то сценарии.
Очень важно знать частотность сценариев и влияние на производительность. Я утверждаю, что в большинстве случаев проблемы, обозначенной автором, просто нет. Но есть конкретные ситуации, в которых про особенность нужно помнить. И именно на таких сценариях нужно было сделать акцент. Когда именно возникает необходимость держать weak на тяжёлый объект долго? В статье про это ни слова не сказано.
Я не говорю, что в библиотеках c++ всё идеально. Есть и неудачные решения. Пример того, где на review четыре человека просмотрели ошибку — передача временного объекта в boost::iterator_range. И это именно ошибка дизайна, т.к. тут можно было бы сделать решение, которое было бы безопасным.
Но make_shared — не пример неудачного решения. Это пример продуманного компромисса.
Существенная часть посыла статьи: «контекст, в котором родился std::make_shared, существенно изменился с приходом c++17».

Далее, Вы говорите про производительность.
Да, это важный параметр.
Но:
— std::make_shared не гарантирует повышение производительности. Если бы была функция std::make_single_allocation_shared — вот тогда да, в точку;
— с нехваткой производительности на std::shared_ptr реально столкнуться тогда, когда их в самом деле 100 000. И если бы я на практике такое встретил — выруливал бы не в сторону экономии одной аллокации (всё равно ведь не спасёт), а действительно, как говорит destman, или в сторону кастомного решения со строго необходимым функционалом, или в сторону уменьшения количества разделяемых объектов до единиц — десятков.
Про 100000. У нас в DI-контейнере лежат сотни элементов, в основном как раз по shared_ptr. Объекты часто ссылаются на какие-то разделяемые данные. Плюс то, плюс сё. Тысяч 10, думаю, может набраться.
Я прикинул — это будет в районе нескольких мс на создание/разрушение на мобилке. Но это суммарно, а оно всё-таки не всё на старте, и потому сильно менее важно. Т.е. да, тезис про производительность не сильно оправдан.
Я потихоньку уменьшаю использование аллокаций и указателей в целом. Но больше эффекта, например, дала замена std::string на string_view во многих местах. Или отказ от хранения json dom в случаях, когда нужен не весь json, а только его подмножество.
При этом при довольно активном использовании shared- и weak-указателей я не помню ситуаций, чтобы «утечка» памяти из-за weak_ptr приводила бы к проблемам, поэтому считаю ваш пример тоже надуманным.
К слову на iOS free работает примерно в 2 раза медленнее чем malloc. И до кучи блокирует free в других потоках…

Что только доказывает мой тезис)

Ну, это не первый случай, когда в язык сначала добавляют хотфикс для «быстрого» решения проблемы, а спустя сколько-то лет делают нормально. Чего только стоит история с добавлением и баном auto_ptr.
Логично было бы сделать поведение std::make_shared зависимым от величины sizeof(T). Например, у менеджера памяти есть пул для объектов размером от 1 до 512 байт и блок управления имеет размер 24 байта, то для объектов размером до 488 байт производит слияние объекта и блока управления, а если больше 488 байт, то делать две аллокации.

к сожалению, без профилирования нельзя четко сказать: бесполезный и вредный.


  • ситуация 1: я шарю int и часто к нему обращаюсь. Очень хорошо, что он в одном блоке с control_block и наплевать, что 4байта памяти будут висеть на слабых ссылках
  • ситуация 2: я шарю вектор на 10к элементов и обращаюсь к нему редко. Здесь верно все то, о чем в статье написано

Так что в качестве вывода можно написать, что std::make_shared лучше подходит для малых объектов(до 64 — sizeof(control_block)) и частых обращений. И приводет к подвисанию памяти на больших объектах.


По поводу custom deleter — "не поддерживает" и "не возможно сказать устновлен ли" — это разные характеристики, не правда ли?


По поводу custom new и custom delete — да, но так ли они нужны, когда проще просто залинковать какойнибудь jmalloc?

По поводу custom deleter — «не поддерживает» и «не возможно сказать устновлен ли» — это разные характеристики, не правда ли?

Вот тут Вы не правильно поняли.

В статье я говорю, что невозможно сказать, как именно поведёт себя экземпляр std::shared_ptr, т.е. как он создан — с помощью конструктора или с помощью std::make_shared. Разница в поведении существенная (настолько существенная, что это могли бы быть даже разные статические типы), а выяснить это нельзя.
уменьшение числа маленьких аллокаций при использовании make_shared уже существенный плюс
для примера уйдем в другую крайность, множество маленьких аллокаций и ни одного weak_ptr
#include <iostream>
#include <chrono>
#include <list>

template<class T, class F>
auto test(F&& create)
{
  std::list<std::shared_ptr<T>> set;
  auto max = std::numeric_limits<size_t>::max();
  for (size_t count = 0; count < max - 1; ++count)
  {
    try
    {
      set.emplace_back(create(count));
    }
    catch (const std::bad_alloc&)
    {
      return count;
    }
  }

  return max;
}

int main(int argc, char *argv[])
{
  using namespace std::chrono;
  using type = int;
  if (argc == 1)
  {
    auto start = steady_clock::now();
    auto val = test<type>([](auto val) { return std::make_shared<type>(val); });
    auto end = steady_clock::now();
    std::cout << "make_shared | count: " << val << ", time: " << duration_cast<milliseconds>(end - start).count() << "msc\n";
  }
  else
  {
    auto start = steady_clock::now();
    auto val = test<type>([](auto val) { return new type(val); });
    auto end = steady_clock::now();
    std::cout << "new | count: " << val << ", time: " << duration_cast<milliseconds>(end - start).count() << "msc\n";
  }
}

вариант с make_shared делает 2 аллокации на элемент, вместо трех, позволяя эффективнее использовать память
на моем компе x86 вариант с make_shared размещает на треть элементов больше)
PS. тестить лучше в разные запуски
Sign up to leave a comment.

Articles