Правило 50 коли має сенс замінювати new і delete - ефективне використання c

Правило 50: Коли можна буде замінювати new і delete

Повернемося до основ. Перш за все навіщо комусь може знадобитися підміняти пропоновані компілятором версії operator new і operator delete? Існують, принаймні, три поширені причини.

• Щоб підвищити ефективність. Версії операторів new і delete, що поставляються разом з компілятором, універсальні. Вони повинні бути прийнятні як для довго працюючих програм (наприклад, Web-серверів), так і для програм, що працюють менше однієї секунди. Вони повинні вміти обробляти серії запитів на виділення великих блоків пам'яті, малих блоків, а також суміші тих і інших. Вони повинні адаптуватися до широкого діапазону варіантів використання - від динамічного виділення декількох блоків великого розміру, які існують протягом усього часу роботи програми, до виділення і звільнення пам'яті для великої кількості дрібних об'єктів з малим часом життя. Вони повинні запобігати фрагментацію «купи», бо якщо цього не робити, то в кінці кінців буде неможливо задовольнити запит на виділення великого блоку пам'яті, навіть якщо сумарно такий обсяг є, але рознесений по безлічі дрібних ділянок.

З огляду на всі вимоги, що пред'являються до менеджерів пам'яті, не дивно, що поставляються з компіляторами оператори new і delete дотримуються усередненої стратегії. Вони працюють досить добре для всіх, але оптимально - ні для кого. Якщо ви добре уявляєте, як динамічна пам'ять використовується у вашій програмі, ви зможете написати власні версії операторів new і delete, що перевершують по ефективності стандартні. Під «перевагою» я маю на увазі, що вони працюють швидше (іноді на багато порядків) і вимагають менше пам'яті (до 50%). Для деяких, але аж ніяк не для всіх, додатків заміна поставляються new і delete власними версіями - простий спосіб відчутного підвищення продуктивності.

• Щоб збирати статистику використання. Перш ніж перейти до написання власних new і delete, розсудливо зібрати інформацію про те, як ваша програма використовує динамічну пам'ять. Як розподілені виділяються блоки по розмірам? Як розподіляється їх час життя? Порядок виділення та звільнення в основному дотримується принципу FIFO ( «першим увійшов - першим вийшов») або ж LIFO ( «останнім увійшов - першим вийшов»)? Або ніякої закономірності не спостерігається? Чи змінюється характер використання пам'яті з часом, тобто чи існує різниця в порядку виділення-звільнення пам'яті між різними стадіями виконання? Який максимальний обсяг динамічно виділеної пам'яті використовується в кожен момент часу?

По суті, написання версій new і delete - досить просте завдання. Наприклад, розглянемо коротко, як можна реалізувати глобальний оператор new з контролем записи за межами виділеного блоку. Правда, в ньому є безліч дефектів, але поки не будемо звертати на них уваги.

static const int signature = 0xDEADBEEF;

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); // викликати malloc для отримання пам'яті

// записати сигнатуру в перше і останнє слово виділеного блоку

* (Static_cast pMem)) = signature;

* (Reinterpret_cast (static_cast (pMem) + realSize-sizeof (int))) =

// повернути покажчик на пам'ять відразу за початковою сигнатурою

return static_cast (pMem) + sizeof (int);

Більшість недоліків цієї версії оператора new пов'язані з тим, що він не повною мірою відповідає угодам C ++ щодо функцій з таким ім'ям. Наприклад, в правилі 51 пояснюється, що всі оператори new повинні включати цикл виклику функції-обробника new, чого цей варіант не робить. Цією угодою присвячено правило 51, тому зараз я хочу зосередитися на більш тонкому моменті: вирівнюванні.

Вирівнювання важливо, тому що C ++ вимагає, щоб всі покажчики, які повертаються оператором new, були вирівняні для будь-якого типу даних. Функція malloc підпорядковується цим же вимогам, тому використання покажчика, повернутого malloc, безпечно. Але в наведеному вище операторі new ми не повертаємо покажчик, отриманий від malloc, а повертаємо покажчик, зміщений від повернутого malloc на розмір int. Немає ніяких гарантій, що це безпечно! Якщо клієнт викличе оператор new, щоб отримати пам'ять, достатню для розміщення double (або якщо ми напишемо оператор new [] для виділення пам'яті під масив значень типу double), а потім запустимо програму на машині, де int займає 4 байта, а значення double повинні бути вирівняні по межах восьмібайтових блоків, то, швидше за все, повернемо неправильно вирівняний покажчик. Це може викликати аварійну зупинку програми. Або ж просто уповільнити її роботу. У будь-якому випадку, це зовсім не те, що ми хотіли.

