Готовиться к Go-собеседованию по списку с GitHub — значит знать ровно то же, что знают все остальные. Интервьюеры это чувствуют сразу. В этой статье — 10 вопросов, которые реально задают на Golang Junior собеседованиях, с разбором так, как это объяснили бы вам после интервью на обратной связи.
Вопрос 1. Nil-ловушка интерфейсов
Смотрим на код:
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 — и не срабатывает, хотя ошибка есть. Найти это без понимания устройства интерфейсов — крайне сложно.
Вопрос 2. Слайс — не массив
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=3b: [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 сразу выделит новый массив
Вопрос 3. defer и возвращаемые значения
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) } }() // ...}
Вопрос 4. Утечка горутины
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 — он покажет какие горутины живут и где они заблокированы.
Хотите 350+ таких вопросов с разборами?
Это только 4 из огромного списка того, что реально спрашивают на Go-собеседованиях.
Если хотите подготовиться системно — есть бесплатный курс «Подготовка к Golang собеседованию | Полный курс». Внутри: 350+ вопросов с разборами, 100+ задач на написание кода, 20 заданий на Code Review и 75 тестовых заданий для самопроверки. Покрывает всё: основы языка, конкурентность, базы данных, алгоритмы, архитектуру, Soft Skills и зарплатные вилки бигтехов.
Вопрос 5. Map — не потокобезопасен
Это не просто 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.Mapm.Store("key", 42)val, ok := m.Load("key")
sync.Map оптимизирована для двух сценариев: когда одни и те же ключи читаются многократно, или когда горутины работают с непересекающимися наборами ключей. Для остальных случаев обычный map + sync.Mutex может быть быстрее.
Вопрос 6. Value receiver vs Pointer receiver
type Counter struct{ count int }func (c Counter) Inc() { c.count++ } // value receiverfunc (c *Counter) IncPtr() { c.count++ } // pointer receiverc := 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 — для маленьких структур без изменения состояния, или когда нужна копия по семантике (время, точка, вектор).
Вопрос 7. Пустой struct{} — зачем он нужен?
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) { // ...}
Вопрос 8. Goroutines vs Threads — реальная разница
Типичный ответ джуна: «горутины легче потоков». Правильный ответ — объяснить почему и в чём именно.
Потоки ОС:
-
Каждый поток = 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.
Вопрос 9. Буферизованный vs небуферизованный канал
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!
Как выбрать?
Небуферизованный — когда нужна гарантия, что сообщение обработано до продолжения. Буферизованный — когда отправитель не должен ждать получателя (задачи в пул воркеров, логирование, метрики). Размер буфера — исходя из ожидаемого пика нагрузки, а не «на всякий случай побольше».
Вопрос 10. select с default — неочевидное поведение
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)}
ссылка на оригинал статьи https://habr.com/ru/articles/1045104/