(переклад оригінальної статті)
Rust стає першокласною мовою в різних областях. Ми в Discord успішно використовуємо його і на серверній, і на стороні клієнта. Наприклад, на стороні клієнта в конвеєрі кодування відео для Go Live, а на стороні сервера для функцій Elixir NIF (Native Implemented Functions).
Нещодавно ми різко покращили продуктивність однієї служби, переписавши її з Go на Rust. У цій статті пояснимо, чому для нас було сенс переписати службу, як ми це зробили і наскільки підвищилася продуктивність.
Служба відстеження станів прочитання (Read States)
Наша компанія побудована навколо одного продукту, тому почнемо з деякого контексту, що ми перевели з Go на Rust. Це служба відстеження станів/статусів "прочитано" (Read States). Її єдине завдання – відстежувати, які канали та повідомлення ви прочитали. Доступ до Read States здійснюється при кожному підключенні до Discord, при кожному надсиланні повідомлення та кожному читанні повідомлення. Коротше кажучи, стани читаються постійно і знаходяться на «гарячому шляху». Ми хочемо переконатися, що Discord завжди швидко працює, тому перевірка станів має відбуватися швидко.
Реалізація служби Go не відповідала всім вимогам. Більшість часу вона працювала швидко, але кожні кілька хвилин починалися сильні затримки, помітні для користувачів. Після вивчення ситуації ми визначили, що затримки пояснюються ключовими особливостями Go: його моделлю пам'яті та збирачем сміття (GC).
Чому Go не відповідає нашим цілям щодо продуктивності
Щоб пояснити, чому Go не відповідає нашим цільовим показникам продуктивності, спочатку потрібно обговорити структури даних, масштаб, шаблони доступу та архітектуру сервісу.
Для зберігання інформації про стани ми використовуємо структуру даних, що так і називається: Read State. У Discord їх мільярди: за одним станом кожного користувача на кожен канал. Кожен стан має кілька лічильників, які необхідно атомарно оновлювати і часто скидати в нуль. Наприклад, один із лічильників – це кількість @mention у каналі.
Для швидкого оновлення атомарного лічильника на кожному сервері Read States є кеш останніх станів (Least Recently Used, LRU). У кожному кеші мільйони користувачів та десятки мільйонів станів. Кеш оновлюється сотні тисяч разів на секунду.
Для збереження кеш синхронізується із кластером бази даних Cassandra. При витісненні ключа з кешу ми заносимо стан цього користувача в БД. У майбутньому ми плануємо оновлювати базу протягом 30 секунд під час кожного оновлення стану. Це десятки тисяч записів у БД кожну секунду.
На графіці внизу – час відгуку та навантаження на CPU у піковий проміжок часу для служби Go. Видно, що затримки та сплески навантаження на CPU відбуваються кожні дві хвилини.
То звідки зростання затримок кожні дві хвилини?
Go пам'ять не звільняється відразу при витісненні ключа з кеша. Натомість періодично запускається збирач сміття, який шукає ділянки пам'яті, що не використовуються. Це велика робота, яка може уповільнити виконання програми.
Дуже схоже, що періодичні підгальмовування нашої служби пов'язані зі складанням сміття. Але ми написали дуже ефективний код Go із мінімальною кількістю виділень пам'яті. Там не повинно залишатися багато сміття. У чому ж справа?
Покопавшись у вихідному коді Go, ми дізналися, що Go примусово запускає складання сміття щонайменше кожні дві хвилини. Незалежно від розміру купи, якщо GC не запускався дві хвилини, Go примусово запустить його.
Ми вирішили, що якщо запускати GC частіше, можна уникнути цих піків з великими затримками, тому ми поставили точку виведення (endpoint) у службі, щоб на льоту змінювати значення GC Percent. На жаль, налаштування GC Percent ні на що не вплинуло. Як таке могло статися? Виявляється, GC не хотів запускатись частіше, тому що ми недостатньо часто виділяли пам'ять.
Ми почали копати далі. Виявилося, що настільки великі затримки виникають не через величезну кількість пам'яті, що вивільняється, а тому що збирач сміття сканує весь кеш LRU, щоб перевірити всю пам'ять. Тоді ми вирішили, що якщо зменшити кеш LRU, обсяг сканування зменшиться. Тому ми додали до служби ще один параметр, щоб змінювати розмір кешу LRU, і змінили архітектуру, на кожному сервері розбивши LRU на багато окремих кешів.
Так і сталося. З меншими кешами пікові затримки зменшились.
На жаль, компроміс зі зменшенням кешу LRU підняв 99-й процентиль (тобто збільшилося середнє значення для вибірки з 99% затримок, крім пікових). Це зв’язано з тим, що зменшення кешу зменшує ймовірність, що Read State користувача буде у кеші. Якщо його тут немає, то ми маємо звернутися до БД.
Провівши великий обсяг навантажувального тестування на різних розмірах кешу, ми начебто знайшли прийнятне налаштування. Нехай і не ідеальне, але це було задовільне рішення, тож ми надовго залишили службу працювати так.
У той же час ми дуже успішно впроваджували Rust в інших системах Discord, і в результаті ухвалили колективне рішення писати фреймворки та бібліотеки для нових сервісів тільки на Rust. А ця служба здавалася чудовим кандидатом для перенесення на Rust: вона невелика та автономна, а ми сподівалися, що Rust виправить ці сплески із затримками і в кінцевому рахунку зробить сервіс приємнішим для користувачів.
Управління пам'яттю в Rust
Rust неймовірно швидкий і ефективно працює з пам'яттю: без середовища виконання та збирача сміття він підходить для високопродуктивних служб, вбудованих додатків і легко інтегрується з іншими мовами.
У Rust немає збирача сміття, тому ми вирішили, що не буде і цих затримок, як у Go.
В управлінні пам'яттю він використовує досить унікальний підхід із ідеєю «володіння» пам'яттю. Якщо коротко, Rust відстежує, хто має право читати з пам'яті та записувати туди. Він знає, коли програма використовує пам'ять і негайно звільняє її, як тільки пам'ять більше не потрібна. Rust примусово застосовує правила пам'яті під час компіляції, що практично унеможливлює помилки пам'яті під час виконання.4 Вам не потрібно вручну відстежувати пам'ять. Про це подбає компілятор.
Таким чином, у версії Rust, коли стан Read State виключається з кешу LRU, пам'ять звільняється негайно. Ця пам'ять не сидить і не чекає на збирача сміття. Rust знає, що вона більше не використовується і негайно звільняє її. Немає жодного процесу в рантаймі для сканування, яку звільнити пам'ять.
Асинхронний Rust
Але була одна проблема із екосистемою Rust. На момент впровадження нашої служби у стабільній гілці Rust не було пристойних асинхронних функцій. Для мережної служби асинхронне програмування є обов'язковою вимогою. Спільнота розробила кілька бібліотек, але з нетривіальним підключенням та дуже дурними повідомленнями про помилки.
На щастя, команда Rust старанно працювала над спрощенням асинхронного програмування, і воно вже було доступне на нестабільному каналі (Nightly).
Discord ніколи не боявся освоювати нові перспективні технології. Наприклад, ми були одними з перших користувачів Elixir, React, React Native та Scylla. Якщо якась технологія виглядає перспективною і дає нам перевагу, ми готові зіткнутися з неминучою труднощами впровадження і нестабільністю передових інструментів. Це одна з причин, як ми настільки швидко досягли аудиторії в 250 мільйонів користувачів з менш ніж 50 програмістами в штаті.
Впровадження нових асинхронних функцій з нестабільного каналу Rust — ще один приклад нашої готовності прийняти нову технологію. Інженерна команда вирішила впровадити потрібні функції, не чекаючи їхньої підтримки у стабільній версії. Разом з іншими представниками спільноти ми подолали всі проблеми, що виникли, і тепер асинхронний Rust підтримується в стабільній гілці. Наша ставка окупилася.
Впровадження, навантажувальне тестування та запуск
Просто переписати код було нескладно. Ми почали з грубої трансляції, потім скоротили його у тих місцях, де це мало сенс. Наприклад, у Rust чудова система типів із великою підтримкою дженериків (для роботи з даними будь-якого типу), тому ми спокійно викинули код Go, який компенсував відсутність дженериків. Крім того, модель пам'яті Rust враховує безпеку пам'яті в різних потоках, тому ми викинули захисні горутини.
Навантажувальне тестування одразу показало відмінний результат. Швидкодія служби на Rust виявилася такою ж високою, як у версії Go, але без цих сплесків підвищення затримки!
Що характерно ми практично не оптимізували версію Rust. Але навіть із найпростішою оптимізацією Rust зміг перевершити ретельно налаштовану версію Go. Це промовистий доказ, наскільки легко писати ефективні програми на Rust порівняно з глибоким зануренням у Go.
Але нас не задовольнив простий статус-кво за продуктивністю. Після невеликого профілювання та оптимізації ми перевершили Go за всіма показниками. Затримка, CPU та пам'ять – все стало краще у версії Rust.
Оптимізації продуктивності Rust включали:
Перехід на BTreeMap замість HashMap у кеші LRU для оптимізації використання пам'яті.
Заміну початкової бібліотеки метрик на версію із підтримкою сучасного паралелізму Rust.
Зменшення кількості копій у пам'яті.
Задоволені ми вирішили розгорнути сервіс.
Запуск пройшов досить гладко, оскільки ми проводили випробування навантаження. Ми підключили службу до одного тестового вузла, виявили та виправили кілька прикордонних випадків. Невдовзі після цього накотили нову версію весь серверний парк.
Результати показані нижче.
Фіолетовий графік – Go, синій – Rust.
Збільшення обсягу кешу
Коли служба успішно відпрацювала кілька днів, ми вирішили знову збільшити кеш LRU. Як згадувалося вище, у версії Go це не можна було зробити, тому що зростав час на складання сміття. Оскільки ми більше не займаємося складанням сміття, можна збільшити кеш у розрахунку на ще більше зростання продуктивності. Отже, ми наростили пам'ять на серверах, оптимізували структуру даних на менше використання пам'яті (для задоволення) та збільшили обсяг кешу до 8 мільйонів станів Read State.
Нижче наведені результати говорять самі за себе. Зверніть увагу, що середній час вимірюється в мікросекундах, а максимальна затримка @mention вимірюється в мілісекундах.
Розвиток екосистеми
Нарешті, Rust має чудову екосистему, яка швидко розвивається. Наприклад, нещодавно вийшла нова версія асинхронного середовища виконання, яке ми використовуємо - Tokio 0.2. Ми оновилися, і без жодних зусиль з нашого боку автоматично знизили навантаження на CPU. На графіці нижче можете бачити, як навантаження знизилося приблизно з 16 січня.
Заключні думки
На даний момент Discord використовує Rust у багатьох частинах програмного стеку: для GameSDK, захоплення та кодування відео в Go Live, в Elixir NIF, кількох бекенд-сервісах і багато де ще.
Під час запуску нового проекту або програмного компонента ми обов'язково розглядаємо можливість використання Rust. Звісно, лише там, де це має сенс.
Окрім продуктивності, Rust дає розробникам багато інших переваг. Наприклад, його типобезпека та перевірка запозичення змінних (borrow checker) сильно спрощують рефакторинг у міру зміни вимог до продукту або впровадження нових функцій мови. Екосистема та інструментарій чудові та швидко розвиваються.