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

от автора

Готовиться к 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 делает следующее:

  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.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/