Як я досліджував витоки пам’яті в Go, використовуючи pprof на великій кодовій базі

Я працюю з Go протягом більшої частини року, впроваджуючи масштабовану інфраструктуру блокчейну в Orbs, і це був хвилюючий рік. Протягом 2018 року ми досліджували, яку мову вибрати для нашої реалізації блокчейну. Це змусило нас вибрати Go через наше розуміння того, що в ньому є хороша спільнота та дивовижний набір інструментів.

Останніми тижнями ми вступаємо в завершальний етап інтеграції нашої системи. Як і в будь-якій великій системі, можуть виникнути проблеми на пізніх стадіях, які включають проблеми з продуктивністю, при конкретних витоках пам'яті. Під час інтеграції системи ми зрозуміли, що знайшли таку. У цій статті я торкнусь особливостей того, як дослідити витік пам’яті в Go, докладно описавши кроки, спрямовані на його пошук, розуміння та вирішення.

Набір інструментів, запропонований Golang, є винятковим, але має свої обмеження. Доторкнувшись до них першим, найбільшим є обмежена можливість досліджувати повні ядра. Повний дамп ядра - це образ пам’яті (або пам’яті користувача), зроблений процесом запуску програми.

Ми можемо уявити собі відображення пам'яті як дерева, і обхід цього дерева провів би нас через різні розподіли об'єктів та відносин. Це означає, що все, що знаходиться в корені, є причиною того, що "тримає" пам'ять, а не GCing (Збір сміття). Оскільки в Go немає простого способу проаналізувати дамп повного ядра, важко дістатись до коренів об’єкта, який не отримує GC-ed.

На момент написання статті ми не змогли знайти в Інтернеті жодного інструменту, який може нам у цьому допомогти. Оскільки існує основний формат дампа та досить простий спосіб експортувати його з пакета налагодження, можливо, такий використовується в Google. Шукаючи в Інтернеті, здається, що воно знаходиться в конвеєрі Golang, створюючи основний переглядач дампа, але не схоже на те, що над ним хтось працює. Сказавши це, навіть не маючи доступу до такого рішення, за допомогою існуючих інструментів ми зазвичай можемо дійти до першопричини.

Витоки пам'яті

Витік пам’яті або тиск пам’яті може мати різні форми в системі. Зазвичай ми звертаємось до них як до помилок, але іноді їх першопричина може бути в дизайнерських рішеннях.

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

  • Забагато розподілів, неправильне представлення даних
  • Інтенсивне використання відображення або струн
  • Використання глобалів
  • Сироти, нескінченні горутини

У Go найпростіший спосіб створити витік пам'яті - це визначення глобальної змінної, масиву та додавання даних до цього масиву. Цей чудовий допис у блозі добре описує цей випадок.

То чому я пишу цей пост? Досліджуючи цю справу, я знайшов багато ресурсів про витоки пам'яті. Однак насправді системи мають більше 50 рядків коду та єдину структуру. У таких випадках пошук джерела проблеми з пам’яттю набагато складніший, ніж опис цього прикладу.

Golang дає нам дивовижний інструмент, який називається pprof. При засвоєнні цього інструменту він може допомогти у вивченні та, швидше за все, виявленні будь-якої проблеми з пам’яттю. Інша мета, яку він має, - дослідити проблеми з процесором, але я не буду вдаватися до нічого, що стосується процесора, у цій публікації.

go інструмент pprof - -

Для висвітлення всього, що робить цей інструмент, знадобиться більше одного повідомлення в блозі. Одне, що зайняло деякий час, - з’ясувати, як використовувати цей інструмент, щоб отримати щось ефективне. Я зосереджу цю публікацію на особливостях, пов’язаних із пам’яттю.

pprofПакет створює купу проб файл дампа, який потім можна аналізувати / Визуализируйте , щоб дати вам карту , як:

  • Поточний розподіл пам'яті
  • Загальне (сукупне) виділення пам’яті

Інструмент має можливість порівняння знімків. Це може дозволити вам порівняти відображення різниці в часі того, що сталося зараз і 30 секунд тому, наприклад. Для сценаріїв стресу це може бути корисно для пошуку проблемних областей вашого коду.

профілі pprof

Принцип роботи pprof - використання профілів.

Профіль - це колекція стеків стеків, що відображають послідовності викликів, що призвели до екземплярів певної події, наприклад розподілу.

Файл runtime / pprof / pprof.go містить детальну інформацію та реалізацію профілів.

