golang

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

  • вторник, 9 июня 2026 г. в 00:00:21
https://habr.com/ru/articles/1045104/

Готовиться к 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=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 сразу выделит новый массив

Вопрос 3. defer и возвращаемые значения

func foo() (result int) {
    defer func() {
        result++
    }()
    return 0
}

fmt.Println(foo()) // Что выведет?

Ответ: 1. Не 0.

Почему?

Здесь используется именованное возвращаемое значение (result int). Оператор return 0 делает следующее:

  1. Присваивает result = 0

  2. Выполняет все defer функции

  3. Возвращает текущее значение 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.Map

m.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 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 — для маленьких структур без изменения состояния, или когда нужна копия по семантике (время, точка, вектор).

Вопрос 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)
}