Использование альтернативного аллокатора памяти в проекте на C/C++

Эта статья написана прежде всего для программистов C/C++, использующих в своей работе Visual Studio 2013. Поскольку я, как говорится, totally windows guy, то я не могу оценить полезность этой статьи для программистов, не использующих эту среду в своей работе. Итак.

Не секрет, что стандартный аллокатор new/delete/malloc/free в языке C/C++ не блещет быстродействием. Конечно, всё зависит от реализации, но, если говорить об оной от компании Microsoft, то это факт. Кроме того, стандартная реализация аллокатора обладает еще одним фатальным недостатком — фрагментацией памяти. Если в вашей программе происходят частые выделения/освобождения памяти, вы можете обнаружить, что спустя несколько часов работы ваша программа упала по причине нехватки памяти, хотя свободной памяти еще достаточно — просто в результате фрагментации в пуле аллокатора не осталось свободного участка достаточного размера. (Это, кстати, абсолютно реальный случай, который произошел на одном из проектов, в котором я принимал непосредственное участие.)

К счастью, существуют аллокаторы, которые лишены обоих этих недостатков. В свое время я попробовал dlmalloc и с тех пор всегда использую его в своих проектах.

Хочу поделиться с вами способом подключения dlmalloc к проекту на Visual Studio C/C++.
Способ, который я использую, примечателен тем, что позволяет использовать альтернативный аллокатор абсолютно для всех аллокаций, которые только могут случиться в вашей программе. Да, простой способ (т.е. замена вызовов malloc на dlmalloc) не достигает этого эффекта. Например, вы подключили стороннюю библиотеку, которая выделят память с помощью malloc. Более того, некоторые вызовы стандартных функций из stdlib также выделяют память функцией malloc и у вас нет возможности этому помешать… Или есть? Есть.

Суть способа


Суть способа в том, чтобы заставить линковщик использовать вашу реализацию malloc/free/new/delete вместо стандартной. Но как это сделать? Когда я только начал исследовать этот вопрос, моей первой попыткой была достаточно глупая идея: пропатчить в runtime тело malloc/free в памяти, поместив туда безусловный jmp на мой код. Надо ли объяснять, почему эта идея глупа? Хотя все работало, но радости этот способ не приносил. В итоге я пришел к другому решению, а именно — запретить линковщику вообще использовать стандартную библиотеку libcmt, в которой и находится стандартный аллокатор. Но и этот способ обладал существенным недостатком, а именно, в этой библиотеке достаточно много других полезных и не очень функций, написать к которым заглушки было категорически невозможно.
Тогда я стал исследовать возможность взять стандартную библиотеку (буквально файл libcmt.lib) и выкинуть из него всё лишние. Оказалось, что это возможно и в итоге этот способ я и использую.

Небольшое отступление
Я говорю о файле libcmt.lib, однако вы должны понимать, что всё тоже самое справедливо и для libc.lib. Объяснение разницы между этими библиотеками выходит за рамки этой статьи.

Технические подробности


Сначала выполним команду:

lib.exe /LIST libcmt.lib

На выходе получим список obj файлов, которые эта библиотека содержит. Для libcmt.lib из Visual Studio 2013 этот список выглядит примерно так:

f:\binaries\Intermediate\vctools\crt_bld\SELF_X86\crt\prebuild\INTEL\mt_lib\chandler4.obj
f:\binaries\Intermediate\vctools\crt_bld\SELF_X86\crt\prebuild\INTEL\mt_lib\chandler4gs.obj
f:\binaries\Intermediate\vctools\crt_bld\SELF_X86\crt\prebuild\INTEL\mt_lib\chkesp.obj
f:\binaries\Intermediate\vctools\crt_bld\SELF_X86\crt\prebuild\INTEL\mt_lib\eh3valid.obj
f:\binaries\Intermediate\vctools\crt_bld\SELF_X86\crt\prebuild\INTEL\mt_lib\exsup.obj
f:\binaries\Intermediate\vctools\crt_bld\SELF_X86\crt\prebuild\INTEL\mt_lib\exsup2.obj
... (и так далее)

К счастью, практически все функции по работе с памятью находятся в отдельных obj файлах, что, собственно, и делает этот способ возможным.
Т.е. нам остается вырезать из тела библиотеки все ненужные obj файлы.
Утилита lib.exe с ключем /remove как раз делает то, что нам нужно.

Реализация


Собственно, исходные коды я выложил на гитхабе.

