Шлях Go: як прискорювалася збірка сміття

Мова Go Twitch використовується в багатьох навантажених системах. Простота, безпека, продуктивність і читабельність роблять його гарним інструментом для вирішення проблем, з якими стикаються сервіси, наприклад при стриминге відео та обслуговуванні листування мільйонів користувачів.

Але ця стаття — не чергове оспівування Go. Вона про те, як наше використання цієї мови розсовує деякі межі поточної реалізації runtime'а і як ми реагуємо на досягнення цих кордонів.

Це історія про те, як поліпшення runtime'а з Go 1.4 за Go 1.6 дало нам 20-кратне зменшення пауз при роботі збирача сміття, як ми отримали ще 10-кратне зменшення пауз в Go 1.6 і як, передавши наш досвід команді розробників, яка працює над runtime'ом Go, забезпечили 10-кратне прискорення в Go 1.7 без додаткових ручних налаштувань з нашої сторони.

Початок саги про паузах на збірку сміття
Наша система чату на базі IRC вперше була реалізована на Go в кінці 2013 року. Вона прийшла на зміну попередньої реалізації на Python. Для її створення використовувалися пререлизные версією Go 1.2, і система була здатна одночасно обслуговувати до 500 000 користувачів з кожного фізичного хоста без особливих хитрувань.

При обслуговуванні кожного з'єднання групою з трьох горутин (легковагі потоки виконання в Go) програма утилізувала 1 500 000 горутин на один процес. І навіть при такій їх кількості єдиною серйозною проблемою в продуктивності, з якою ми зіткнулися у версіях Go 1.2, була тривалість пауз на збірку сміття. Додаток зупинялося на десятки секунд при кожному запуску складальника, а це було неприпустимо для нашого інтерактивного чат-сервісу.

Мало того, що кожна пауза на збірку сміття обходилася дуже дорого, так ще і складання запускалася по кілька разів в хвилину. Ми витратили багато сил на зниження кількості і розміру виділених блоків пам'яті, щоб складальник запускався рідше. Для нас стало перемогою збільшення купи (heap) лише на 50% кожні дві хвилини. І хоча пауз стало менше, вони залишалися дуже тривалими.

Після релізу Go 1.2 паузи скоротилися «всього» до декількох секунд. Ми розподілили трафік по більшій кількості процесів, що дозволило зменшити тривалість пауз до більш комфортного значення.

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

Починаючи з Go 1.5 в серпні 2015-го збирач сміття Go став працювати по більшій частині конкуррентно і инкрементально. Це означає, що майже вся робота виконується без повної зупинки програми. Крім того що фази підготовки та переривання досить короткі, наша програма продовжує працювати в той час, як процес збирання сміття вже йде. Перехід на Go 1.5 моментально привів до 10-кратного зменшення пауз у нашій чат-системі: при великому навантаженні в тестовому оточенні з двох секунд до приблизно 200 мс.

Go 1.5 — нова ера сміття
Хоча зменшення затримки в Go 1.5 саме по собі стало для нас святом, найкращим властивістю нового збирача сміття виявилося те, що він підготував грунт для подальших послідовних поліпшень.

Збірка сміття в Go 1.5 як і раніше, складається з двох основних фаз:

  • Mark — спочатку позначаються ті ділянки пам'яті, які ще використовуються;
  • Sweep — всі невикористовувані ділянки готуються до повторного використання.
Але кожна з цих фаз тепер складається з двох стадій:

  • Mark:
    • додаток призупиняється, очікуючи завершення попередньої sweep-фази;
    • потім одночасно з роботою програми виконується пошук використовуваних блоків пам'яті.

  • Sweep:
    • додаток знову ставиться на паузу для переривання mark-фази;
    • одночасно з роботою програми невживана пам'ять поступово готується до переиспользованию.

Runtime-функція
gctrace
дозволяє вивести на екран інформацію з результатами по кожній ітерації сміття, включаючи тривалість усіх фаз. Для нашого чат-сервера вона показала, що більша частина паузи припадає на переривання mark-фази, тому ми вирішили зосередити на цьому свою увагу. І хоча група розробників Go, що відповідає за runtime, запросила баг-репорти з додатків, в яких спостерігаються довгі паузи на прибирання сміття, ми опинилися нехлюями і зберегли це в секреті!

Звичайно, нам потрібно було зібрати більше подробиць про роботу складальника під час пауз. Основні пакети в Go включають в себе чудовий CPU-профілювальник користувацького рівня, але для своєї задачі ми скористалися інструментом perf з Linux. Під час перебування в ядрі він дозволяє отримувати семпли з більш високою частотою і видимістю. Моніторинг циклів у ядрі може допомогти нам налагодити повільні системні виклики і зробити прозорим управління віртуальною пам'яттю.

Нижче показана частина профілю нашого чат-сервера, що працює на Go 1.5.1. Графік (Flame Graph) побудований за допомогою інструменту Брендана Грегга. Включені тільки ті семпли, в стеку яких є функція
runtime.gcMark
, Go 1.5 яка апроксимує час, витрачений на переривання mark-фази.