Увага до подібних деталей відрізняє менеджери пам'яті професійної якості від тих, що роблять на швидку руку програмісти, змушені відволікатися на інші завдання. Написати власний менеджер пам'яті, який майже працює, досить просто. Написати такий, який працює добре, набагато складніше. Взагалі кажучи, я не рекомендую займатися цією справою, якщо тільки немає нагальної потреби.

У багатьох випадках її немає. Деякі компілятори мають перемикачі, що дозволяють налагоджувати і протоколювати роботу функцій управління пам'яттю. Поверхневе знайомство з документацією на вашу компілятору може виключити необхідність в написанні власних версій new і delete. На багатьох платформах доступні комерційні продукти, що дозволяють замінити функції управління пам'яттю, що поставляються з компіляторами. Щоб скористатися їх розширеною функціональністю і (імовірно) підвищеною продуктивністю, доведеться лише заново компонувати програму (ну і, само собою, заплатити).

Тема цього правила - питання про те, коли має сенс підміняти версії new і delete за замовчуванням - на глобальному рівні або на рівні класу. Тепер ми можемо відповісти на це питання більш детально.

• Щоб виявляти помилки використання (як було сказано вище).

• Щоб збирати статистику про використання динамічно розподіленої пам'яті (також було сказано вище).

• Для прискорення процесу розподілу та звільнення пам'яті. Розподільники загального призначення часто (хоча і не завжди) працюють набагато повільніше, ніж оптимізовані версії, особливо якщо останні спеціально розроблені для об'єктів певного типу. Специфічні для класу розподільники є прикладами виділення блоків фіксованого розміру, на зразок тих, що представляє бібліотека Pool з проекту Boost. Якщо ваш додаток однопоточні, але менеджер пам'яті, що поставляється з компілятором, за замовчуванням потокобезпечна, то ви можете отримати помітне зростання продуктивності, написавши менеджер пам'яті для однопоточних додатків. Звичайно, перш ніж вирішити, що потрібно переписувати оператори new і delete для підвищення швидкості, переконайтеся за допомогою профілювання, що ці функції дійсно є вузьким місцем.

• Щоб зменшити накладні витрати, характерні для стандартного менеджера пам'яті. Менеджери пам'яті загального призначення часто (хоча не завжди) не тільки повільніше оптимізованих версій, але і споживають більше пам'яті. Це відбувається через те, що з кожним виділеним блоком пов'язані деякі накладні витрати. Розподільники, оптимізовані для дрібних об'єктів (як, наприклад, Pool), дозволяють майже позбутися від цих витрат.

• Щоб компенсувати субоптимальное вирівнювання в розподільниках за замовчуванням. Як я вже згадував, найшвидший доступ до значень double на архітектурі x86 виходить тоді, коли вони вирівняні по восьмибайтових кордонів. На жаль, оператори new, що поставляються з деякими компіляторами, не гарантують восьмібайтового вирівнювання при динамічному виділенні double. У цих випадках заміна оператора new за замовчуванням на спеціальний, який гарантує таке вирівнювання, може дати помітний ріст продуктивності програми.

• Щоб згрупувати взаємопов'язані об'єкти один з одним. Якщо ви знаєте, що певні структури даних зазвичай використовуються разом, і хочете мінімізувати частоту помилок через відсутність сторінки у фізичній пам'яті при роботі з такими даними, то, можливо, має сенс створити окрему купу для подібних структур, щоб вони були зібрані разом, або на такому невеликому числі сторінок, наскільки можливо. Версії операторів new і delete з розміщенням (див. Правило 52) можуть забезпечити таку угруповання.

• Щоб отримати нестандартну поведінку. Іноді може знадобитися, щоб оператори new і delete робили щось, чого поставляються з компілятором версії робити не вміють. Наприклад, вам потрібно розподіляти і звільняти блоки пам'яті в пам'яті, що, але для операцій з такою пам'яттю у вас тільки програмний інтерфейс C. Написання спеціальних версій new і delete (можливо, з розміщенням - см. Правило 52) дозволить вам обернути C API в класи C ++. Ви також можете написати спеціальний оператор delete, який заповнює звільняється пам'ять нулями, щоб підвищити ступінь захисту даних в додатку.

Схожі статті