Webrium
Подписаться

Почему Go стал платформенным дефолтом

Почему горутины, планировщик и статическая линковка делают Go дефолтом для микросервисных платформ — и где те же свойства начинают вредить

Фильтр выбора: из нескольких языков для микросервисов проходит один — Go, ставший дефолтом платформыЯзыки для сервисовGo — дефолт

Вопрос, который закрывают один раз

Когда сервисов становится много, вопрос «на чём писать новый» снимается с инженера и переезжает на уровень выше. Не потому, что архитекторы любят распоряжаться, а потому что каждый ответ стоит денег: свой пайплайн сборки, свой способ профилировать, своя процедура обновления зависимостей, своя планка входа для новичка. При десятках сервисов дешевле закрыть вопрос один раз и объяснять исключения, чем разрешать всё и содержать зоопарк.

«Дефолт» — слово из экономики, а не из программирования, и это честно: выбирает не тот, кто пишет код, а тот, кто платит за его содержание. В бэкенде этот дефолт последние годы раз за разом разрешается в пользу Go, и вопрос «хороший ли это язык» почти ни при чём. Интереснее другое: какие свойства языка, умноженные на сотню сервисов, превращаются в свойства платформы — и где те же свойства начинают вредить.

Как устроен выигрыш

Микросервис по устройству — сетевой демон: принять запрос, сходить в базу и к соседям, подождать, ответить. Почти всё время он ждёт, и язык для него хорош ровно настолько, насколько дёшево умеет ждать.

Дёшево ждать умеют два классических способа. Событийный цикл экономен, но заходит в сам язык: асинхронность заражает сигнатуры, у каждой функции появляется вторая, «асинхронная» версия, колбэки ломают линейную логику — конечный автомат в итоге пишется руками. Потоки сохраняют линейность, но поток операционной системы — дорогой предмет: на каждое ожидание его не напасёшься, память под стеки и планировщик ядра берут своё.

Go сделал третью ставку: горутины и собственный планировщик поверх потоков. Стек горутины стартует крошечным и растёт по требованию — когда глубина вызовов требует, рантайм переносит его на новое место побольше, — поэтому горутину можно заводить на каждое соединение, не считая. Горутина, которая блокируется на чтении сокета, паркуется, дескриптор уходит в netpoller (под капотом — тот же epoll, что и у событийных серверов), а освободившийся поток обслуживает остальных. Код выглядит так, будто вы честно блокируетесь: прочитай, обработай, ответь. Рантайм под этой вывеской превращает блокировки в неблокирующие. Линейный код по экономике событийного цикла, без второй версии каждой функции. Для сервиса, который по работе — обвязка вокруг сетевых вызовов, это попадание в центр.

Сборщик мусора настроен на хвосты, а не на пики. Разметка идёт параллельно с работой сервиса; полная остановка нужна лишь чтобы стартовать и финализировать разметку, а это работа фиксированная, от размера кучи не зависящая. Поэтому пауза предсказуема и на маленькой куче, и на большой. Плата — память: сборщик держит запас над живыми данными, целевой уровень задаёт, во сколько раз куча может превышать живое, и этот запас — цена предсказуемости. Для сервиса, у которого в договоре перцентили задержек, размен правильный.

Дальше — артефакт. Go линкуется статически: на выходе один файл, внутри которого и скомпилированный код, и рантайм. Не нужен интерпретатор, не нужна виртуальная машина на хосте, кросс-компиляция — флажок при сборке. Контейнер может состоять из пустого базового образа плюс бинарник; холодный старт при автоскейле — запуск обычного процесса. Для платформы это одна форма пайплайна на все сервисы.

И стандартная библиотека: HTTP-сервер, TLS, JSON, тесты, бенчмарки, профилирование через pprof — из коробки. Голый сервис без внешних зависимостей уже слушает порт и отдаёт профили, поэтому фреймворк-культура вокруг языка тонкая, а поездов обновлений у сервисов меньше.

Отдельно — маленький язык. Спецификация читается за вечер, форматирование одно на всех, канонический стиль один. Когда одни и те же репозитории пишут разные люди, споры о стиле исчезают как класс: gofmt закрывает вопрос до того, как он начнётся. Инженер, переходящий между сервисами, читает чужой код без диалектных сюрпризов — при постоянной ротации людей это не удобство, а прямая экономия.

Побочный эффект для платформы: одинаковый рантайм даёт похожий ресурсный профиль. Сервисы лежат в предсказуемом коридоре памяти и процессора, квоты и ёмкостное планирование от этого проще. Предсказуемость здесь дороже рекордов.

Где ломается — и почему именно там

Слабые места Go — не случайные дыры, а тени сильных сторон: ломается он там, где платит за свои выигрыши.

Первый счёт — память. Запас над живыми данными и фоновая разметка означают, что резидентной памяти сервису нужно заметно больше, чем нативному коду с той же работой. Пока квота щедрая, этого не видно; когда сервис попадает в тесный слот — сайдкар, жёсткий лимит, плотное соседство, — счёт выставляется. На очень большой куче сама разметка становится работой, и налог на пропускную способность растёт.

Планировщик конфликтует с квотами. По умолчанию рантайм отталкивается от числа ядер машины — это настройка GOMAXPROCS, — а контейнер получает долю процессора по квоте. Рантайм заводит потоки под все ядра, включая те, которые ему жечь не разрешено; квота исчерпывается раньше времени, cgroup душит процесс, задержки идут всплесками — при том, что сам код невиновен. В свежих релизах рантайм научился видеть квоту контейнера, но класс поломки показательный: модель машины у рантайма и модель контейнера у оркестратора — две разные правды, и сводить их — постоянная работа, а не разовая.