У Go є кілька вбудованих профілів, які ми можемо використовувати в загальних випадках:

  • goroutine - стек стеків усіх поточних goroutines
  • куча - вибірка виділення пам'яті живих об'єктів
  • allocs - вибірка всіх попередніх розподілів пам'яті
  • threadcreate - стекові стеки, що призвели до створення нових потоків ОС
  • блок - стекові стеки, що призвели до блокування на примітивах синхронізації
  • mutex - стек стеків власників суперечних мьютексів

Розглядаючи проблеми з пам'яттю, ми зосередимося на кучевому профілі. Профіль allocs ідентичний щодо збору даних, який він робить. Різниця між ними полягає в тому, як інструмент pprof читає там на час запуску. Профіль Allocs запустить pprof у режимі, який відображає загальну кількість байтів, виділених з моменту запуску програми (включаючи байти, зібрані сміттям). Зазвичай ми використовуємо цей режим, намагаючись зробити наш код ефективнішим.

Купи

Абстрактно, саме тут ОС (операційна система) зберігає пам’ять об’єктів, які використовує наш код. Це пам'ять, яка згодом отримує "зібране сміття" або звільняється вручну мовами, що не збираються.

Купа - це не єдине місце, де відбувається виділення пам’яті, частина пам’яті також виділяється в стеку. Мета стека є короткостроковою. У Go стек зазвичай використовується для призначень, які відбуваються всередині закриття функції. Інше місце, де Go використовує стек, - це те, коли компілятор "знає", скільки пам'яті потрібно зарезервувати до часу виконання (наприклад, масиви фіксованого розміру). Існує спосіб запустити компілятор Go, щоб він вивів аналіз того, де розподіли «вирвуться» зі стеку до купи, але я не торкаюся цього в цій публікації.

Незважаючи на те, що дані купи потрібно `` звільнити '' та gc-ed, дані стеку - ні. Це означає, що набагато ефективніше використовувати стек там, де це можливо.

Це анотація різних місць, де відбувається розподіл пам’яті. У цьому є набагато більше, але це буде поза рамками цієї публікації.

Отримання даних купи за допомогою pprof

Існує два основних способи отримання даних для цього інструменту. Перший, як правило, є частиною тесту або гілки і включає імпорт, runtime/pprofа потім виклик pprof.WriteHeapProfile(some_file)для написання інформації про купу.

Зверніть увагу, що WriteHeapProfileце синтаксичний цукор для бігу:

// lookup takes a profile namepprof.Lookup("heap").WriteTo(some_file, 0)

Згідно з документами, WriteHeapProfileіснує для зворотної сумісності. Решта профілів не мають таких ярликів, і ви повинні скористатися Lookup()функцією, щоб отримати їхні дані профілю.

Другий, що є найбільш цікавим, полягає в тому, щоб увімкнути його через HTTP (веб-кінцеві точки). Це дозволяє витягувати дані adhoc, із запущеного контейнера у вашому e2e / тестовому середовищі або навіть із "виробництва". Це ще одне місце, де виконання та інструментарій Go перевершуються. Повну документацію до пакету можна знайти тут, але TL; DR - вам потрібно буде додати її до свого коду як такої:

import ( "net/http" _ "net/http/pprof")
...
func main() { ... http.ListenAndServe("localhost:8080", nil)}

"Побічним ефектом" імпорту net/http/pprofє реєстрація кінцевих точок pprof під коренем веб-сервера на /debug/pprof. Тепер за допомогою curl ми можемо отримати файли інформації про купу для дослідження:

curl -sK -v //localhost:8080/debug/pprof/heap > heap.out

Додавання http.ListenAndServe()вищезазначеного потрібно лише у тому випадку, якщо у вашій програмі раніше не було прослуховувача http. Якщо у вас він є, він зачепить його, і слухати його знову не потрібно. Існують також способи встановити його за допомогою, ServeMux.HandleFunc()який мав би більше сенсу для більш складної програми з підтримкою http.

Використання pprof

Отже, ми зібрали дані, що зараз? Як зазначалося вище, існує дві основні стратегії аналізу пам'яті за допомогою pprof. Один навколо дивиться на поточні розподіли (байти або кількість об’єктів), що викликаються inuse. Інший розглядає всі виділені байти або кількість об’єктів протягом часу роботи програми, що викликається alloc. Це означає, незалежно від того, чи було це gc-ed, підсумовування всього вибірки.

Це гарне місце, щоб повторити, що профіль купи - це вибірка розподілу пам'яті . pprofза лаштунками використовується runtime.MemProfileфункція, яка за замовчуванням збирає інформацію про розподіл на кожні 512 КБ виділених байтів. Можна змінити MemProfile для збору інформації про всі об'єкти. Зауважте, що, швидше за все, це уповільнить вашу програму.

