10 вопросов на Go собеседовании, которые валят большинство джунов
- вторник, 9 июня 2026 г. в 00:00:21
Готовиться к Go-собеседованию по списку с GitHub — значит знать ровно то же, что знают все остальные. Интервьюеры это чувствуют сразу. В этой статье — 10 вопросов, которые реально задают на Golang Junior собеседованиях, с разбором так, как это объяснили бы вам после интервью на обратной связи.
Смотрим на код:
type MyError struct{ msg string } func (e *MyError) Error() string { return e.msg } func getError() error { var myErr *MyError = nil return myErr } func main() { err := getError() fmt.Println(err == nil) // Что выведет? }
Большинство джунов уверенно отвечают: true. Правильный ответ — false.
Почему так происходит?
В Go интерфейс — это не просто указатель. Внутри он хранит два поля: тип (type) и значение (value). Интерфейс равен nil только тогда, когда оба поля равны nil.
Когда вы возвращаете *MyError(nil) через интерфейс error — происходит следующее:
err = { type: *MyError, value: nil }
Тип уже есть — значит интерфейс не nil, даже если само значение внутри — nil. Это одна из самых коварных ловушек в Go.
Как правильно:
func getError() error { var myErr *MyError = nil if myErr == nil { return nil // Возвращаем именно nil интерфейс } return myErr }
Где это стреляет в продакшене? В функциях, которые возвращают конкретный тип ошибки через интерфейс error. Код вызывает if err != nil — и не срабатывает, хотя ошибка есть. Найти это без понимания устройства интерфейсов — крайне сложно.
a := []int{1, 2, 3} b := a[:2] b = append(b, 99) fmt.Println(a) // ? b = append(b, 100, 101, 102) fmt.Println(a) // А теперь?
Большинство ожидают, что b — независимая копия. На деле оба вопроса имеют разные ответы, и причина в устройстве слайса.
Как устроен слайс изнутри:
type slice struct { array unsafe.Pointer // указатель на underlying array len int cap int }
Слайс — это три поля: указатель на массив, длина и ёмкость. Когда вы делаете b := a[:2] — вы не копируете данные. b смотрит на тот же массив, что и a.
Первый append:
a: [1, 2, 3] cap=3 b: [1, 2] cap=3 ← смотрит на тот же массив append(b, 99) → пишет 99 на позицию 2 (в том же массиве!) a: [1, 2, 99] ← a тоже изменился!
Второй append:
b уже [1, 2, 99], cap исчерпан append(b, 100, 101, 102) → не хватает места → Go выделяет НОВЫЙ массив b переезжает на новый массив a остаётся [1, 2, 99] — теперь они независимы
Как избежать такого поведения:
b := make([]int, len(a[:2])) copy(b, a[:2]) // Явная копия — b независим от a
Или использовать трёхиндексный срез, который ограничивает ёмкость:
b := a[:2:2] // len=2, cap=2 — append сразу выделит новый массив
func foo() (result int) { defer func() { result++ }() return 0 } fmt.Println(foo()) // Что выведет?
Ответ: 1. Не 0.
Почему?
Здесь используется именованное возвращаемое значение (result int). Оператор return 0 делает следующее:
Присваивает result = 0
Выполняет все defer функции
Возвращает текущее значение result
Поскольку defer выполняется после присваивания, но до фактического возврата — он успевает изменить result. Итог: result = 0 + 1 = 1.
Сравниваем с безымянным возвращаемым значением:
func bar() int { result := 0 defer func() { result++ // Изменит локальную переменную, не возвращаемое значение! }() return result // Скопирует result в анонимную переменную возврата } fmt.Println(bar()) // 0 — defer не влияет!
Где это полезно? В функциях обработки ошибок, где нужно гарантированно обернуть ошибку перед возвратом:
func readFile(path string) (err error) { defer func() { if err != nil { err = fmt.Errorf("readFile %s: %w", path, err) } }() // ... }
func leak() { ch := make(chan int) go func() { val := <-ch // Горутина ждёт значения fmt.Println(val) }() // Функция завершилась. Канал никто не закрывает. }
Что произошло?
Горутина ждёт значения из канала. Функция leak() завершилась, канал ch вышел из области видимости — но горутина продолжает существовать в памяти и ждать значения, которое никогда не придёт. Это и есть утечка горутины.
В отличие от языков с GC, сборщик мусора Go не уничтожает горутины, которые заблокированы на канале. Он не может отличить "горутина ждёт данных" от "горутина ждёт данных, которые никогда не придут".
Как это выглядит в реальности:
// Вызываем leak() в цикле — например, на каждый HTTP-запрос for { leak() // Каждый вызов создаёт горутину, которая никогда не умрёт } // Через время: утечка памяти, деградация сервиса
Правильное решение — контекст с отменой:
func noLeak(ctx context.Context) { ch := make(chan int) go func() { select { case val := <-ch: fmt.Println(val) case <-ctx.Done(): // Горутина завершится когда контекст отменён return } }() }
Как обнаружить утечки? Пакет runtime позволяет отслеживать количество горутин:
fmt.Println(runtime.NumGoroutine()) // Если число растёт — есть утечка
Для продакшена используют pprof — он покажет какие горутины живут и где они заблокированы.
Это только 4 из огромного списка того, что реально спрашивают на Go-собеседованиях.
Если хотите подготовиться системно — есть бесплатный курс «Подготовка к Golang собеседованию | Полный курс». Внутри: 350+ вопросов с разборами, 100+ задач на написание кода, 20 заданий на Code Review и 75 тестовых заданий для самопроверки. Покрывает всё: основы языка, конкурентность, базы данных, алгоритмы, архитектуру, Soft Skills и зарплатные вилки бигтехов.
Это не просто race condition — это краш всей программы.
m := make(map[string]int) go func() { m["key"] = 1 }() go func() { fmt.Println(m["key"]) }()
Запустите это — и получите:
fatal error: concurrent map read and map write
Почему Go падает с fatal error, а не просто даёт неверный результат?
В большинстве языков одновременная запись в коллекцию даёт неопределённое поведение, но программа продолжает работать. Go намеренно выбрал стратегию немедленного краша — потому что молчаливое повреждение данных гораздо хуже предсказуемого сбоя. Начиная с Go 1.6, runtime обнаруживает конкурентный доступ к map и паникует.
Решение 1: sync.Mutex
type SafeMap struct { mu sync.RWMutex m map[string]int } func (s *SafeMap) Set(key string, val int) { s.mu.Lock() defer s.mu.Unlock() s.m[key] = val } func (s *SafeMap) Get(key string) int { s.mu.RLock() defer s.mu.RUnlock() return s.m[key] }
Решение 2: sync.Map — встроенная потокобезопасная карта
var m sync.Map m.Store("key", 42) val, ok := m.Load("key")
sync.Map оптимизирована для двух сценариев: когда одни и те же ключи читаются многократно, или когда горутины работают с непересекающимися наборами ключей. Для остальных случаев обычный map + sync.Mutex может быть быстрее.
type Counter struct{ count int } func (c Counter) Inc() { c.count++ } // value receiver func (c *Counter) IncPtr() { c.count++ } // pointer receiver c := Counter{} c.Inc() // count = ? c.IncPtr() // count = ?
Inc() — count остаётся 0. IncPtr() — count становится 1.
Почему?
Value receiver получает копию структуры. Метод работает с копией — оригинал не меняется. Это то же самое, что передача значения в функцию по значению в любом другом языке.
Pointer receiver получает указатель на оригинал. Все изменения влияют на исходную структуру.
Неочевидный момент: Go автоматически берёт адрес переменной, когда это нужно:
c := Counter{} c.IncPtr() // Go автоматически делает (&c).IncPtr()
Но это работает только для адресуемых значений. Если counter — часть map или возвращён функцией — адрес взять нельзя:
counters := map[string]Counter{"a": {}} counters["a"].IncPtr() // Ошибка компиляции: cannot take the address
Правило выбора: Используйте pointer receiver если метод изменяет состояние, структура большая (дорого копировать), или нужна консистентность (все методы типа с pointer receiver). Value receiver — для маленьких структур без изменения состояния, или когда нужна копия по семантике (время, точка, вектор).
ch := make(chan struct{}) set := make(map[string]struct{})
Новички часто спрашивают: зачем такая странная конструкция?
struct{} занимает ноль байт памяти. Буквально:
fmt.Println(unsafe.Sizeof(struct{}{})) // 0
Это делает его идеальным там, где важен сам факт наличия элемента, но не его значение.
Использование 1: канал-сигнал
done := make(chan struct{}) go func() { // Делаем работу... close(done) // Сигнализируем о завершении }() <-done // Ждём сигнала
Вместо chan bool (1 байт) используем chan struct{} (0 байт). Семантически точнее: мы передаём сигнал, а не данные.
Использование 2: Set (множество)
// Хотим хранить уникальные элементы без значений visited := make(map[string]struct{}) visited["google.com"] = struct{}{} visited["github.com"] = struct{}{} if _, ok := visited["google.com"]; ok { fmt.Println("Уже посещали") }
По сравнению с map[string]bool — экономим память на значениях (в больших map это заметно).
Использование 3: реализация интерфейса без состояния
type Handler struct{} // Нет полей — нет аллокации func (Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { // ... }
Типичный ответ джуна: "горутины легче потоков". Правильный ответ — объяснить почему и в чём именно.
Потоки ОС:
Каждый поток = 1–8 МБ стека (зарезервировано сразу)
Планировщик ОС: вытесняющий, kernel-space
Переключение контекста: ~1–10 мкс (сохранить/восстановить регистры CPU)
100 000 потоков = 100–800 ГБ памяти под стеки
Горутины Go:
Каждая горутина = 2–8 КБ стека изначально (растёт динамически)
Планировщик Go: кооперативный + вытесняющий, user-space (M:N модель)
Переключение контекста: ~100–200 нс (в 10-50 раз быстрее)
100 000 горутин = 200–800 МБ памяти — вполне реально
Планировщик Go (M:N):
G G G G G G G G ← Goroutines (тысячи) ↕ P P P P ← Processors (= GOMAXPROCS, обычно = ядрам CPU) ↕ M M M M ← OS Threads (несколько)
Go сам распределяет горутины по потокам ОС. Когда горутина блокируется (I/O, channel, mutex) — поток не простаивает, планировщик переключает его на другую горутину.
Практический итог: 100 000 горутин — нормальная ситуация для Go-сервиса. 100 000 потоков ОС — это 100–800 ГБ памяти только под стеки и немедленный OOM-killer.
ch1 := make(chan int) // небуферизованный ch2 := make(chan int, 1) // буферизованный, ёмкость 1
Разница кажется простой — но именно здесь рождаются дедлоки.
Небуферизованный канал — синхронная точка встречи:
ch := make(chan int) go func() { ch <- 42 // Блокируется ЗДЕСЬ, пока кто-то не прочитает }() val := <-ch // Разблокирует отправителя
Отправитель и получатель должны встретиться одновременно. Если одного нет — другой ждёт вечно.
Буферизованный канал — асинхронная очередь:
ch := make(chan int, 3) // Буфер на 3 элемента ch <- 1 // Не блокируется ch <- 2 // Не блокируется ch <- 3 // Не блокируется ch <- 4 // БЛОКИРУЕТСЯ — буфер полон
Классический дедлок с небуферизованным каналом:
func main() { ch := make(chan int) ch <- 1 // main горутина блокируется val := <-ch // до этой строки никогда не дойдёт fmt.Println(val) } // fatal error: all goroutines are asleep - deadlock!
Как выбрать?
Небуферизованный — когда нужна гарантия, что сообщение обработано до продолжения. Буферизованный — когда отправитель не должен ждать получателя (задачи в пул воркеров, логирование, метрики). Размер буфера — исходя из ожидаемого пика нагрузки, а не "на всякий случай побольше".
ch := make(chan int) select { case v := <-ch: fmt.Println("Получили:", v) default: fmt.Println("Нет значения") }
Выведет: Нет значения
Как работает select:
select проверяет все case одновременно. Если готов хотя бы один — выполняет его. Если готовы несколько — выбирает случайный (это важно!). Если ни один не готов:
Есть default → выполняет его немедленно (неблокирующий select)
Нет default → блокируется до первого готового case
Неблокирующая отправка — классический паттерн:
select { case ch <- value: // Успешно отправили default: // Канал занят — пропускаем или логируем }
Таймаут через select:
select { case result := <-ch: fmt.Println("Результат:", result) case <-time.After(5 * time.Second): fmt.Println("Таймаут!") }
Случайный выбор при нескольких готовых case — это не баг, а фича. Она предотвращает голодание (starvation), когда один case всегда побеждает других:
// Если оба канала готовы — Go выберет случайный select { case msg := <-ch1: fmt.Println("ch1:", msg) case msg := <-ch2: fmt.Println("ch2:", msg) }