«Полювання» на пам’ять. Практичні рекомендації щодо уникнення memory leaks на прикладі Node.js
За часів, коли комп’ютери були великі, а обсяги пам’яті незначні, розробники «боролися» за кожний біт на дорогоцінних мікросхемах. Мови програмування тоді буквально вимагали від авторів програм детального ознайомлення з низькорівневими особливостями конкретної архітектури й відповідно мегаакуратного ставлення до виокремлення та вивільнення й без того обмежених ресурсів.
Проте минуло небагато часу відтоді, як обсяги пам’яті вимірювали числами з дев’ятьма й більше нулями, інструменти абстрагувалися від особливостей архітектур, обросли допоміжним функціоналом, фреймворками, методиками. І в цьому є чимале раціональне зерно, адже нині розробник може ліпше зосередитися на предметній частині проблеми, яку розв’язує його код, замість того щоб гаяти час на вивчення тонкощів конкретної апаратної архітектури. На жаль, неможливо сповна абстрагувати всю апаратну частину, і одним з яскравих прикладів цього є наявність помилок типу виходу за межі доступної для процесу пам’яті (Out Of Memory Error / OOM) у значній більшості, а можливо, й у всіх сучасних мовах програмування.
Особисто для мене перше серйозне знайомство з особливостями ручного керування пам’яттю відбулося після написання перших програм мовою С і це мало приблизно такий вигляд:
Концепція роботи з покажчиками неможлива без знань того, як адресувати й виокремлювати пам’ять, що й примусило опановувати зен. Потім були Java, Python та Node.js, які вже не змушували прискіпливо досліджувати код на наявність викликів free після кожного malloc і набагато зменшили кількість SIGSEGV через зосередження на блоках пам’яті, яких немає.
Останні п’ять років як хобі я беру участь у роботі над проектом з відкритим кодом Appium в якості Core Maintainer’а. Це фреймворк, серверна частина якого написана на Node.js і яка імплементує REST API, сумісне з протоколом Selenium WebDriver для автоматизації функційного тестування. Appium зазвичай асоціюють з мобільним тестуванням, але він через концепцію драйверів підтримує значну кількість різноманітних платформ, зокрема Windows, Mac OS, Raspberry Pi тощо. Саме в цьому проекті ми стикалися (і далі стикаємося) з цікавими, на мою думку, проблемами, практичними рецептами, про розв’язання яких хотілося б розповісти. Також під час роботи над проектом і як результат опрацювання помилок я виробив для себе кілька правил, зокрема щодо роботи з обмеженими ресурсами, які, сподіваюся, будуть корисні не лише в контексті розробки для Node.js.
Трохи нудної теорії
Node.js використовує сміттєзбірник (Garbage collector) для очищення пам’яті, що була раніше зайнята об’єктами. Ділянка пам’яті, яку V8 (JavaScript engine, розроблений для Chrome та Chromium) використовує для розміщення джаваскриптових сутностей, називається купою (V8 managed heap). Після запуску інтерпретатора для купи виокремлюєтсься ділянка пам’яті, максимальний розмір якої обмежений. У ранніх версіях Node.js це обмеження зумовлено архітектурними причинами й залежно від розрядності інтерпретатора могло мати розмір до 1,5 Гбайт (1400 Мбайт для х64 та 700 Мбайт для х86). У нещодавніх версіях за бажанням максимальний розмір можна збільшити за допомогою параметрів командного рядку (див. --max-old-space-size
).
Реалізація сміттєзбірника в Node.js досить просунута. Вона виявляє потенційні циклічні посилання, розбирається із замиканнями (closures), але безсила перед класичними проблемами вивільнення об’єктів з боку розробника (тобто браку такого вивільнення). Щоб не вдаватися у глибокі «нетрі», треба знати, що новостворений об’єкт будуть вважати «живим» і він не зможе бути переданим процедурі збору сміття доти, доки на нього посилається хоча б одна сутність з активного простору імен. Якщо таких «живих» об’єктів накопичиться досить, щоб їхній загальний розмір досяг максимального розміру купи, V8 не зможе виокремити пам’ять для наступної нової сутності, і це призведе до «краху» всього процесу (FATAL ERROR: CALL_AND_RETRY_LAST Allocation failed).
Досліджуємо проблему
Для діагностики проблем з використанням пам’яті в Node.js в Appium використовуємо Chrome Inspector / Remote Debugger. В Інтернеті є досить інформації про те, як користуватися цим інструментом. Відлагоджування можна здійснювати в реальному часі або за допомогою дослідження знімків стану купи (Heap Snapshots). На мою думку, останній метод ефективніший, особливо якщо є проблема на комп’ютері користувача, до якого немає фізичного доступу. У Node.js до версії 12 треба під’єднати модуль Heap Dump і надіслати процесу SIGUSR2, щоб Node.js створив знімок купи в активному каталозі процесу (не впевнений, що це працює у Windows, оскільки сигналів там немає). Після релізу версії 12 цю функційність внесено в основну кодову базу Node.js, тому під’єднувати цей модуль більше не треба. Знімок купи можна потім завантажити в той самий інспектор для дослідження. Якщо знімок зроблено у правильному відрізку часу, то в інспекторі можна буде побачити, які сутності займають найбільше місце в купі і стек викликів під час їхнього створення, чого в багатьох випадках досить для локалізації помилки в коді. Внісши потрібні виправлення, процедуру слід повторити й переконатися в тому, що проблеми немає й не відбувся регрес.
На жаль, такі проблеми майже неможливо розв’язати без знімків або хоча б досить детального описання кроків для їхнього відтворення. Останній варіант досить непевний, бо інколи для розв’язання проблеми треба буде відтворити й усе оточення. Також, залежно від характеру помилки, відтворення може потребувати тривалого часу (інколи йдеться про години, навіть дні).
Подвійний удар. Виявляється, журналювання може бути не таким уже й безпечним
Запис журналу подій (logs) є звичною практикою для всіх більш-менш серйозних застосунків. Журнали можуть інколи бути єдиною ниточкою, яка дасть змогу розплутати клубок загадкової поведінки застосунку в конкретному середовищі (читайте на комп’ютері користувача).
Але в нашому випадку саме журналювання «виїдало» доступну пам’ять й зумовило OOM.
Appium для журналювання використовує тонку обгортку над модулем npmlog. Аналіз вищезазначених знімків зі звіту засвідчив, що значну кількість пам’яті в купі займають рядки (strings), а якщо точніше, то рядки, що були записані в журнал. Крім того, виявилося, що більшість з них займають по кілька мегабайтів (!), хоча файл журналу мав розмір менше як 1 Мбайт.
Надалі аналіз модуля npmlog засвідчив, що в ньому є внутрішній циклічний буфер, у якому зберігають N недавніх записів у журнал. Стандартно N становить 10 000. Якщо припустити, що середній розмір 1 рядку — 1 Мбайт (а там були й набагато довші), це дасть загальний розмір журналу мінімум 10 000 Мбайт або 10 Гбайт, що вже далеко за межами доступної пам’яті. Тому спочатку ми встановили максимальний розмір буфера рівним 3000. Це дало певне поліпшення, однак проблему все одно не розв’язало — у буфер потрапляли дуже довгі рядки, яких не було в журналі (тобто вони були, але обрізані максимум до 300 символів від початку), і ми спершу не могли зрозуміти причину такої поведінки. Подорож «нутрощами» реалізації Node.js, що тривала кілька вечорів, нарешті, допомогла знайти відповідь — інтернування рядків (strings interning) та відповідний баг на цю тему.
Виявляється, виклики substring, trim, split і схожих методів для «поділу» рядків не зберігає нову копію оригінальної рядку, а зберігає покажчик на його зріз (slice). Зберігши такий покажчик у масиві, збільшуємо кількість посилань на оригінальний (довгий) рядок, що потім не дає змогу сміттєзбірнику стерти його з пам’яті. Розв’язання цієї проблеми недосконале, але ліпшого поки що знайти не вдалося. Ідея тут проста — примусити інтерпретатор зберігати копію скороченого рядку під час збереження його в буфер журналу, замість того щоб зберігати посилання на оригінал.
Висновки
- Не покладайтеся сповна на релізацію модулів, отриманих від третіх сторін, заздалегідь не вивчивши їхні API та особливості роботи.
- Не бійтеся заглянути «всередину» цих модулів, якщо невпевнені в їхній функційності.
- Учіться на око визначати приблизний максимальний розмір структур даних з динамічним розміром під час написання коду — списків, хешів, дерев. Особливу увагу треба зосереджувати на структурах, які будуть доступні протягом життєвого циклу застосунку або більшої його частини (в ідеалі таких структур має бути якнайменше). Якщо приблизний максимальний розмір структури досягає хоча б десятої частини розміру всієї доступної пам’яті, варто задуматися про обмеження кількості елементів у ній та/або про обмеження розміру цих елементів.
- Вникайте в особливості реалізації вашої мови програмування — це хоч і потребує додаткових зусиль, зате допоможе в майбутньому уникнути проблем, які складно діагностувати.
- Реальною причиною втрат пам’яті в чималих проектах може бути майже кожен рядок коду — намагатися навздогад знайти його і розв’язати проблему, не маючи відповідних матеріалів для детального дослідження, буде марним гаянням часу.
Давши Promise — тримайся, не давши — кріпися
Ще один цікавий випадок можна знайти тут.
Згідно зі знімком, купа була сповнена рядків, але незрозуміло, як вони там опиняються й чому залишаються. Ланцюжок викликів стеку (Stack Trace) демонстрував, що рядки утримуються всередині Bluebird-промісів. Але чому ці проміси акуратно накопичуються в пам’яті й надалі не стираються звідти, не міг сказати ніхто. Після кількох «підходів» стало зрозуміло, що без радикального рішення обійтися буде складно. Один з методів, сигнатура якого розміщена вище по стеку, мав таку хитромудру реалізацію черги, що саме описання цієї реалізації займало більше місця, ніж її імплементація. Та ще й до того це все було «підживлене» хаками, що дають змогу скасувати проміси. Заміна реалізації черги з використанням асинхронних локів (async lock) дала змогу уникнути втрат пам’яті, а також зробила непотрібними наявні хаки.
Висновки
- Якщо ви не можете розібратися в певній частині коду або код має такий вигляд, що лише за допомогою багатогодинної медитації можна відкрити його сакральні таємниці, та ще й до того ж у виконанні коду є проблеми, то це може означати, що його треба переписати. Звичайно, таке рішення слід ухвалювати командою, але наявність хитрих хаків, коментарів TODO та FIXME неодмінно буде прекрасним індикатором того, що ви на правильному шляху.
- Завжди спочатку намагайтеся зреалізувати функційність за допомогою стандартного документованого інструментарію. Удавайтеся до застосування хаків лише тоді, коли інші стандартні рішення «не працюють». Зазвичай використання прихованих API — найпростіший спосіб «прострелити собі ногу», особливо, якщо ви не впевнені в тому, що робите.
Де просто, там живуть років 100
Третій і останній випадок у цьому огляді.
Проблема все в тих самих промісах, що (незрозуміло як) залишаються в пам’яті. Була застосована стратегія з попереднього випадку, і ще один хитрий хак переписано з використанням «усталеніших» методів. Але це не допомогло, і пам’ять все одно втрачали. Зате у знімку стек був набагато простішим, і тепер знайти «винуватця урочистості» більше не було суперскладною проблемою.
Висновки
- Згодом будь-який проект збільшується до розмірів, коли ніхто не здатен засвоїти сповна його архітектуру. Спрощення й локалізація проблемних областей — це завжди прекрасні «друзі» для розв’язання таких проблем.
- Думайте про керування пам’яттю безпосередньо під час написання коду й переглядів комітів ваших колег. Наявність сміттєзбірника (Garbage collector), хоч яким просунутим він був, не позбавляє код можливих проблем з втратами пам’яті.
- Додавайте моніторинг стану пам’яті у процес неперервної інтеграції. Є інструменти, що можуть автоматично генерувати попередження, якщо використання пам’яті застосунком набагато «відхиляється» від медіани.
- Користуйтеся статичними аналізаторами коду.
Що більше знаєш, то більше сумніваєшся
На мою думку, проблеми з втратою пам’яті належать до найскладніших для розв’язання програмних проблем. Вони непомітні для компіляторів та інтерпретаторів, а також можуть за певних умов або не проявлятися взагалі або дуже рідко, через значні відрізки часу. Виникнення такої проблеми на системному рівні вважають критичною помилкою і призводить до «краху» всього процесу. Це робить memory leaks одночасно і небезпечними, і цікавими (challenging) з погляду розробника.