Если у вас уже установлена Visual Studio 2013, достаточно запустить make_libcmt_nomem.cmd, который выполнит всю работу и создаст обрезанный файл libcmt_nomem.lib, который можно подключать вместо полновесного libcmt.

В своей работе скрипт использует unix утилиту grep. Если у вас не установлены UnixUtils, настоятельно рекомендую это сделать (например отсюда).

Но это еще не всё. От стандартного аллокатора мы избавились. Но беда в том, что заодно мы избавились и от некоторой стандартной функциональности, которая, увы, неотделима от аллокатора. Поэтому мной были написаны необходимые заглушки, которые вы можете найти в файле include/crtfunc.h (там же, на гитхабе).

Способ применения


  1. Получаем обрезанную версию стандартной библиотеки с помощью скрипта make_libcmt_nomem.cmd и кладем ее в доступное для линковщика место;
  2. Отключаем в проекте использование стандартной библиотеки libcmt (Ignore Specific Default Libraries" «libcmt» в опциях линкера Configuration Properties->Linker->Input);
  3. В любом c++ файле в проекте делаем "#include crtfunc.h" из исходников;
  4. Подключаем dlmalloc к проекту.

Я не расписываю подробно каждый пункт, поскольку, если вы прочитали эту статью и поняли ее, подробности вам и не требуются. Единственный момент: подключать crtfunc.h следует именно в C++ (не C) файл. Если ваш проект написан на C, вам следует добавить к проекту пустой .cpp файл и включить в него crtfunc.h. Впрочем, никто не запрещает вам взять в руки напильник.

PS. На самом деле, не dlmalloc'ом единым. Существуют и другие, весьма достойные аллокаторы. Исходные файлы рассчитаны на dlmalloc, но это не принципиально. Минимальным вмешательством в crtfunc.h можно добиться использования любого другого аллокатора.

После выхода Visual Studio 2015 всё вышенаписанное утратило актуальность
AdBlock похитил этот баннер, но баннеры не зубы — отрастут

Подробнее
Реклама

Комментарии 40

    +8
    Поясните пожалуйста, зачем поступать столь варварским способом вместо того чтобы глобально переопределить new и delete?

    Если говорить о том, что где-то в C++ программе кто-то использует malloc/free вместо new/delete… Ну в угол такого автора надо поставить и конфет лишить.
      +2
      new и delete — да, malloc и free — нет.
      Большинство полезных сторонних библиотек написаны на C, а значит new и delete, очевидно, там не используются.
      Простой пример — sqlite.
      Ну и я бы не назвал этот способ варварским. Обрезанная библиотека создается один раз и далее просто используется. Кроме того, использовать ее можно только в конфигурации Release, а в Debug все оставить как было.
        +4
        Зачем глобально переопределять new и delete, когда это можно сделать для отдельных классов, которые часто аллокируется?
          0
          Ну это вопрос скорее к автору, который решил во всей программе поменять всю работу с динамической памятью. Стандартный способ для C++ это сделать — переопределить new/delete глобально.

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

          Однако, при наличии исходников можно поиском/заменой переименовать все malloc/free на что-то свое.

          Я с вами согласен, уместнее было бы выявить узкое место, и там подменить аллокатор, а не затевать крупномасштабные маневры с компоновщиком.
            +6
            > Однако, при наличии исходников можно поиском/заменой переименовать все malloc/free на что-то свое.
            А вот это как раз варварский способ. Этим мы сразу сильно усложняем себе дальнейшее обновление используемых библиотек.
            Хорошо, если библиотека позволяет подменять аллокатор (например pnglib), а если не позволяет, то подмена стандартной библиотеки видится мне единственным способом.
            • НЛО прилетело и опубликовало эту надпись здесь
              +3
              А зачем? В С malloc определяется в mem.h и stdlib.h. Можно вместо них подключить что-то своё.
                0
                А можно через линкер символы переопределить.
            0
            Например malloc и free используются при разработке собственных контейнеров. Да, кстати std::aallocator тоже не использует new и delete (только placement)
              0
              Проблемы, которые можно получить, используя malloc/free:
              • malloc возвращает void*, который затем приходится силком преобразовывать в нужный тип. При этом, контроль над размером и типом возлагаются на программиста;
              • malloc тяжело переопределить штатными средствами, из-за чего собственно и приходится идти на разные ухищрения вроде описанных автором;
              • если перепутать и попытаться высвободить память, выделенную malloc, при помощи delete, получим неизвестно что;
              • используя malloc, можно позабыть вызвать конструктор.

              Ссылки:
              1. Ответ на stackoverflow
              2. Ответ на C++ FAQ
              3. Заметка на cppreference

              Все эти источники гласят: в чистом C++ malloc/free скорее вредны. Единственное оправдание — работа с гибридным C/C++ кодом.
                0
                Покажите другой кроссплатформенный способ реализовать нормально тот же аналог std::vector. А эти проблемы из разряда «что-бы нанятая мартышка ничего не сломала»
                  0
                  А что не так с std::vector? Он использует абстрактный аллокатор, который имеет только методы allocate/deallocate. Иными словами, всякий раз, когда вектору надо подрасти (.size()>.capacity()), он выделяет новый блок и копирует (C++11 — перемещает) в него все элементы.

                  Иллюстрации процесса на stackoverflow

                  Да, vector не использует realloc вовсе. Это связано с тем, что использование realloc может внезапно поломать те экземпляры классов, которые находятся в блоке, который вдруг взял — и переехал на другой базовый адрес.

                  realloc программисту C++ не друг, а источник плавающих ошибок.
                    0
                    Тогда по другому: напишите свою кроссплатформенную реализациб std::allocator, идентичную оригиналу, не используя malloc/free
                      –1
                      Так я в ней буду использовать стандартные операторы из C++ — new и delete. А уж как их реализовали авторы стандартной библиотеки — меня не волнует.
                        –1
                        И получите тормоза из-за ненужных вызовов конструкторов, выделяя память с запасом, когда можно просто выделить сырую память malloc и вызывать конструктор отдельно для уже выделенного участка памяти, когда потребуется. Именно так работает весь STL, и именно тем отличаются у контейнеров там reserve() от resize().
                          0
                          Давайте уточним, во-первых, о какой реализации STL мы говорим. Я говорю о реализации GNU STL, которая используется в GCC.

                          Во-вторых, я еще раз напоминаю, что вектор в STL всегда использует аллокатор, у которого нет вызова, соответствующего realloc.

                          Так каким образом, по вашему, STL реализует resize/reserve? При помощи malloc/realloc или тем, который по вашему заверению «дает тормоза»?
                            +1
                            Стандартный аллокатор в реализация STL в gcc вызывает не malloc, а "::operator new(size)", как раз для того, чтобы можно было переопределить этот new глобально для всего приложения.
                              +1
                              В реализации STL в Visual Studio 2012 стандартный аллокатор тоже вызывает "::operator new(size)", а не malloc. В реализациях Apache stdcxx и libc++ делается то же самое.

                              Вообще странно бы было, если бы стандартный аллокатор пытался бы вызывать malloc, когда есть возможность вызвать new и предоставить возможность его глобального переопределения.
                                +2
                                Глянул в стандарт. Пункт 20.9.5.1 требует, чтобы стандартный аллокатор использовал ::operator new для получения памяти.
                              –1
                              Уточню — работа с динамической памятью в C++ должна осуществляться посредством new/delete.

                              Если при этом мы желаем воспользоваться наследием C, каким-нибудь fake_alloc, мы должны обернуть его в new/delete, с учетом стандарта не забыть обеспечить выброс исключений, и обращаться к вызовам fake_alloc только из этих самых new/delete.
                                +1
                                В allocate вызывается new char[size], в deallocate — delete [] reinterpret_cast<char*>(ptr). Зачем malloc/free?
                    • НЛО прилетело и опубликовало эту надпись здесь
                        +11
                        Я правильно понял, что ваша версия new
                        void * __cdecl operator new(size_t sz)
                        {
                             return dlmalloc(sz);
                        }
                        

                        игнорирует стандарт C++, § 5.3.4/15:
                        [Note: unless an allocation function is declared with a non-throwing exception-specification(15.4), it indicates failure to allocate storage by throwing a std::bad_alloc exception (Clause 15, 18.6.2.1); it returns a non-null pointer otherwise. If the allocation function is declared with a non-throwing exception-specification, it returns null to indicate failure to allocate storage and a non-null pointer otherwise.—end note]

                        [Замечание: За исключением ситуации, когда функция, выделяющая память, объявлена не выбрасывающей исключений(15.4), индикация ошибки выполняется путем выбрасывания исключения std::bad_alloc; в случае отсутствия ошибок, функция должна выдать ненулевой указатель. Если же таковая функция объявлена, как не бросающая исключений, она должна вернуть ноль для индикации ошибки при выделении памяти, и ненулевой указатель в противном случае. — конец замечания]

                        Вы подменяете глобальный new, поэтому вы должны, чтобы не вызвать непоняток у вышестоящих библиотек, бросать исключение при ошибке выделения.
                          0
                          Формально вы, конечно, правы.
                          Я выложил код, который использую у себя. Так получилось, что в своих программах я предпочитаю исключения C++ отключать вообще. Я сторонник такого подхода, что таких исключений в программе быть не должно, краши же ловятся с помощью SEH.
                          Что же касается исключения по причине нехватки памяти, то, согласитесь, в современных системах памяти столько, что ситуация, когда программе ее не хватило, говорит о какой-то фундаментальной проблеме приложения. В любом случае bad_alloc — это в 99% случаев гарантированное падение.
                          Резюмируя:
                          да, по хорошему я должен был написать в коде бросок исключения. Вообще я должен был написать еще много чего, например проверку на то, в C или C++ файл включают мою заглушку, удобный способ замены dlmalloc'а на что-то еще, комментариев побольше, и т.п.
                          Это всё так, но всё дело в том, что у меня не было такой задачи. Я не задавался целью написать универсальную заглушку на все случаи жизни. Я задавался целью познакомить программистов со способом подмены аллокатора. Просто воспринимайте этот код в качестве… м… учебного, чтоли.
                            +4
                            Если и считать ваш код учебным, то только в качестве примера «как делать не следует». Опасно вольничать со стандартом. Очень сомнительно игнорировать языковые механизмы (интересно, как вы реализуете конструктор без исключений? Как реализуете RAII?).
                            Как работать с библиотеками, которые информируют об ошибках только при помощи исключений (STL, кстати, не исключение)? Как, наконец, корректно вычищать ресурсы после возникновения проблем? Объявлять панику и падать?

                            Ссылка на интересную запись в блоге Nicholas Nethercote об опасности кода, использующего стандартную библиотеку C++, и скомпилированного без поддержки исключений.

                            В любом случае, ваша замена — глобальна, следовательно — влияет на все вызовы new в программе. Что будет происходить, когда new вместо выбрасывания исключения вернет нуль, а программа, справедливо не ожидающая такого подвоха, его разыменует? UB будет происходить. Любой код, содержащий любые, даже маловероятные (закон Мерфи, как известно, работает), предпосылки к UB может быть отправлен исключительно в мусорку. (Если это не очередная иллюстрация — «смотрите, а еще UB можно сотворить вот так»).
                              0
                              учебный код в данном случае — не «то как надо делать», а то что «сделано упрощенно, чтобы только понять суть».
                                0
                                Исключения при выделении памяти вещь на практике довольно сомнительная. По сути в user space на современных десктоп платформах все что может сделать приложение при получении ошибки выделения памяти — это завершить работу, что можно сделать либо явно (abort) или через segfault по обращению к нулевому указателю. Сделать какой-то recovery в случае ошибки выделения — это удел каких-нибудь специализированных приложений или где-нибудь в embedded/kernel (где исключения скорей все не используются в любом случае).
                                  0
                                  Это очень сильно завязано на реализацию аллокатора в ОС. И это может быть пользовательский аллокатор поверх своего, пользовательского пула памяти.
                                  И тогда ситуация «пул заполнен» не означает «у ОС кончилась память».

                                  А еще, в такой ситуации приложение может попытаться начать повторный расчет, используя более экономный метод. Язык дает автору все средства для этого.
                                    0
                                    В этом примере нет ничего про пул, заменяется стандартный new/malloc. Если какая-то из библиотек использует пул и специализированный аллокатор, то они в логике ничего не должны потерять.

                                    Реалистично приложениям отработать ошибку работы с памятью в портабельном C++ невозможно. Допустим на linux вы пишете char* hello = new char[очень_большое_число], то hello получить валидный указатель поскольку Linux не выделяет физическую память в этот момент, и программа упадет из-за нехватки памяти в момент записи в какой-нибудь hello[i].

                                    Если я правильно понял вашу отсылку на стандарт, то чтобы этот new из crtfunc.h соответствовал стандарту ему нужно добавить non-throwing exception-specification или внутри проверять на null и выбрасывать bad_alloc? Я думаю это несложно было бы сделать и соответстовать стандарту, хоть никаким лучшим образом на поведение программ это бы не повлияло.
                                      0
                                      А если память сильно фрагментирована и такого блока просто нет? Если у меня C++ на микроконтроллере работает, и при недостатке памяти я просто игнорирую всю дальнейшую обработку и жду, пока память освободится?

                                      Вы хотите сказать, что в стандарте вообще надо было проигнорировать ошибки, связанные с памятью, потому как вам лично кажется, что из таких ошибок восстановиться невозможно?
                                        0
                                        Нет, я говорю что данной библиотеке можно было бы вполне проигнорировать обработку ошибок от аллокатора по стандарту потому какого-либо способа ее обработки кроме terminate нет. (читаем статью: VC2013, Windows).
                                          0
                                          Игнорирование этой ошибки приводит к неопределенному поведению (показано выше). Неопределенное поведение недопустимо.
                                            0
                                            Если вы под неопределенным поведением понимаете разыменовывание нулевого указателя, то хотя по стандарту C++ оно неопределено, на ОС Windows у него вполне конкретное проявление при чтении/или записи. Так что здесь мы по сути выбираем между terminate() или EXCEPTION_ACCESS_VIOLATION. Решать разработчику приложения важна ли ему разница.

                                            Я думаю что конечно лучше было бы привести к стандрту как вы предлагаете: это просто, код можно скопировать на другие платформы, да и terminate() — это лучше чем access violation. Кроме того исключение может освободит какие-то ресурсы.
                            +4
                            Что-то как-то все сложно. Не знаю, как работает линковщик в MSVC, но, например, в gcc он поступает довольно прямолинейно — ищет символ сначала в объектных файлах, а если не находит, то затем во всех библиотеках по порядку, пока не найдет. Соответственно, кто первый нашелся — того и тапки.

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

                            test.cpp
                            #include <cstdlib>
                            int main()
                            {
                            	return (size_t)malloc(112);
                            }
                            


                            my_malloc.c
                            #include <stdlib.h>
                            
                            void * malloc(size_t sz)
                            {
                            	return (void*)42; //cannot print from here, as printf or puts can use malloc inside
                            }
                            


                            compile & run
                            cc -c -Wall my_malloc.c
                            g++ -c -Wall test.cpp
                            g++ test.o my_malloc.o -o test
                            ./test
                            echo $?
                            
                              0
                              И вроде бы MS линкер поступает так же

                              stackoverflow.com/questions/22403792/object-symbols-override-library-symbols
                                –3
                                Честно говоря, я уже и не помню, почему я просто не поменял порядок линковки. Возможно потому что линковщик от ms всегда ищет сначала в своих библиотеках. Не могу точно сказать. Может просто плохо пытался. Эксперименты с подменой я начал проделывать еще на 2008-й студии и, помню, тогда ее линковщик выдавал предупреждение на появление дублей. Может быть это предупреждение показалось мне тогда неприемлемым. А может просто хотелось избавиться от стандартного аллокатора с гарантией (все же порядок линковки не кажется мне надежной штукой, т.к. обычно все должно линковаться в любом порядке с одинаковым результатом).
                                В конце концов, эти вещи из разряда «блажь перфекциониста». Скорее всего работа программы без полной замены аллокатора ничем не будет отличаться от той же программы с полной заменой. Однако хочется идеала, даже если его никто не видит кроме тебя.
                                +1
                                Не секрет, что стандартный аллокатор new/delete/malloc/free в языке C/C++ не блещет быстродействием. Конечно, всё зависит от реализации, но, если говорить об оной от компании Microsoft, то это факт.

                                Факт ли? Вот например создатель ned malloc утверждает, что в современных OS системные аллокаторы очень хороши.

                                To my knowledge, nedmalloc is among the fastest portable memory allocators available, and it has many features and outstanding configurability useful in themselves. However it cannot consistently beat the excellent system allocators in Windows 7, Apple Mac OS X 10.6+ or FreeBSD 7+ (and neither can any other allocator I know of in real world testing).
                                www.nedprod.com/programs/portable/nedmalloc/

                                И кому в данном случае верить: создателю одного из альтернативных аллокаторов или Вашим голословным утверждениям, которые сделаны для рационализации сомнительных велосипедов?
                                  +1
                                  Абсолютли. Я ожидал увидеть сравнения по быстродействию, но увы.
                                    0
                                    Вполне допускаю, что для частной задачи автора его специализированный аллокатор будет быстрее. Но скорее всего он не будет так же универсален, как системный.
                                    0
                                    Проблемму фрагментации памяти в Windows решает технология Low-Fragmentation Heap, которая включена по умолчанию начиная с Vista, а в XP включается через WinAPI

                                    Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                                    Самое читаемое