Це означає, що за замовчуванням існує певна ймовірність того, що може статися проблема з меншими об’єктами, які проскочать під радаром pprof. Для великої кодової бази / довготривалої програми це не проблема.

Після того, як ми зібрали файл профілю, настав час завантажити його в інтерактивну консоль, яку пропонує pprof. Зробіть це, запустивши:

> go tool pprof heap.out

Давайте спостерігатимемо за відображеною інформацією

Type: inuse_spaceTime: Jan 22, 2019 at 1:08pm (IST)Entering interactive mode (type "help" for commands, "o" for options)(pprof)

Тут важливо відзначити Type: inuse_space. Це означає, що ми розглядаємо дані розподілу конкретного моменту (коли ми захопили профіль). Тип - це значення конфігурації sample_index, а можливими значеннями є:

  • inuse_space - обсяг пам'яті, виділений і ще не звільнений
  • inuse_object s - кількість виділених і ще не випущених об'єктів
  • alloc_space - загальний обсяг виділеної пам'яті (незалежно від звільненої)
  • alloc_objects - загальна кількість виділених об'єктів (незалежно від звільнених)

Тепер введіть topінтерактив, на виході будуть провідні споживачі пам'яті

Ми бачимо рядок, який розповідає нам про Dropped Nodesце, це означає, що вони відфільтровані. Вузол - це запис об’єкта або „вузол” у дереві. Викидання вузлів - це гарна ідея для зменшення шуму, але іноді це може приховувати першопричину проблеми з пам’яттю. Приклад цього ми побачимо, продовжуючи розслідування.

Якщо ви хочете включити всі дані профілю, додайте -nodefraction=0опцію під час запуску pprof або введіть nodefraction=0інтерактив.

У виведеному списку ми можемо побачити два значення flatі cum.

  • flat означає, що пам'ять, виділена цією функцією і утримується цією функцією
  • cum означає, що пам'ять була виділена цією функцією або функцією, яку вона викликала у стек

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

Ще одна акуратна хитрість topв інтерактивному вікні полягає в тому, що вона насправді працює top10. Команда top підтримує topNформат, де Nвказана кількість записів, які ви хочете переглянути. У випадку, вставленому вище, top70наприклад, набравши текст , буде виведено всі вузли.

Візуалізації

Незважаючи на те, що topNнадає текстовий список, є кілька дуже корисних варіантів візуалізації, які постачаються з pprof. Можна ввести pngабо gifта багато іншого (див. go tool pprof -helpПовний список).

У нашій системі візуальний вихід за замовчуванням виглядає приблизно так:

Спочатку це може залякувати, але це візуалізація потоків розподілу пам’яті (відповідно до слідів стеків) у програмі. Читання графіка не таке складне, як здається. Білий квадрат із числом показує виділений простір (і сукупний обсяг пам’яті, який він забирає зараз на краю графіка), а кожен ширший прямокутник показує функцію розподілу.

Зверніть увагу, що на зображенні вище я зняв png із inuse_spaceрежиму виконання. Багато разів вам також слід поглянути inuse_objects, оскільки це може допомогти у пошуку проблем з розподілом.

Копаючи глибше, знаходячи першопричину

Наразі ми змогли зрозуміти, що виділяє пам’ять у нашому додатку під час виконання. Це допомагає нам скласти відчуття того, як поводиться наша програма (або неналежним чином поводиться).

У нашому випадку ми могли бачити, що пам’ять зберігається за допомогою membuffers, що є нашою бібліотекою серіалізації даних. Це не означає, що у нас витік пам'яті в цьому сегменті коду, це означає, що ця функція зберігає пам'ять. Важливо розуміти, як читати графік, і вивід pprof загалом. У цьому випадку ми розуміємо, що коли ми серіалізуємо дані, тобто ми виділяємо пам’ять для структур та примітивних об’єктів (int, string), вони ніколи не звільняються.

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

Десь у ланцюжку ми можемо побачити нашу бібліотеку реєстрації, відповідальну за> 50 Мб виділеної пам'яті. Це пам’ять, яка виділяється функціями, що викликаються нашим реєстратором. Подумавши, насправді це очікується. Логгер викликає розподіл пам'яті, оскільки йому потрібно серіалізувати дані для виведення їх до журналу, і, отже, він викликає розподіл пам'яті в процесі.

