Вопросы на собеседовании по Golang

Собрал вопросы, которые чаще всего задают Go-разработчикам на собесах уровня junior и middle. К каждому даю короткий ответ, с которым не стыдно выйти к интервьюеру, и подсказываю, где обычно сыпятся

Дима — backend-менторДимаSenior Backend, провожу собеседования

24 вопроса7 тем~13 мин чтения

Тема 014 вопроса

Горутины и планировщик

01Чем горутина отличается от потока ОС?

Горутиной управляет рантайм Go, а не операционная система. Стек у неё маленький, от 2 КБ, и растёт по мере надобности. У потока ОС стек фиксированный и занимает мегабайты

Горутины переключаются без системного вызова, поэтому это намного дешевле, чем переключать потоки. Отсюда и сотни тысяч горутин в одном процессе: рантайм раскладывает их на несколько потоков ОС. Такую схему называют M:N

02Как устроен планировщик Go (модель GMP)?

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 навсегда

03Что такое утечка горутин и как её найти?

Это горутина, которая заблокировалась навсегда. Например, пишет в канал, который уже никто не читает. Она держит свой стек и всё, на что ссылается, и память понемногу растёт, пока сервис не упадёт

Чтобы такого не было, у каждой горутины должен быть понятный способ завершиться: через 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()

Тема 025 вопросов

Каналы и синхронизация

05Как устроен канал и чем буферизованный отличается от небуферизованного?

Внутри рантайма канал это структура hchan. В ней кольцевой буфер, мьютекс и две очереди ждущих горутин: отправители в sendq и получатели в recvq

Небуферизованный канал синхронный. Отправитель ждёт, пока получатель заберёт значение, и в этот момент две горутины встречаются. В буферизованный можно писать без ожидания, пока буфер не заполнится, а читать из него, пока он не опустеет

06Что происходит при работе с закрытым и nil-каналом?

Из закрытого канала сначала дочитываются значения, которые остались в буфере. Дальше чтение сразу возвращает нулевое значение и ok == false. Запись в закрытый канал и повторный close приводят к панике

С nil-каналом чтение и запись блокируются навсегда, а close вызывает панику. Это поведение используют специально: в select каналу присваивают nil, чтобы выключить ветку

Закрывать канал должен тот, кто в него пишет, и только когда писать больше никто не будет

07Как работает select?

select ждёт, пока сможет выполниться одна из операций с каналами. Если готовы сразу несколько, выбирается случайная, чтобы ни одна ветка не голодала. Если есть default, select не блокируется

На практике через него делают таймауты (time.After или ctx.Done()), неблокирующую отправку и чтение из нескольких источников сразу

08Когда использовать мьютекс, а когда канал?

Канал нужен, когда данные переходят от одной горутины к другой или горутины надо скоординировать: конвейеры, 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. Основную часть оплаты вносишь после трудоустройства

Тема 033 вопроса

Слайсы, map и строки

10Как устроен слайс и что происходит при append?

Слайс состоит из трёх полей: указатель на массив, длина 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 не меняется

11Как устроена map в Go?

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, а не через +, иначе на каждой итерации будет создаваться новая строка

Тема 044 вопроса

Интерфейсы, ошибки и defer

13Почему интерфейс с nil-указателем не равен nil?

Интерфейс хранит две вещи: тип и указатель на данные. Он равен 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

15Как работает defer и что можно сломать?

Отложенные вызовы срабатывают при выходе из функции в обратном порядке. Аргументы вычисляются сразу, в строке с defer, а не в момент вызова. Отложенная функция может поменять именованные возвращаемые значения, так часто оборачивают ошибку

recover работает только внутри отложенной функции и только в той же горутине. Паника в другой горутине уронит весь процесс. И частая ошибка: defer в цикле. Ресурсы освободятся только при выходе из функции, а не на каждой итерации

16Что дают дженерики и когда их стоит использовать?

