Context, GMP и практика: CRUD, баг-хантинг
Финальная часть конспекта по конкурентности — context.Context, планировщик GMP и сборщик мусора, а дальше два практических формата, которые реально дают на секции по Go: собрать CRUD-сервис и найти баг в чужом коде. Живое написание worker pool/pipeline/балансировщика руками — в предыдущей главе «Паттерны конкурентности», сразу после кода этих же паттернов.
context.Context
Задача, которую решает context.Context: у тебя есть цепочка вызовов (handler → service → repository → внешний HTTP-запрос), и в какой-то момент снаружи решают, что операцию пора остановить — истёк таймаут, клиент отменил запрос, пользователь закрыл вкладку. Как об этом узнает код, который сейчас глубоко внутри и ждёт ответа от чужого сервиса? Без явного механизма — никак: он будет ждать до победного конца, даже если результат уже никому не нужен.
context.Context передаётся первым параметром в каждую функцию по цепочке именно для этого — явный, видимый в сигнатуре канал сигнала "пора остановиться".
Интерфейс всего с четырьмя методами: Done() — канал, который закрывается в момент отмены/истечения таймаута (закрытый канал не блокирует чтение, поэтому его обычно слушают через select); Err() — до отмены возвращает nil, после — причину (context.Canceled, если кто-то явно отменил, или context.DeadlineExceeded, если истекло время); Deadline() — момент дедлайна, вызывают реже, обычно чтобы заранее решить, стоит ли вообще начинать дорогую операцию, если до дедлайна осталось слишком мало времени; Value(key) — для СКВОЗНЫХ метаданных вроде request-ID или trace-ID, а не для параметров, которые нужны самой бизнес-логике.
WithTimeout(parent, d)/WithDeadline(parent, t) — как WithCancel, но контекст отменяется САМ по истечении времени, необязательно ждать ручного вызова. WithCancel создаёт дочерний context от родительского: отмена родителя каскадно закрывает Done() у ВСЕХ его потомков сразу, на любую глубину — целое дерево запущенных по цепочке горутин обрывается одним вызовом cancel().
Background() — настоящий корень дерева без родителя, с него всё начинается; TODO() — временная заглушка на месте будущего настоящего контекста при рефакторинге, функционально то же самое, что Background(). У каждого входящего HTTP-запроса в Go уже есть готовый r.Context(), привязанный к жизни этого конкретного запроса — отменяется автоматически, если клиент разорвал соединение.
Вот почему забытый таймаут — не абстрактная теория, а реальная угроза продакшену: без него вызов упавшего/зависшего внешнего сервиса виснет НАВСЕГДА, а не просто "долго".
Функция внутри готова "провисеть" две секунды, но благодаря ctx.Done() внутри select вызывающий код освобождается уже через ~100мс с понятной ошибкой context deadline exceeded, вместо того чтобы держать горутину, соединение и память все две секунды. При частых вызовах без такого таймаута это накопительный эффект: соединения/горутины/память постепенно исчерпываются, пока процесс не упадёт или не начнёт отказывать всем подряд.
Отдельная деталь, которую легко упустить: cancel() из WithTimeout/WithCancel нужно вызывать ВСЕГДА через defer, даже если операция успела завершиться сама, раньше таймаута — иначе внутренний таймер продолжает существовать в памяти до истечения полного срока, а не освобождается немедленно.
Если тема контекста плохо знакома целиком — есть отдельный подробный гайд с нуля в разделе «Изучение», который специально объясняет её ещё медленнее и проще, чем этот конспект.
Планировщик (GMP) и сборщик мусора
G (Goroutine) — сама горутина, лёгкая единица работы. M (Machine) — реальный поток операционной системы, именно он физически исполняет инструкции процессора. P (Processor) — логическая абстракция планировщика со своей очередью задач; число P равно GOMAXPROCS, и именно P (а не M и не количество горутин) определяет реальную степень ПАРАЛЛЕЛИЗМА — сколько горутин могут выполняться в буквальном смысле одновременно на разных ядрах.
Горутин в программе может быть на порядки больше, чем M и P — рантайм сам динамически перераспределяет их между доступными P, включая work stealing (свободный P «ворует» горутины из очереди занятого P, чтобы не простаивать). Разницу между конкурентностью и параллелизмом проще всего увидеть не на словах, а на времени выполнения одной и той же тяжёлой работы при разном GOMAXPROCS:
Обе версии запускают одни и те же две горутины с одинаковой чисто вычислительной (CPU-bound) работой — конкурентная структура программы не меняется ни на строчку. Но при GOMAXPROCS=1 обе горутины по очереди делят одно ядро (на локальной 22-ядерной машине это дало 822мс), а при GOMAXPROCS=2 они реально выполняются одновременно на двух ядрах (490мс) — почти двукратная разница на одном и том же коде.
Именно это и означает фраза «конкурентность ≠ параллелизм»: конкурентность — свойство СТРУКТУРЫ программы (в коде независимые горутины были и остаются независимыми), параллелизм — свойство ИСПОЛНЕНИЯ (реально ли они работают одновременно на железе прямо сейчас). Программа с GOMAXPROCS=1 может быть сколь угодно конкурентной, но никогда не будет параллельной.
Отсюда практический вывод: увеличение GOMAXPROCS не ускоряет IO-bound код (который в основном ждёт сеть/диск, а не считает) — там узкое место вообще не в количестве доступных "слотов" для параллельного счёта.
Сборщик мусора Go — конкурентный трёхцветный mark-and-sweep, который работает параллельно с самой программой, а не останавливает её на всё время сборки. Короткие STW-паузы всё же случаются (обычно суб-миллисекундные), но подавляющая часть работы GC вынесена в фазу, которая идёт конкурентно с рабочим кодом, а не блокирует его целиком.
REST CRUD-сервис на net/http с graceful shutdown
Прямое требование к Backend-стажёру — уметь написать слоистый CRUD на стандартной библиотеке, без фреймворков вроде Gin/Echo. Здесь фокус не на самом HTTP как таковом, а на двух вещах вокруг него: правильной архитектуре слоёв и корректном завершении процесса.
Архитектура handler → service → repository: handler отвечает ТОЛЬКО за HTTP-часть (разобрать запрос, вызвать сервис, закодировать ответ), бизнес-логика живёт в service отдельно от протокола, а repository — единственное место в программе, которое вообще знает о способе хранения данных. Смысл разделения не в «красоте» — service можно протестировать юнит-тестами вообще без поднятия HTTP-сервера, просто вызывая его методы напрямую.
type item struct {
ID int `json:"id"`
Name string `json:"name"`
}
// repository — единственное место, знающее о хранилище
type repository struct{ items []item }
func (r *repository) List() []item { return r.items }
// service — бизнес-логика, ничего не знает про HTTP
type service struct{ repo *repository }
func (s *service) ListItems() []item { return s.repo.List() }
// handler — отвечает ТОЛЬКО за HTTP: разбор запроса, вызов сервиса, кодирование ответа
type handler struct{ svc *service }
func (h *handler) listItems(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(h.svc.ListItems())
}Graceful shutdown — вторая половина задачи, про то, что происходит, когда процесс просят остановиться (деплой новой версии, kill, Ctrl+C). Наивное завершение (просто дать процессу умереть) обрывает уже начатые запросы клиентов на середине.
Правильная последовательность: signal.NotifyContext создаёт context, который закрывается в момент получения SIGINT/SIGTERM; сервер запускается в ОТДЕЛЬНОЙ горутине — иначе ListenAndServe, который блокирует вызывающий код навсегда, попросту не даст программе дойти до кода, слушающего сигнал; при получении сигнала вызывается srv.Shutdown(shutdownCtx) с отдельным, более коротким таймаутом — сервер перестаёт принимать НОВЫЕ соединения, но даёт время закончиться уже начатым запросам; если кто-то не успел уложиться в этот таймаут, Shutdown вернёт context.DeadlineExceeded и принудительно разорвёт то, что осталось.
srv := &http.Server{Addr: ":8080", Handler: mux}
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
go func() {
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatal(err)
}
}()
<-ctx.Done() // блокируемся здесь, пока не придёт SIGINT/SIGTERM
stop()
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
log.Println("принудительное закрытие:", err)
}Это не абстрактный псевдокод — оба фрагмента выше собраны в одну программу и реально проверены go build перед публикацией; в песочнице на play.golang.org полноценный слушающий сервер не запустить (там нет доступа к сети), поэтому здесь обычные код-блоки, а не playground, но сама программа компилируется и работает как обычный Go-бинарник.
Частая ошибка джуна: смешать всю логику в одном хендлере без слоёв ("и так же работает") — тогда бизнес-логику невозможно протестировать юнит-тестами без реального поднятого сервера, любое изменение приходится проверять руками через curl. Формат интервью здесь обычно короткий: 15–20 минут на 2–3 эндпоинта, оценивают именно структуру и наличие recovery-middleware (чтобы паника в одном хендлере не роняла весь процесс), а не объём написанного кода.
Найди баг в чужом коде — формат Wildberries/Avito
Три классических сознательно сломанных фрагмента, которые дают на такой секции. Разберём подробно тот, который путают чаще всего: Lock() без Unlock().
func withdraw(mu *sync.Mutex, balance *int, amount int) {
mu.Lock()
fmt.Println("захватили лок, но забыли Unlock")
// Unlock() отсутствует — лок остаётся захваченным навсегда
}Кандидата часто тянет назвать это «гонкой данных» — на самом деле это DEADLOCK, принципиально другой класс проблемы: мьютекс захвачен и никогда не освобождён, поэтому ЛЮБОЙ следующий Lock() на этом же мьютексе (в этой же или любой другой горутине) зависнет навсегда, а не даст неверный результат.
Второй и третий классические фрагменты того же формата: (2) захват переменной цикла в замыкании вместе с time.Sleep() вместо честного WaitGroup — на современных версиях Go сама переменная цикла уже не расползается между горутинами (см. главу про горутины и каналы), но time.Sleep() вместо WaitGroup всё равно остаётся угадыванием тайминга, а не гарантией; (3) продюсер не закрывает канал — тогда range в консьюмере зависает навсегда в ожидании ещё одного значения или сигнала о закрытии, который никогда не придёт, и любой код, ждущий <-done дальше по цепочке, виснет вместе с ним.
Главная ошибка при разборе чужого кода под давлением времени: остановиться на ПЕРВОМ найденном баге и не проверить остальную часть фрагмента — в реальных заданиях нередко в одном и том же куске кода спрятано больше одной проблемы, и интервьюер именно этого и ждёт.
Три типичные ошибки джуниора — проговорить вслух
Концентрат для последних 5 минут перед интервью, если спросят «какие ошибки вы видели у младших разработчиков» — здесь уже не нужен отдельный код на каждый пункт, это финальный список для проговаривания вслух, а не новая тема для разбора:
- Забытый
context.WithTimeoutна внешнем вызове — риск зависания навсегда (разобрано выше). - Горутина без гарантированного способа завершиться — утечка.
- Конкурентный доступ к общей структуре без синхронизации — race condition, а для обычной
mapконкретно — не тихая гонка, а НЕМЕДЛЕННАЯ паника рантаймаfatal error: concurrent map writes(в отличие от гонки на простом int-счётчике, где часть инкрементов просто молча теряется).