Ми також можемо бачити, що за шляхом розподілу пам'ять зберігається лише серіалізацією і нічим іншим. Крім того, обсяг пам'яті, що утримується реєстратором, становить близько 30% від загальної кількості. Вищесказане говорить нам, що, швидше за все, проблема не в реєстраторі. Якщо це було на 100%, або щось близьке до нього, то ми мали б шукати там - але це не так. Це може означати, що реєструється щось, чого не повинно бути, але це не витік пам'яті реєстратором.

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

Ми бачимо, що зроблені розподіли сидять у cumстовпці, тобто виділена пам’ять зберігається у стеці викликів. Це відповідає тому, що також показано на графіку. На той момент легко зрозуміти, що причина, через яку реєстратор виділяв пам'ять, полягає в тому, що ми надіслали йому весь об'єкт 'block'. Їй потрібно було принаймні серіалізувати деякі його частини (наші об’єкти - це об’єкти, що вбудовують пам’яті, які завжди реалізують якусь String()функцію). Це корисне повідомлення журналу чи хороша практика? Можливо, ні, але це не витік пам'яті, не в кінці реєстратора або код, який викликав реєстратор.

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

То чому пам’ять зберігається?

Передумовою для цього розслідування стала підозра, що у нас проблема - витік пам’яті. Ми прийшли до цього поняття, оскільки побачили, що споживання пам'яті перевищує те, що ми очікували від системи. На додачу до цього, ми бачили, що воно постійно зростало, що було ще одним вагомим показником "тут є проблема".

На даний момент, у випадку з Java або .Net, ми відкрили б якийсь аналіз або профілі "gc root" і дійшли до фактичного об'єкта, який посилається на ці дані, і створює витік. Як пояснювалося, це не зовсім можливо з Go, як через проблему з інструментарієм, так і через низький рівень представлення пам'яті в Go.

Не вдаючись у подробиці, ми не думаємо, що Go зберігає, який об’єкт зберігається за якою адресою (окрім покажчиків, можливо). Це означає, що насправді, розуміючи, яка адреса пам'яті представляє, який член вашого об'єкта (структури), потребуватиме певного зіставлення з висновком кучевого профілю. Говорячи теорією, це може означати, що перед тим, як зробити повний дамп ядра, слід також взяти профіль купи, щоб адреси могли бути зіставлені з розподільчим рядком і файлом і, отже, об'єктом, представленим у пам'яті.

На даний момент, оскільки ми знайомі з нашою системою, було легко зрозуміти, що це вже не помилка. Це було (майже) за задумом. Але давайте продовжимо вивчати, як отримати інформацію з інструментів (pprof), щоб знайти першопричину.

При встановленні nodefraction=0ми побачимо всю карту виділених об'єктів, включаючи менші. Давайте розглянемо результати:

У нас є два нових піддерева. Знову нагадуючи, профіль кучі pprof є вибіркою розподілу пам’яті. Для нашої системи, яка працює - ми не пропускаємо жодної важливої ​​інформації. Довше нове дерево зеленого кольору, яке повністю відключене від решти системи, є бігуном для тестування, це нецікаво.

Коротший, синього кольору, який має край, що з'єднує його з усією системою, - це inMemoryBlockPersistance. Ця назва також пояснює "витік", який ми собі уявляли. Це серверна база даних, яка зберігає всі дані в пам'яті і не зберігається на диску. Що приємно відзначити, так це те, що ми одразу побачили, що в ньому є два великі предмети. Чому два? Оскільки ми бачимо, що об’єкт має розмір 1,28 МБ, а функція зберігає 2,57 МБ, тобто два з них.

На даний момент проблема добре зрозуміла. Ми могли б використати delve (налагоджувач), щоб побачити, що це масив, що вміщує всі блоки драйвера збереження в пам'яті, який ми маємо.

То що ми могли виправити?

Ну, це відмовно, це була людська помилка. Поки процес навчався (а спільний доступ - це турбота), нам не стало краще, чи ні?

Щось все ще пахло в цій інформації про купу. Десеріалізовані дані забирали занадто багато пам'яті, чому 142 МБ для чогось, що мало б зайняти значно менше? . . pprof може відповісти на це - насправді існує точна відповідь на такі питання.

Щоб розглянути анотований вихідний код функції, ми запустимо list lazy. Ми використовуємо lazy, оскільки назва функції, яку ми шукаємо, є, lazyCalcOffsets()і ми не знаємо, що інші функції в нашому коді починаються з ледачого. list lazyCalcOffsetsЗвичайно, набір тексту також би працював.

Ми можемо побачити дві цікаві відомості. Знову ж пам’ятайте, що у профілі купи pprof зразки інформації про розподіли. Ми бачимо, що flatі cumцифри, і цифри однакові. Це вказує на те, що виділена пам’ять також зберігається цими точками розподілу.

