Вопрос, который закрывают один раз
Когда сервисов становится много, вопрос «на чём писать новый» снимается с инженера и переезжает на уровень выше. Не потому, что архитекторы любят распоряжаться, а потому что каждый ответ стоит денег: свой пайплайн сборки, свой способ профилировать, своя процедура обновления зависимостей, своя планка входа для новичка. При десятках сервисов дешевле закрыть вопрос один раз и объяснять исключения, чем разрешать всё и содержать зоопарк.
«Дефолт» — слово из экономики, а не из программирования, и это честно: выбирает не тот, кто пишет код, а тот, кто платит за его содержание. В бэкенде этот дефолт последние годы раз за разом разрешается в пользу 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 с наработанной инструментальной обвязкой: экономия от унификации не покроет миграцию, и второй стек рядом с первым хуже, чем один неидеальный.
Дефолт полезен ровно настолько, насколько известно, где он кончается.