Отдельная история — граница cgo. Как только нужен нативный код — кодек, криптопримитив, драйвер, существующий только в C, — сыпется сразу всё. Бинарник перестаёт быть чисто статическим, каждое пересечение границы стоит фиксированных накладных, из-за которых мелкие вызовы съедают весь выигрыш, блокирующийся вызов привязывает поток ОС и ломает арифметику планировщика, кросс-компиляция усложняется. Обвал выглядит непропорциональным — и это честно: рушится не одна фича, а весь пакет сразу.

С ошибками стройно по замыслу: ошибка — это значение, обрабатывай явно. Но компилятор проверяет присваивание, а не обработку, и проверку легко не написать или обернуть ошибку бездумно. Системная беда даже не в многословии, а в потере контекста: завёрнутая в общую фразу ошибка ничего не сообщает тому, кто будет разбираться в ней в три часа ночи. Линтеры помогают, но проблема культурная, а не синтаксическая.

Наконец, процессор. Рантайм построен вокруг ожиданий ввода-вывода. CPU-bound цикл платит налоги сборщика и планировщика, не получая ничего взамен: нет контроля над раскладкой памяти, векторных инструкций из языка не достать, сборщик сканирует указатели в горячих структурах. Go умеет «достаточно быстро», но не «быстрее всех на ядро» — и это ровно территория спора про Rust в сервисах: где он окупается, а где нет, мы разбирали отдельно.

Тупик: крутить ручки

Первое, что пробуют, когда сервис на Go упирается в память или квоту, — крутить ручки: GOGC вниз, лимит памяти сборщику, квоту контейнера вверх, реплик побольше. Это не глупость — это самая доступная кнопка: ручки не требуют понимать устройство, меняются в конфиге и выглядят как действие.

Почему это не помогает: ручки не меняют кривую размена, они двигают точку вдоль неё. GOGC задаёт, до какого превышения над живыми данными куча может вырасти до следующего цикла; уменьшаешь — циклы идут чаще, фоновый процессор растёт, и вы выкупили память за процессор. Увеличиваешь — памяти нужно ещё больше. Поднятая квота — налог, который теперь платит каждая реплика при каждом автоскейле. Если работе объективно нужно меньше памяти на запрос, ни одна ручка этого не даст: планку ставят структуры данных и рантайм, настройки тут ни при чём. Метрики после подкрутки подравниваются, кажется, что помогло, — а кривая возвращается на место через неделю.

Помогает смена формы работы: стримить вместо буферизации целых ответов, пулить горячие объекты, батчить мелкие вызовы, вынести горячий цикл за очередь. А если и этого мало — признать границу и переписать один сервис на другом инструменте; о том, где такой размен окупается, а где нет, у нас был отдельный материал.

Тупик с ручками дорог именно тем, что похож на работу.

Позиция редакции

Наша позиция: для платформы из множества мелких сетевых сервисов дефолт должен быть один, и по совокупности свойств — дешёвое ожидание, статический бинарник, встроенное профилирование, маленький язык — это Go. Лучшим языком он не является; он дешевле всех стандартизируется, а унификация окупается больше, чем победа каждого сервиса в отдельности.

Проверить её на своих данных можно без всяких замеров, и там же лежит условие, при котором мы неправы. Пройдитесь по каталогу сервисов и про каждый спросите одно: большую часть времени он ждёт сеть — или жжёт процессор и лезет в нативные библиотеки? Мы ошибаемся, если перевес за вторыми: тогда дефолт превращается из экономии в налог, исключений становится больше, чем правил, и картину правильнее развернуть — другой инструмент в центр, Go оставить для сетевой обвязки. Мы также ошибаемся, если у вас уже зрелая платформа на JVM или .NET с наработанной инструментальной обвязкой: экономия от унификации не покроет миграцию, и второй стек рядом с первым хуже, чем один неидеальный.

Дефолт полезен ровно настолько, насколько известно, где он кончается.

С чем это связано

Метка над заголовком говорит, зачем туда идти: продолжить тему, перейти к практике или увидеть возражение.

Сетка сервисов: закрашены лишь три ячейки — случаи, где Rust окупается; рядом счётчик сэкономленных машинЭкономия машин
Спор

Rust в сервисах: где окупается, а где нет

Спор с тезисом о Go как платформенном дефолте: окупаемость Rust, по мнению автора, определяют два фактора, и оба нетехнические. Отдельно — как мерить выгоду, чтобы не соврать себе.

Из трёх причин медленной работы за шкалу выходит ожидание, а не вычислениеожидание
Предыстория

Python тормозит: что действительно ускоряет

База под остальное: пока три вида «медленно» не разделены, любое ускорение остаётся угадыванием, включая переписывание на другом языке.

За порогом срока жизни системы цена содержания перевешивает скорость первой версиисрок жизницена содержания
Предыстория

Java никуда не делась: где она осталась и почему

Что учесть заранее: решение о языке принимает тот, кто платит за содержание системы годами, а не тот, кто её начинает.

Момент, местное время и намерение на будущее хранятся по-разномумомент внутри
Практика

Время в приложении: где оно ломается

Руками по датам: момент, местное время и намерение на будущее — три разные вещи, и путаница между ними ломает расчёты.

Письмо по вторникам

Один разбор недели и короткий список того, что изменилось. Без дайджестов на сорок ссылок.

Начните вводить — материалы появятся здесь.

выбрать · Enter открыть · Esc закрыть