Дженерики появились в Go 1.18. С ними можно писать функции и типы с параметрами типа, например func Map[T, R any](s []T, f func(T) R) []R. Ограничение comparable разрешает сравнение через ==, а набор типов вроде ~int | ~float64 разрешает арифметику

Они хорошо подходят для контейнеров и утилит над коллекциями. Если поведение отличается от типа к типу, лучше по-прежнему использовать интерфейсы

Тема 052 вопроса

Память и сборщик мусора

17Как работает сборщик мусора в Go?

В Go конкурентный трёхцветный mark-and-sweep. Большую часть работы он делает параллельно с программой, а паузы, когда всё останавливается, обычно меньше миллисекунды. Барьер записи следит, чтобы программа не спрятала объект от разметки, пока идёт сборка. Поколений нет, объекты в памяти не перемещаются

С Go 1.26 по умолчанию работает новый сборщик Green Tea. Принцип у него тот же, но мелкие объекты он размечает и сканирует группами, по страницам памяти. По оценке команды Go это снижает накладные расходы на GC на 10–40%

Как часто запускать сборку, решает GOGC. По умолчанию это 100: новый цикл начнётся, когда куча вырастет вдвое относительно живых данных. А GOMEMLIMIT (с Go 1.19) задаёт мягкий лимит памяти, и в контейнерах с ограничением по RAM его стоит выставлять

18Что такое escape analysis?

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

Решения компилятора можно посмотреть через go build -gcflags=-m. С этого обычно начинают оптимизацию аллокаций, вместе с pprof и бенчмарками с флагом -benchmem

Тема 062 вопроса

Context и сервисы

19Для чего нужен context.Context?

Через context по цепочке вызовов передают отмену, дедлайн и данные запроса вроде request id. Его передают первым аргументом и не хранят в структурах

WithTimeout и WithCancel возвращают функцию cancel, и её обязательно нужно вызвать, обычно через defer. Иначе дочерний контекст (а у WithTimeout ещё и таймер) провисит в памяти, пока не отменят родителя или не наступит дедлайн. go vet такое ловит. И через Value не стоит передавать обязательные параметры, это скрытая зависимость

20Как сделать graceful shutdown HTTP-сервиса?

Ловим SIGINT и SIGTERM через signal.NotifyContext и вызываем server.Shutdown(ctx) с таймаутом. Сервер перестаёт принимать новые соединения и дожидается текущих запросов. После этого по очереди закрываем консьюмеры, пул БД и остальное

В Kubernetes таймаут должен укладываться в terminationGracePeriodSeconds. Перед Shutdown стоит подождать несколько секунд или добавить preStop-хук. Под убирают из Service не мгновенно, и какое-то время на него ещё приходит трафик

Тема 074 вопроса

Базы данных и микросервисы

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

24Чем gRPC отличается от REST?

gRPC работает поверх HTTP/2 и передаёт данные в бинарном формате Protocol Buffers. Контракт описывают в .proto-файле и генерируют из него клиент и сервер. За счёт этого есть строгая типизация, сообщения меньше, а ещё мультиплексирование и стриминг в обе стороны

REST с JSON проще отлаживать и вызывать из браузера. Поэтому часто наружу отдают REST, а внутренние сервисы общаются между собой по gRPC

Это только часть вопросов

Полная подготовка к собеседованию на менторстве

Здесь собраны самые частые вопросы. На менторстве по Go у тебя будет вся база и личная подготовка до оффера

  • Детальная база знанийТемы собеседования по Golang с разбором, примерами и уточняющими вопросами интервьюеров
  • Roadmap из 5 блоковПлан подготовки под твой уровень и цель, а после каждого блока мок-собеседование
  • 350+ реальных собеседованийЗаписи собесов из реальных компаний: что спрашивают и как отвечают те, кто получил оффер

Готовишься на другой стек? Вопросы на собеседовании Node.js