Содержание статьи
С++ исконно считается гибким, но сложным, языком программирования. Почему? Потому что так оно и есть :). В этой статье мы узнаем об операторах new и delete, о том, как писать собственные процедуры управления памятью и как не совершить ужасную ошибку, занимаясь этим нелегким делом.
Многие кодеры, выбирая C++ в качестве основного языка для написания своих программ, хотят тем самым добиться от них максимальной эффективности, как в плане потребления ресурсов, так и в плане скорости выполнения. Именно написание собственных операторов для работы с памятью дает такую возможность. Конечно, в наше время, когда повсеместно используются сборщики мусора (например, в Java или C#), сам по себе вызов специальных команд для выделения и освобождения памяти выглядит немного странно, но именно благодаря этим командам любой программист может значительно улучшить производительность своего кода.
Для того, чтобы написать правильный код, который будет работать с памятью, надо понимать, как организованы процедуры управления этой самой памятью в C++. Также следует помнить о многопоточности и проблемах, связанных с ней. Куча — это модифицируемый глобальный ресурс, доступ к которому должен быть синхронизирован. Если игнорировать этот факт, то рано или поздно все сломается, и потом будет очень сложно разобраться, в чем же собственно дело. Поэтому при написании собственного менеджера памяти всегда надо помнить о возможности одновременного доступа к куче из разных потоков программы.
Когда имеет смысл заменять new и delete?
Для начала давай разберемся, стоит ли нам вообще писать собственные процедуры работы с памятью. Чаще всего new и delete переписывают для того, чтобы обнаружить так называемые ошибки применения. К таким ошибкам относятся, например, утечки памяти. Они могут случаться как изза простой невнимательности программиста, так и вследствие высокого уровня сложности структуры кода. Попросту говоря, для динамически выделенной памяти не всегда вызывается delete. Бывает и другая крайность, когда для одного и того же блока из кучи delete вызывается два и более раз. В этом случае поведение программы предсказать невозможно.
Всего этого можно избежать, если пользовательские функции по работе с кучей будут вести список выделенных блоков памяти. Еще одной часто встречающейся ошибкой применения является переполнение буфера.
Сколько хакерских атак было успешно выполнено через такую вот старую, как мир, дыру? Антагонист переполнения — это запись с адреса, предшествующего началу выделенного блока. Самописная версия new может запрашивать блоки большего размера и записывать в начало и конец таких блоков специальную сигнатуру. Оператор delete может проверять наличие этой сигнатуры и, если ее не окажется на месте, поднимать тревогу.
Второй причиной, из-за которой можно смело переписывать процедуры управления памятью, является производительность. Стандартные версии операторов new и delete, поставляемые вместе с компилятором, «слишком» универсальны. Они должны одинаково хорошо работать как для кода, выполнение которого занимает меньше секунды, так и для программ, аптайм которых составляет месяцы. Эффективно выделять как несколько больших блоков памяти, которые существуют на протяжении всей работы программы, так и множество маленьких, которые «живут» сотые доли секунды. Стандартные функции работы с кучей должны уметь эффективно бороться с ее фрагментацией, поскольку даже если суммарный объем свободной памяти будет достаточно велик, высокая степень ее «раздробленности» может помешать выделению нужного блока.
Теперь понятно, почему дефолтные new и delete не всегда оказываются быстрыми и эффективными — используются слишком общие алгоритмы работы с памятью, которые призваны учесть все нюансы. В некоторых случаях написание собственных операторов работы с памятью помогает значительно ускорить выполнение кода, а также уменьшить расход ресурсов. Так, например, самописные new и delete будут полезны для ускорения процесса распределения и освобождения памяти, для уменьшения накладных расходов, характерных для стандартного менеджера памяти; чтобы компенсировать субоптимальное выравнивание в распределителях по умолчанию (об этом чуть ниже), чтобы сгруппировать взаимосвязанные объекты друг с другом и т.д. Еще очень часто new и delete переписывают для сбора статистики об используемой памяти. В высоконагруженных приложениях часто оказываются очень полезными знания о том, как используется память: как распределены выделяемые блоки по размерам, каково время их жизни, какой порядок выделения и освобождения блоков характерен для кода, изменяется ли «потребление» динамической памяти на разных стадиях выполнения программы, и есть ли вообще какая-либо закономерность.
На все эти вопросы помогут ответить собственные операторы работы с памятью.
Пример собственной версии new
Написать собственную версию операторов new и delete достаточно просто. Рассмотрим, например, как можно реализовать глобальный оператор new с контролем записи за границами выделенного блока. Правда, в примере ниже есть несколько недостатков, но об этом далее.
Пользовательская версия оператора new
static const int signature = 0xADADEAEA;
typedef unsigned char Byte;
void *operator new(std::size_t size)
throw(std::bad_alloc)
{
using namespace std;
size_t realSize = size + 2 * sizeof(int);
void *pMem = malloc(realSize);
if (!pMem)
throw(bad_alloc);
*(static_cast<int>pMem)) = signature;
*(reinterpret_cast<int*>(static_cast<Byte*>(pMem)
+ realSize — sizeof(int))) = signature;
return static_cast<Byte*>(pMem) + sizeof(int);
}
Здесь мы сначала с помощью функции malloc выделяем блок памяти на два слова больше, чем запрашивается в передаваемом параметре, затем записываем сигнатуру в начало и в конец выделенного куска памяти, после чего возвращаем указатель на нее. Вроде все хорошо, но мы забываем о такой важной вещи, как выравнивание. Многие компьютерные архитектуры требуют, чтобы данные определенных типов располагались в памяти по вполне конкретным адресам.
Например, архитектура может требовать, чтобы указатели располагались по адресам, кратным четырем, а данные типа double были выровнены на границу двойного четырехбайтного слова. Если не соблюдать эти требования, то возможны аппаратные сбои или замедление работы системы. C++ требует, чтобы все указатели, возвращаемые оператором new, были выровнены для любого типа данных. Функция malloc удовлетворяет этим условиям, но, поскольку мы записываем в начало блока сигнатуру, и, следовательно, возвращаем указатель, смещенный на длину этой сигнатуры, то нет никаких гарантий, что это безопасно. Если мы выделим память под переменную типа double на компьютере с архитектурой, где int занимает четыре байта, то оператор new, приведенный в примере, скорее всего вернет неправильный указатель, что в итоге может завершиться аварийной остановкой программы или ее сильным замедлением.
Надеюсь, теперь понятно, почему правильное выравнивание так важно. Но не менее важным является требование к операторам new, согласно которому все они должны включать цикл вызова функции-обработчика new.
Функция-обработчик new
Когда оператор new не может удовлетворить запрос на выделение запрошенного количества памяти, он возбуждает исключение. В старые времена оператор new возвращал ноль и следы подобного поведения сохранились в некоторых компиляторах и по сей день. Основная же масса современных компиляторов генерирует код с new, поддерживающим вызов исключений.
Перед тем как вызвать исключение после неудачной попытки выделения памяти, оператор new должен выполнить код функции-обработчика (newhandler), которая определяется пользователем. Чтобы задать обработчик, нужно вызвать стандартную библиотечную функцию set_new_handler, объявленную в заголовочном файле <new> следующим образом:
Объявление set_new_handler
namespace std {
typedef void (*new_handler) ();
new_handler set_new_handler(new_handler p)
throw();
}
Как видно, new_handler — это typedef для указателя на функцию, которая не принимает никаких параметров, а set_new_handler — функция, которая как раз получает в качестве параметра переменную типа new_handler. Полученный указатель на функции впоследствии вызывается оператором new в случае неудачной попытки выделения памяти. Предыдущий указатель на обработчик также возвращается (set_new_handler). В итоге можно получить примерно следующий код:
Использование set_new_handler
void outOfMem()
{
std::cerr << "Невозможно выделить память\n";
std::abort();
}
int main()
{
std::set_new_handler(outOfMem);
int *pBigDataArray = new int[100000000L];
...
}
Обработчик оператора new вызывается циклически, пока он не «сумеет» найти достаточное количество памяти или не выполнит какоелибо другое действие для корректной обработки ситуации. Найти свободную память не так-то просто, но можно пойти на небольшую уловку — в начале работы программы зарезервировать некоторый объем памяти и высвободить его при первом вызове обработчика. В результате таких действий следующая попытка выделить кусок памяти увенчается успехом. Альтернативным вариантом действий может быть установка другого обработчика или вовсе его удаление. Если текущий new-handler не может найти нужное количество свободной памяти, то, возможно, он знает какой-то другой обработчик, который справится с этой задачей лучше.
А если с помощью set_new_handler установить нулевой указатель, то оператор new сразу возбудит исключение при неудачной попытке выделения памяти. Также в функции-обработчике оператора new можно возбудить исключение типа bad_alloc или любого типа, унаследованного от него. Исключения такого типа не перехватываются в new, и поэтому их можно поймать в месте вызова оператора. А еще можно вообще ничего не делать и завершить программу с помощью abort или exit, что, собственно, мы и сделали в примере. До этого момента мы все время говорили о глобальной замене оператора new, но определить специфичный код выделения памяти можно лишь для объектов определенного типа. Сделать это достаточно просто, нужно лишь в каждом классе написать свои версии set_new_handler и new. Определенная в классе set_new_handler позволит пользователям задать обработчик new для класса, а принадлежащий классу operator new гарантирует, что при выделении памяти для объектов этого класса вместо глобального обработчика new будет использован тот, что определен в данном классе.
Собственный new для класса
class Widget {
public:
static std::new_handler set_new_handler
(std::new_handler p) throw();
static void *operator new(std::size_t size)
throw(std::bad_alloc);
private:
static std::new_handler currentHandler;
}
Оператор new, определенный в классе Widget, должен отработать по вполне определенному алгоритму. Во-первых, он должен вызвать стандартный set_new_handler, указав в качестве параметра функциюобработчик из класса Widget. В результате этот new-handler станет глобальным. Затем следует вызвать глобальный operator new. В случае ошибки будет вызван обработчик new, принадлежащий классу Widget.
Если это ни к чему не приведет, то глобальный new возбудит исключение, а new из класса должен восстановить исходный обработчик и распространить исключение. Если же выделение памяти прошло удачно, то new, принадлежащий классу Widget, должен вернуть указатель на эту память и восстановить предыдущий new-handler.
Правильный менеджер памяти
Написать почти работающий менеджер памяти просто, а вот написать хорошо работающий менеджер в разы сложнее. Нужно учитывать массу нюансов. Во многих книгах по C++ приводятся примеры высокопроизводительного кода распределения памяти, но опускаются такие «скучные» моменты, как переносимость, соглашения о выравнивании, безопасность относительно потоков и т.д.
В большинстве случаев следует хорошо подумать, прежде чем писать собственные процедуры работы с памятью. Некоторые современные компиляторы умеют протоколировать и отлаживать работу функций управления памятью. Можно найти множество коммерческих продуктов, позволяющих заменить менеджер памяти, поставляемый компилятором.
Такие продукты хорошо протестированы и практически не имеют ошибок. И все же, если ни один из этих вариантов тебе не подошел, то советую, прежде чем заняться кодингом собственных new и delete, заглянуть в open source проекты по управлению памятью. Например, ознакомиться с библиотекой Pool из проекта Boost. Там можно найти множество мелочей, которые позволят детально разобраться во всех тонкостях управления памятью в C++.
Заключение
Это была лишь небольшая часть того, что можно сказать об операторах new и delete. Надеюсь, в следующих статьях мы продолжим познавать тайны функций управления памятью.