Далі ми бачимо, що make () займає трохи пам’яті. Це має сенс, це вказівник на структуру даних. Проте ми також бачимо, що призначення в рядку 43 займає пам'ять, тобто воно створює розподіл.

Це навчило нас картам, де присвоєння карті не є прямим призначенням змінних. Ця стаття детально описує, як працює карта. Коротше кажучи, на карті є накладні витрати, і чим більше елементів, тим більші накладні витрати будуть «коштувати» у порівнянні зі зрізом.

Наступне слід сприймати з достатньою кількістю солі: непогано було б сказати, що використання a map[int]T, коли дані не є розрідженими або їх можна перетворити на послідовні індекси, зазвичай слід намагатись із реалізацією фрагмента, якщо споживання пам'яті є важливим фактором. . Проте великий зріз, розширившись, може уповільнити операцію, де на карті це уповільнення буде незначним. Чарівної формули оптимізації не існує.

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

Так просто, замість використання карти ми зараз використовуємо фрагмент. Через те, як ми отримуємо дані, які ледаче завантажуються в них, і як ми пізніше отримуємо доступ до цих даних, крім цих двох рядків та структури, що містить ці дані, ніяких інших змін коду не потрібно. Що це зробило із споживанням пам'яті?

Давайте розглянемо benchcmpлише кілька тестів

Тести читання ініціалізують структуру даних, яка створює розподіли. Ми бачимо, що час роботи покращився на ~ 30%, розподіл зменшився на 50%, а споживання пам'яті -> 90% (!)

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

Дивлячись pprofзнову, і приймаючи профіль купи з того ж тіста , ми побачимо , що тепер споживання пам'яті, насправді вниз на ~ 90%.

Винос полягатиме в тому, що для менших наборів даних не слід використовувати карти там, де буде достатньо фрагментів, оскільки карти мають великі накладні витрати.

Повноядерний дамп

Як уже згадувалося, саме тут ми бачимо найбільше обмеження щодо інструментарію прямо зараз. Коли ми розслідували цю проблему, ми були одержимі можливістю дістатися до кореневого об'єкта без особливого успіху. Go еволюціонує з часом із високими темпами, але ця еволюція має свою ціну у випадку повного дампа або представлення пам'яті. Повний формат дампа купи, коли він змінюється, не є сумісним із зворотною стороною. Описана тут остання версія і ви можете використовувати повний дамп купи debug.WriteHeapDump().

Хоча зараз ми не опиняємось “застряглими”, оскільки немає хорошого рішення для вивчення повних звалищ. pprofвідповів на всі наші запитання дотепер.

Зауважте, Інтернет пам’ятає багато інформації, яка вже не є актуальною. Ось деякі речі, які ви повинні ігнорувати, якщо ви збираєтеся спробувати відкрити повний дамп самостійно, починаючи з go1.11:

  • Немає можливості відкрити та налагодити повноцінний дамп на MacOS, лише Linux.
  • Інструменти на //github.com/randall77/hprof призначені для Go1.3, існує форк для версії 1.7+, але він також не працює належним чином (неповний).
  • viewcore на //github.com/golang/debug/tree/master/cmd/viewcore насправді не компілюється. Це досить легко виправити (внутрішні пакунки вказують на golang.org, а не на github.com), але він теж не працює , не на MacOS, можливо, на Linux.
  • Також //github.com/randall77/corelib не працює на MacOS

pprof UI

Останньою деталлю, про яку слід знати, коли йдеться про pprof, є його функція інтерфейсу користувача. Це може заощадити багато часу, починаючи розслідування будь-якої проблеми, що стосується профілю, зробленого за допомогою pprof.

go tool pprof -http=:8080 heap.out

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

Насправді користувальницький інтерфейс ознайомив мене з графіками полум'я, які дуже швидко розкривають зони винуватця коду.

Висновок

Go - це захоплююча мова з дуже багатим набором інструментів, з pprof можна зробити набагато більше. Наприклад, ця публікація взагалі не стосується профілювання процесора.

Деякі інші хороші читає:

  • //rakyll.org/archive/ - Я вважаю, що це один із основних учасників моніторингу ефективності, багато хороших дописів у її блозі
  • //github.com/google/gops - написаний JBD (який керує rakyll.org), цей інструмент гарантує власний допис у блозі.
  • //medium.com/@cep21/using-go-1-10-new-trace-features-to-debug-an-integration-test-1dc39e4e812d - go tool traceщо стосується профілювання процесора, це чудовий пост про цю функцію профілювання .