Піки на графіку показують збільшення глибини стека, а ширина кожної секції відображає час роботи CPU. Кольори і порядок по осі Х не звертайте уваги, вони не мають значення. У лівій частині графіка ми бачимо, що майже в кожному з семпловых стеків
runtime.gcMark
викликає
runtime.parfordo
. Подивившись вище, помічаємо, що більшу частину часу займають здійснюються
runtime.markroot
виклики
runtime.scang
,
runtime.scanobject
та
runtime.shrinkstack
.

Функція
runtime.scang
призначена для пересканирования пам'яті, щоб допомогти завершитися mark-фазі. Сутність переривання полягає у закінченні сканування пам'яті програми, так що ця робота абсолютно необхідна. Краще придумати, як підвищити продуктивність інших функцій.

Переходимо до
runtime.scanobject
. У цій функції кілька завдань, але її виконання під час переривання mark-фази в Go 1.5 потрібно для реалізації финализаторов (функція, виконувана перед видаленням об'єкта збирачем сміття. — Приміт. перекладача). Ви можете запитати: «Чому програма використовує так багато финализаторов, що вони помітно впливають на тривалість пауз при збірці сміття?» В даному випадку програма — це чат-сервер обробляє повідомлення від сотень тисяч користувачів. Основний «мережевий» пакет Go прикріплює за финализатору до кожного TCP-з'єднання для допомоги в управлінні витоками файлових дескрипторів. А оскільки кожен користувач отримує власне TCP-з'єднання, то це вносить невеликий внесок у тривалість переривання mark-фази.

Нам здалося, що це гідно бути зарепорченным команди Go. Ми написали розробникам, і вони дуже допомогли нам своїми порадами, як можна діагностувати проблеми у продуктивності і як їх виділити в мінімальні тестові кейси. В Go 1.6 розробники перенесли сканування финализаторов в паралельну mark-фазу, що дозволило зменшити паузу в додатках з великою кількістю TCP-з'єднань. Було зроблено і багато інших поліпшень, у результаті при переході на Go 1.6 паузи на нашому чат-сервері зменшилися порівняно з Go 1.5 вдвічі — до 100 мс. Прогрес!

Скорочення стека
Прийнятий в Go підхід до concurrency передбачає дешевизну використання великої кількості горутин. Якщо програма, що використовує 10 000 потоків ОС, може працювати повільно, для горутин така кількість в порядку речей. На відміну від традиційних великих стеків фіксованого розміру, горутины починають з дуже маленького стека — всього 2 Кб, — який збільшується по мірі необхідності. На початку виклику функції в Go виконується перевірка, чи достатньо розміру стека для наступного виклику. І якщо ні, то перед продовженням виклику стек горутины переміщається в більш велику область пам'яті, з перезаписом покажчиків у разі необхідності.

Отже, по мірі роботи програми стеки горутин збільшуються, щоб виконувати найглибші виклики. У завдання збирача сміття входить повернення невикористовуваної пам'яті. За переміщення стеків горутин в більш підходящі за розміром області пам'яті відповідає функція
runtime.shrinkstack
, яка Go 1.5 і 1.6 виконується під час переривання mark-фази, коли додаток стоїть на паузі.



Цей графік записаний на пререлизной версії від 1.6 жовтня 2015 року.
runtime.shrinkstack
займає приблизно три чверті семплів. Якщо б ця функція виконувалася під час роботи програми, то ми отримали б серйозне скорочення пауз на нашому чат-сервері і інших подібних програмах.

Документація по runtime-пакету Go пояснює, як відключити скорочення стеків. Для нашого чат-сервера втрата якоїсь частини пам'яті — невелика плата за зменшення пауз на збірку сміття. Ми так і зробили, перейшовши на Go 1.6. Після відключення скорочення стеків тривалість пауз знизилася до 30-70 мс, залежно від «напрямку вітру».

Структура та схема роботи нашого чат-сервера майже не змінювалися, але від многосекундных пауз в Go 1.2 ми дійшли до 200 мс в Go 1.5, а потім до 100 мс в Go 1.6. Зрештою більшість пауз стали коротше 70 мс, тобто ми отримали поліпшення більш ніж у 30 разів.

Але, напевно, повинен бути потенціал для подальшого вдосконалення. Настав час знову знімати профіль!

Page fault'и?!
До цього моменту розкид тривалості пауз був невеликий. Але тепер вони стали змінюватися в широких межах (від 30 до 70 мс), не корелює з будь-якими результатами
gctrace
. Ось графік циклів під час досить довгих пауз переривання mark-фази:



Коли збирач сміття викликає
runtime.gcRemoveStackBarriers
, система генерує помилку відсутності сторінки (page fault), що призводить до виклику функції ядра
page_fault
. Це відображає широка «вежа» праворуч від центру графіка. З допомогою page fault'ів ядро розподіляє сторінки віртуальної пам'яті (зазвичай розміром 4 Кб) фізичної пам'яті. Часто процеси можуть розміщувати величезні обсяги віртуальної пам'яті, яка перетворюється в резидентну при зверненні додатки тільки за допомогою page fault'ів.

Функція
runtime.gcRemoveStackBarriers
перетворює пам'ять стека, до якої нещодавно зверталася додаток. Фактично вона призначена для видалення «бар'єрів стека» (stack barriers), доданих за деякий час до цього, на початку циклу збірки сміття. Системі доступно достатньо пам'яті, вона не присвоює фізичну пам'ять якимось іншим, більш активним процесам. Так чому ж доступ до неї призводить до помилок?

Підказка може ховатися в нашому обладнанні. Для чат-системи ми використовуємо сучасні двопроцесорні сервери. До кожного сокету безпосередньо підключено кілька банків пам'яті. Така конфігурація дозволяє реалізувати нерівномірний доступ до пам'яті (NUMA, Non-Uniform Memory Access). Коли потік (thread) виконується в ядрі сокета 0, то до підключеного до цього сокету пам'яті у нього доступ швидше, ніж до решти пам'яті. Ядро Linux намагається зменшити цю різницю, запускаючи потоки на те ядрі, до якого підключена використовувана ними пам'ять, і переміщаючи сторінки фізичної пам'яті «ближче» до відповідних потоків.

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

Ядро Linux підхопило майже безглузді патерни доступу до пам'яті під час переривання mark-фази і з-за них переносить сторінки пам'яті, що дорого нам обходиться. Така поведінка дуже слабо проявлялося на графіку Go 1.5.1, але, коли ми звернули увагу на
runtime.gcRemoveStackBarriers
, стало набагато помітніше.

Тут найбільш виразно проявилися переваги профілювання з допомогою perf. Цей інструмент може показати стеки ядра, у той час як профілювальник Go користувацького рівня показав би тільки, що Go-функції виконуються нез'ясовно повільно. Perf набагато складніше у використанні, він вимагає root-доступу для перегляду стеків ядра і Go 1.5 і 1.6 вимагає використання нестандартного тулчейна (toolchain) (GOEXPERIMENT=framepointer ./make.bash, Go 1.7 буде стандартним). Але рішення зазначених проблем варто зусиль.

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



Після прив'язки до єдиного вузла NUMA тривалість переривання mark-фази знизилася до 10-15 мс. Суттєве покращення в порівнянні з 200 мс в Go 1.5 або двома секундами в Go 1.4. Такий же результат можна отримати і без того, щоб пожертвувати половиною сервера. Достатньо з допомогою
set_mempolicy(2)
або
mbind(2)
призначити процесу політику використання пам'яті
MPOL_BIND
. Наведений профіль був отриманий на пререлизной версією Go 1.6 в жовтні 2015 року. У лівій частині видно, що виконання
runtime.freeStackSpans
займає чимало часу. Після того як ця функція була перенесена в паралельно виконувану фазу сміття, вона більше не впливає на тривалість паузи. Мало що тепер можна видалити стадії переривання mark-фази!

Go 1.7
Аж до Go 1.6 ми відключали функцію скорочення стека. Це надавало мінімальний вплив на використання пам'яті нашим чат-сервером, але істотно підвищувало операційну складність. Для деяких додатків зменшення стека відіграє дуже велику роль, тому ми відключали цю функцію дуже вибірково. В Go 1.7 стек тепер зменшується прямо під час роботи програми. Так що ми отримали все найкраще з двох світів: мале споживання пам'яті без спеціальних налаштувань.

З моменту появи паралельно виконуваного збирача сміття в Go 1.5 runtime відстежує, виконувалася кожна горутина після його останнього сканування. Під час переривання mark-фази знову виявляються горутины, які нещодавно виконувалися, і піддаються скануванню. В Go 1.7 runtime підтримує готельний короткий список таких горутин. Це дозволяє більше не шукати по всьому списку горутин, коли код ставиться на паузу, і сильно скорочує кількість звернень до пам'яті, які можуть запустити міграцію пам'яті у відповідності з алгоритмами NUMA.

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

У пререлизной версією Go 1.7 від червня 2016 року паузи на збірку сміття стали ще менше, причому без всяких додаткових хитрувань. У нашого сервера вони «з коробки» наблизилися до 1 мс — у десять разів менше порівняно з налаштованої конфігурацією Go 1.6!

Наш досвід допоміг команді розробників Go знайти постійне вирішення проблем, з якими ми стикалися. Для додатків, подібних нашому, при переході з Go на 1.5 1.6 профілювання та налаштування дозволили в десять разів зменшити паузи. Але в Go 1.7 розробники змогли досягти вже 100-кратної різниці порівняно з Go 1.5. Знімаємо капелюха перед їх стараннями поліпшити продуктивність runtime'а.

далі
Весь цей аналіз присвячений прокляття нашого чат-сервера — пауз в роботі, але це лише один вимір продуктивності збирача сміття. Вирішивши проблему пауз, розробники Go тепер можуть зайнятися проблемою пропускної здатності.

Згідно опису транзакційного складальника (Transaction Oriented Collector), у ньому застосовується підхід прозорого недорогого виділення та збирання пам'яті, яка не використовується спільно горутинами. Це дозволить відкладати потреба у повноцінному запуску збирача і знижує загальну кількість циклів CPU на збірку сміття.
Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.