Собрал вопросы, которые чаще всего задают Go-разработчикам на собесах уровня junior и middle. К каждому даю короткий ответ, с которым не стыдно выйти к интервьюеру, и подсказываю, где обычно сыпятся
Горутиной управляет рантайм Go, а не операционная система. Стек у неё маленький, от 2 КБ, и растёт по мере надобности. У потока ОС стек фиксированный и занимает мегабайты
Горутины переключаются без системного вызова, поэтому это намного дешевле, чем переключать потоки. Отсюда и сотни тысяч горутин в одном процессе: рантайм раскладывает их на несколько потоков ОС. Такую схему называют M:N
G это горутина, M это поток ОС, P это логический процессор со своей очередью горутин. Чтобы выполнять Go-код, потоку нужно захватить P. Сколько будет P, задаёт GOMAXPROCS. По умолчанию это число логических CPU. С Go 1.25 в Linux рантайм ещё смотрит на CPU-лимит cgroup, в Kubernetes это limits пода, requests он не учитывает
Новая горутина попадает в локальную очередь своего P, туда помещается 256 штук, остальные уходят в глобальную очередь. Когда у P заканчивается работа, он заглядывает в глобальную очередь и в сетевой поллер. Если и там пусто, забирает половину горутин у случайного соседа. Это называется work stealing
Если горутина застряла в блокирующем системном вызове, рантайм забирает у этого потока P и отдаёт другому, чтобы остальные горутины не стояли. А с Go 1.14 планировщик умеет прерывать горутину сигналом, так что даже бесконечный цикл без вызовов функций не займёт P навсегда
Это горутина, которая заблокировалась навсегда. Например, пишет в канал, который уже никто не читает. Она держит свой стек и всё, на что ссылается, и память понемногу растёт, пока сервис не упадёт
Чтобы такого не было, у каждой горутины должен быть понятный способ завершиться: через context, закрытие канала или буфер под нужное число отправок. Искать утечки помогают метрика runtime.NumGoroutine(), профиль горутин в pprof и библиотека goleak в тестах. В Go 1.26 появился экспериментальный профиль goroutineleak, он находит такие горутины сам
Go
// Утечка: если ctx отменится раньше, ch никто не прочитает,
// и горутина навсегда зависнет на отправке
func fetch(ctx context.Context) (string, error) {
ch := make(chan string) // исправление: make(chan string, 1)
go func() { ch <- slowCall() }()
select {
case res := <-ch:
return res, nil
case <-ctx.Done():
return "", ctx.Err()
}
}
04Что выведет цикл с горутинами, захватывающими переменную цикла?
До Go 1.22 переменная цикла была одна на все итерации. Все горутины читали одну и ту же переменную, поэтому обычно печаталось 3, 3, 3, и это ещё и гонка данных. С Go 1.22 у каждой итерации своя переменная, и код ниже выведет 0, 1, 2 в любом порядке
Есть тонкость, которую любят спрашивать. Новое поведение включает версия языка в go.mod (go 1.22 и выше), а не версия установленного Go. И ещё: без WaitGroup программа может завершиться раньше горутин и напечатать только часть чисел или вообще ничего
Go
var wg sync.WaitGroup
for i := 0; i < 3; i++ {
wg.Add(1)
go func() {
defer wg.Done()
fmt.Println(i) // при go 1.22+ в go.mod: 0, 1, 2
}()
}
wg.Wait()
Тема 02·5 вопросов
Каналы и синхронизация
05Как устроен канал и чем буферизованный отличается от небуферизованного?
Внутри рантайма канал это структура hchan. В ней кольцевой буфер, мьютекс и две очереди ждущих горутин: отправители в sendq и получатели в recvq
Небуферизованный канал синхронный. Отправитель ждёт, пока получатель заберёт значение, и в этот момент две горутины встречаются. В буферизованный можно писать без ожидания, пока буфер не заполнится, а читать из него, пока он не опустеет
06Что происходит при работе с закрытым и nil-каналом?
Из закрытого канала сначала дочитываются значения, которые остались в буфере. Дальше чтение сразу возвращает нулевое значение и ok == false. Запись в закрытый канал и повторный close приводят к панике
С nil-каналом чтение и запись блокируются навсегда, а close вызывает панику. Это поведение используют специально: в select каналу присваивают nil, чтобы выключить ветку
Закрывать канал должен тот, кто в него пишет, и только когда писать больше никто не будет
select ждёт, пока сможет выполниться одна из операций с каналами. Если готовы сразу несколько, выбирается случайная, чтобы ни одна ветка не голодала. Если есть default, select не блокируется
На практике через него делают таймауты (time.After или ctx.Done()), неблокирующую отправку и чтение из нескольких источников сразу
Канал нужен, когда данные переходят от одной горутины к другой или горутины надо скоординировать: конвейеры, worker pool, сигнал завершения. Мьютекс нужен, когда несколько горутин работают с общим состоянием, например с кэшем или счётчиком
Если чтений намного больше, чем записей, подойдёт sync.RWMutex. Простой счётчик быстрее сделать на sync/atomic. А гонки данных ищут флагом -race в тестах
09Как ограничить число одновременно работающих горутин?
Классика это worker pool: N горутин читают задачи из общего канала. Ещё можно сделать семафор на буферизованном канале
В реальном коде удобнее всего errgroup из golang.org/x/sync. Он ограничивает параллелизм через SetLimit, возвращает первую ошибку и отменяет контекст у остальных задач. Если ни лимит, ни ошибки не нужны, с Go 1.25 есть короткий sync.WaitGroup.Go
Go
g, ctx := errgroup.WithContext(ctx)
g.SetLimit(10) // не больше 10 запросов одновременно
for _, id := range ids {
g.Go(func() error {
return process(ctx, id)
})
}
if err := g.Wait(); err != nil {
return err
}
Мок-собеседование
Знать ответы мало, надо уверенно отвечать вслух
На менторстве проводим мок-собеседования по каждому блоку и доводим до оффера на Go. Основную часть оплаты вносишь после трудоустройства
Слайс состоит из трёх полей: указатель на массив, длина len и ёмкость cap. Если при append места хватает, элемент записывается в тот же массив. Если не хватает, Go выделяет новый массив побольше и копирует туда данные
Маленькие слайсы растут примерно вдвое. После 256 элементов рост постепенно замедляется примерно до 1,25 раза. Главная ловушка в том, что слайсы, сделанные из одного массива, видят изменения друг друга, пока один из них не переедет в новый массив
Go
a := []int{1, 2, 3, 4}
b := a[:2] // len=2, cap=4, общий массив с a
b = append(b, 9) // место есть, перезаписали a[2]
fmt.Println(a) // [1 2 9 4]
c := a[:2:2] // full slice expression: cap=2
c = append(c, 7) // новый массив, a не меняется
Map это хеш-таблица. До Go 1.24 она состояла из бакетов по 8 элементов и цепочек overflow-бакетов. Когда в среднем на бакет приходилось больше 6,5 элемента, таблица увеличивалась вдвое, а данные переносились постепенно, во время следующих записей
С Go 1.24 map сделана на Swiss Tables. Слоты объединены в группы по 8, у каждого есть байт-метка, и при поиске сравниваются метки сразу всей группы. Работает быстрее и тратит меньше памяти
В любой версии порядок обхода случайный, а взять адрес элемента нельзя: &m[k] не скомпилируется. Если одна горутина пишет в map, а другая в это время пишет или читает, программа падает с fatal error, и recover его не поймает. Для конкурентного доступа нужен мьютекс или sync.Map
12Что такое строка в Go, и чем byte отличается от rune?
Строка в Go это неизменяемый набор байт: указатель и длина. Поэтому len(s) считает байты, а не символы. byte это uint8, а rune это int32, в нём хранится символ Unicode
for range по строке идёт по символам и сам декодирует UTF-8, а s[i] возвращает отдельный байт. Строки в цикле лучше собирать через strings.Builder, а не через +, иначе на каждой итерации будет создаваться новая строка
Интерфейс хранит две вещи: тип и указатель на данные. Он равен nil, только если пусты оба. Когда ты кладёшь в него nil-указатель конкретного типа, тип уже заполнен, поэтому err != nil срабатывает
Go
type MyErr struct{}
func (*MyErr) Error() string { return "boom" }
func do() error {
var e *MyErr = nil
return e // внутри: тип *MyErr, данные nil
}
fmt.Println(do() == nil) // false
14Как правильно работать с ошибками: errors.Is, errors.As, wrapping?
Ошибку оборачивают с контекстом через fmt.Errorf("load user %d: %w", id, err). Глагол %w сохраняет исходную ошибку внутри. errors.Is проверяет, есть ли в этой цепочке конкретная ошибка, например sql.ErrNoRows. errors.As достаёт из цепочки ошибку нужного типа, чтобы прочитать её поля
Паника нужна для ситуаций, после которых работать дальше нельзя, и для ошибок программиста. Обычные бизнес-ошибки возвращают как error
Отложенные вызовы срабатывают при выходе из функции в обратном порядке. Аргументы вычисляются сразу, в строке с defer, а не в момент вызова. Отложенная функция может поменять именованные возвращаемые значения, так часто оборачивают ошибку
recover работает только внутри отложенной функции и только в той же горутине. Паника в другой горутине уронит весь процесс. И частая ошибка: defer в цикле. Ресурсы освободятся только при выходе из функции, а не на каждой итерации
16Что дают дженерики и когда их стоит использовать?
Дженерики появились в Go 1.18. С ними можно писать функции и типы с параметрами типа, например func Map[T, R any](s []T, f func(T) R) []R. Ограничение comparable разрешает сравнение через ==, а набор типов вроде ~int | ~float64 разрешает арифметику
Они хорошо подходят для контейнеров и утилит над коллекциями. Если поведение отличается от типа к типу, лучше по-прежнему использовать интерфейсы
В Go конкурентный трёхцветный mark-and-sweep. Большую часть работы он делает параллельно с программой, а паузы, когда всё останавливается, обычно меньше миллисекунды. Барьер записи следит, чтобы программа не спрятала объект от разметки, пока идёт сборка. Поколений нет, объекты в памяти не перемещаются
С Go 1.26 по умолчанию работает новый сборщик Green Tea. Принцип у него тот же, но мелкие объекты он размечает и сканирует группами, по страницам памяти. По оценке команды Go это снижает накладные расходы на GC на 10–40%
Как часто запускать сборку, решает GOGC. По умолчанию это 100: новый цикл начнётся, когда куча вырастет вдвое относительно живых данных. А GOMEMLIMIT (с Go 1.19) задаёт мягкий лимит памяти, и в контейнерах с ограничением по RAM его стоит выставлять
Компилятор решает, где хранить переменную: на стеке горутины, где это дёшево и чистится само, или в куче, где за ней придётся следить сборщику. Переменная уходит в кучу, если компилятор не может доказать, что ссылка на неё не переживёт функцию. Например, функция возвращает указатель, или значение сохранили в интерфейс или замыкание, или размер слишком большой либо заранее неизвестен
Решения компилятора можно посмотреть через go build -gcflags=-m. С этого обычно начинают оптимизацию аллокаций, вместе с pprof и бенчмарками с флагом -benchmem
Через context по цепочке вызовов передают отмену, дедлайн и данные запроса вроде request id. Его передают первым аргументом и не хранят в структурах
WithTimeout и WithCancel возвращают функцию cancel, и её обязательно нужно вызвать, обычно через defer. Иначе дочерний контекст (а у WithTimeout ещё и таймер) провисит в памяти, пока не отменят родителя или не наступит дедлайн. go vet такое ловит. И через Value не стоит передавать обязательные параметры, это скрытая зависимость
Ловим SIGINT и SIGTERM через signal.NotifyContext и вызываем server.Shutdown(ctx) с таймаутом. Сервер перестаёт принимать новые соединения и дожидается текущих запросов. После этого по очереди закрываем консьюмеры, пул БД и остальное
В Kubernetes таймаут должен укладываться в terminationGracePeriodSeconds. Перед Shutdown стоит подождать несколько секунд или добавить preStop-хук. Под убирают из Service не мгновенно, и какое-то время на него ещё приходит трафик
Тема 07·4 вопроса
Базы данных и микросервисы
21Какие уровни изоляции транзакций есть в PostgreSQL?
По стандарту их четыре: Read Uncommitted, Read Committed, Repeatable Read и Serializable. В PostgreSQL по умолчанию Read Committed. Read Uncommitted там работает так же, как Read Committed, грязного чтения в Postgres нет
Repeatable Read работает со снимком данных и защищает от неповторяемого чтения и фантомов, но пропускает аномалию write skew. Serializable закрывает и её. На обоих этих уровнях Postgres может откатить транзакцию с ошибкой сериализации (код 40001), так что приложение должно уметь повторить её
22Как в Go работает пул соединений с базой данных?
*sql.DB это не одно соединение, а пул, с которым можно работать из разных горутин. Его создают один раз на всё приложение и настраивают через SetMaxOpenConns, SetMaxIdleConns и SetConnMaxLifetime
Если забыть закрыть rows или завершить транзакцию, соединение не вернётся в пул, и под нагрузкой сервис упрётся в лимит. С PostgreSQL часто работают напрямую через pgx и pgxpool: так быстрее и есть поддержка типов, специфичных для Postgres
23Как гарантировать обработку сообщения из Kafka ровно один раз?
В Kafka есть встроенный exactly-once: идемпотентный продюсер и транзакции с isolation.level=read_committed. Но он работает, только если ты читаешь из Kafka и пишешь результат тоже в Kafka. Как только результат уходит во внешнюю базу, нужен другой подход
Порядок сообщений Kafka гарантирует только внутри одной партиции, а партиции делятся между консьюмерами группы. Для внешней базы обычно делают доставку at-least-once, то есть коммитят оффсет после обработки, а сам обработчик делают идемпотентным. Например, проверяют ключ сообщения или ставят уникальный индекс в базе
Если нужно в одной операции записать данные в базу и отправить событие, используют transactional outbox. Событие пишут в таблицу outbox в той же транзакции, а отдельный процесс потом отправляет его в Kafka
gRPC работает поверх HTTP/2 и передаёт данные в бинарном формате Protocol Buffers. Контракт описывают в .proto-файле и генерируют из него клиент и сервер. За счёт этого есть строгая типизация, сообщения меньше, а ещё мультиплексирование и стриминг в обе стороны
REST с JSON проще отлаживать и вызывать из браузера. Поэтому часто наружу отдают REST, а внутренние сервисы общаются между собой по gRPC
Это только часть вопросов
Полная подготовка к собеседованию на менторстве
Здесь собраны самые частые вопросы. На менторстве по Go у тебя будет вся база и личная подготовка до оффера
Детальная база знанийТемы собеседования по Golang с разбором, примерами и уточняющими вопросами интервьюеров
Roadmap из 5 блоковПлан подготовки под твой уровень и цель, а после каждого блока мок-собеседование
350+ реальных собеседованийЗаписи собесов из реальных компаний: что спрашивают и как отвечают те, кто получил оффер