Вы наверняка слышали рекламу Go: легковесные горутины, конкурентность на каналах, быстрый HTTP-сервер в несколько строк. Синтаксис настолько мал, что его можно выучить за выходные. Затем вы разворачиваете сервис, и через неделю он съедает 4 ГБ оперативной памяти, p99 задержка подскакивает, а go tool pprof показывает сотни тысяч горутин, большинство из которых навсегда заблокированы на отправке в канал. Язык вас не обманул — вас подвела ментальная модель, построенная по туториалам.
Эта статья о тех частях Go, которые обзор синтаксиса пропускает: что на самом деле делает M:N планировщик, почему go func() стоит около 2 КБ, а поток ОС — мегабайты, что происходит внутри операции с каналом и где стандартная библиотека перестаёт быть достаточной. Если вы изучаете Go для бэкенд-разработки, именно эти вещи отличают код, который компилируется, от кода, который выживает в продакшене.
Что на самом деле делает рантайм
Go не отображает одну горутину на один поток ОС. Рантайм реализует M:N планировщик: M горутин мультиплексируются на N потоков ОС через структуру, называемую GMP-моделью.
- G — горутина: стек, указатель инструкций и небольшая структура состояния. Начинается с 2 КБ и растёт или сжимается по мере необходимости.
- M — машина, то есть реальный поток ОС, управляемый ядром. Дорогой: стек по умолчанию обычно занимает 8 МБ виртуального адресного пространства, а переключения контекста проходят через ядро.
- P — процессор: логический контекст планирования с локальной очередью выполнения (до 256 горутин) и ссылкой на M. Количество P задаётся
GOMAXPROCS, по умолчанию равным числу ядер CPU.
Когда вы пишете go doWork(), рантайм помещает новую G в локальную очередь текущего P. Если очередь P заполнена, G отправляется в глобальную очередь. Простаивающий P крадёт работу из очереди другого P — это work stealing, и именно поэтому программа на Go с тысячами горутин всё равно насыщает все ядра без написания пула потоков.
Неочевидная часть — что происходит, когда горутина блокируется на вводе-выводе. Рантайм не паркует поток ОС. Он отсоединяет P от M, передаёт P другому M и позволяет заблокированному M оставаться в системном вызове. Когда системный вызов возвращается, G пытается присоединиться к P; если свободного нет, она отправляется в глобальную очередь, а M паркуется. Вот почему Go-сервер может держать десятки тысяч одновременных соединений с горсткой потоков — и почему плохо написанный вызов cgo может заморить планировщик, если блокируется надолго.
Пошаговый разбор: от наивного до продакшн HTTP-сервера
Возьмём конкретную задачу: сервис принимает POST /shorten с URL и возвращает короткий код, используя Postgres, с обработчиком редиректа на GET /{code}. Наивная версия — сорок строк. Вот что практик меняет по порядку и почему.
Шаг 1 — обработчик и контекст запроса. net/http даёт вам http.ServeMux, http.HandlerFunc и *http.Request, чей Context() отменяется при отключении клиента. Первое правило: передавайте r.Context() во все последующие вызовы — запрос к базе данных, исходящий HTTP-вызов. Если вы используете context.Background(), клиент, который сдался, оставит ваш запрос выполняться, и под нагрузкой они накопятся, пока пул соединений не исчерпается.
Шаг 2 — слой базы данных. database/sql из стандартной библиотеки — это пул соединений, а не ORM. Вы пишете db.QueryRowContext(ctx, ...) и сканируете в переменные. Это больше кода, чем ORM, и именно поэтому сервисы на Go остаются предсказуемыми: нет ленивой загрузки, нет скрытых N+1, нет магии. Явно задайте db.SetMaxOpenConns — по умолчанию значение не ограничено, что является распространённым способом уронить Postgres под нагрузкой.
Шаг 3 — фоновая работа. Предположим, вы хотите записывать аналитику кликов, не блокируя редирект. Соблазнительный ход — go recordClick(code). Теперь каждый редирект порождает горутину без ограничений. При всплеске трафика горутины накапливаются быстрее, чем разгружаются, и процесс либо исчерпывает память, либо планировщик тратит больше времени на переключения, чем на работу. Решение — ограниченный пул воркеров: фиксированное число горутин, читающих из буферизованного канала, и неблокирующая отправка (select с веткой default), которая отбрасывает или логирует, когда очередь заполнена.
Шаг 4 — корректное завершение. http.Server.Shutdown(ctx) перестаёт принимать новые соединения и ждёт завершения текущих запросов. Без него деплой убивает запросы на полпути. С ним вы получаете чистое завершение — но только если ваши обработчики уважают контекст, который вы передали вниз.
Шаг 5 — тесты. net/http/httptest позволяет тестировать обработчики без реальной сети. Табличные тесты — идиома Go: срез случаев, цикл, t.Run для каждого. Именно здесь многие самоучки в Go спотыкаются — обработчик работает, но никто никогда не тестировал крайний случай, когда база данных возвращает sql.ErrNoRows.
GODEBUG=gctrace=1 и наблюдайте за циклами GC. Если GC запускается чаще нескольких раз в секунду под нагрузкой, ваша проблема — аллокации, а не горутины. Сначала исправьте аллокации — планировщик обычно в порядке.
Где модель конкурентности Go ломается
Модель хороша, но у неё есть специфические режимы отказа. Это не ошибки новичков; это места, где сама схема даёт сбой.
Утечки горутин
Горутина, заблокированная на отправке в канал без читателя, никогда не завершится. Рантайм её не убивает; она просто сидит там, удерживая свой стек и все захваченные ссылки. В долгоживущем сервисе утечка нескольких горутин на запрос накапливается. Как заметить рано: экспортируйте runtime.NumGoroutine() как метрику и оповещайте о растущем тренде, а не об абсолютном числе. Если уже слишком поздно: снимите профиль горутин с помощью pprof.Lookup("goroutine").WriteTo(w, 1) — 1 даёт полные трассировки стека, и вы увидите точную строку, где они застряли.
Дедлоки каналов
Небуферизованные каналы требуют готовности обеих сторон. Классический паттерн: воркер отправляет результат в канал, потребитель уже вернулся из-за таймаута, и воркер блокируется навсегда. select с веткой case <-ctx.Done(): — стандартная защита. Более тонкая версия: цикл for range ch, который никогда не завершается, потому что никто не закрывает канал. Правило большого пальца: отправитель закрывает канал, и только отправитель.
Паузы GC и давление аллокаций
Сборщик мусора Go работает конкурентно и с низкими паузами — обычно менее миллисекунды — но он не бесплатен. Каждая аллокация добавляет работу фазе маркировки. Обработчик, который строит строки с помощью + в цикле или преобразует []byte в string на каждом запросе, генерирует мусор быстрее, чем сборщик успевает его убирать. Симптом — не длинная пауза, а CPU, потраченный на GC вместо обслуживания. Решение: strings.Builder, sync.Pool для горячих буферов и профиль аллокаций pprof, чтобы найти главных аллокаторов.
cgo и планировщик
Вызов cgo блокирует поток ОС, на котором выполняется, и рантайм не может его вытеснить. Вызов cgo, занимающий 10 мс, — нормально; занимающий 10 секунд может заморить P на это время, и если таких вызовов выполняется одновременно достаточно много, у планировщика заканчиваются M. Обходной путь — runtime.LockOSThread и выделенный пул, или — лучше — полный отказ от cgo на горячем пути.
context в вызове базы данных.
Действительно ли вашему сервису нужен Go?
Go — не правильный ответ для каждого бэкенда. Вот контрольный список, который реально используют практики, с явно указанными компромиссами.
| Ситуация | Go хорошо подходит | Go, вероятно, избыточен |
|---|---|---|
| Профиль конкурентности | Тысячи долгоживущих соединений, fan-out/fan-in | Несколько CRUD-эндпоинтов за балансировщиком |
| Бюджет задержки | p99 ниже десятков миллисекунд, паузы GC важны | p99 в сотни мс приемлем |
| Команда | Готова писать явный SQL и явную обработку ошибок | Хочет ORM и фреймворк, который делает обвязку |
| Развёртывание | Один статический бинарник, маленький образ контейнера | Существующая платформа уже хорошо работает на Node или Python |
| Экосистема | Стандартная библиотека покрывает HTTP, JSON, криптографию, TLS | Сильная зависимость от плагинной экосистемы конкретного фреймворка |
Если ваш сервис — CRUD API с умеренным трафиком, Node или Python доставят вас туда быстрее и с большим пулом кандидатов. Go окупается, когда модель конкурентности — это суть: прокси, шлюзы, стриминг, сервисы с высоким fanout, CLI-инструменты, которые нужно поставлять одним бинарником.
Полезное неочевидное наблюдение: стандартная библиотека Go — это и есть фреймворк. net/http с ServeMux (начиная с Go 1.22 поддерживает шаблоны методов и параметров пути, такие как POST /shorten/{code}) покрывает маршрутизацию, middleware через обёртку обработчиков, TLS и корректное завершение. К стороннему роутеру прибегают, когда нужно что-то специфическое, а не по умолчанию. То же верно для encoding/json, crypto/tls и database/sql. Компромисс: вы пишете больше шаблонного кода и сами принимаете решения, которые принял бы фреймворк.
Пути обучения и их реальная цена
Есть три реалистичных пути в Go, и они различаются не столько ценой, сколько циклами обратной связи.
Самостоятельное изучение по официальному туру и документации. Бесплатно, и документация действительно хороша. Цена — время, потраченное на неверные повороты: вы напишете утечку горутин, не заметите её неделю, и никто не скажет вам, что означает профиль. Этот путь работает для тех, у кого уже есть ментальная модель бэкенда из другого языка.
Структурированный курс. Платно, но ценность в последовательности и обратной связи. Golang с нуля построен именно вокруг этой прогрессии — синтаксис, затем система типов, затем горутины и каналы, затем HTTP-серверы и клиенты, затем базы данных и тесты — с проектами (REST API, CLI-инструменты, веб-сервисы), которые заставляют вас столкнуться с вышеописанными режимами отказа в контролируемой среде, а не в продакшене. Уроки текстовые и адаптированы к вашему уровню, а AI-чат — это место, где можно попросить обратную связь по проектному решению или фрагменту кода, чего самообучение дать не может.
Практика на реальной кодовой базе. Самый быстрый путь, если он доступен: присоединитесь к команде, которая уже использует Go, читайте их код и копируйте их идиомы. Цена — возможность: сначала нужна работа, и вы унаследуете ошибки, которые команда уже совершила.
flag и os, затем HTTP-сервер с net/http и encoding/json, затем добавьте базу данных с database/sql, и только потом вводите горутины и каналы. Конкурентность без работающего сервиса, к которому её можно прикрепить, — это теория.
Кому это подходит, а кому нет
Go вознаграждает тех, кто любит явный код: возврат ошибок вместо исключений, SQL вместо ORM, небольшую стандартную библиотеку вместо фреймворка. Если вам это нравится, язык покажется облегчением. Если вы хотите, чтобы фреймворк принимал решения за вас, Go покажется вам многословным и упрямым.
Он подходит бэкенд-разработчикам, которым нужно рассуждать о конкурентности, DevOps и платформенным инженерам, которые хотят поставлять один бинарник, и всем, кто строит сервисы, где стандартной библиотеки достаточно. Он меньше подходит людям, которым нужна огромная экосистема готовых плагинов, или командам, чей существующий стек уже работает и чьё узкое место не в рантайме.
Если вы никогда не писали бэкенд-сервис ни на одном языке, начните с этого — Go не научит вас HTTP или SQL, он просто даст быстрый способ ошибиться в них.
Курс проходит по планировщику, семантике каналов и слоям HTTP и баз данных с запускаемыми примерами и даёт вам место, где можно спросить, выдержит ли ваш дизайн, прежде чем он попадёт в продакшен. Если это тот пробел, который вы пытаетесь закрыть, начните с Golang с нуля.
Начать можно здесь: Golang с нуля.