Перейти к содержанию

Задачи, которые нужно знать на 100%

Это подборка задач на компилятор и на конкурентность, которые встречаются на почти каждом собесе на Go. Источник — реальные собесы из telegram-чата «Разбор резюме» и личный архив.

Ответ к каждой задаче — внутри <details>. Сначала подумай, потом разверни.


Часть 1. Слайсы и массивы

C-001. append в общий backing array

package main

import "fmt"

func main() {
    a := []int{1, 2, 3, 4, 5}
    b := a[:3]
    b = append(b, 99)
    fmt.Println(a)
    fmt.Println(b)
}
Ответ

[1 2 3 99 5]
[1 2 3 99]
b — слайс длины 3 с cap=5. append на 4-й элемент укладывается в capacity, не выделяется новый массив, и пишет в общий backing → a[3] тоже становится 99.

C-002. append y и z от одного исходного

x := []int{0, 1, 2}
y := append(x, 3)
z := append(x, 4)
fmt.Println(y)
fmt.Println(z)
Ответ

[0 1 2 4]
[0 1 2 4]
x имеет cap=3 (точно по len). Первый append выделяет новый массив (обычно cap2 = 6), копирует туда [0 1 2 3]. Второй append от того же x снова видит cap=3, снова выделяет новый массив, копирует [0 1 2 4]. Поскольку y «успел» получить отдельный backing — третий элемент в нём уже не будет затёрт. Гочча*: если бы первый append остался в исходном массиве (если бы там был запас), то второй append его перезаписал бы.

C-003. Мутация через функцию

func mod1(s []int) { s[0] = 1 }
func mod2(s []int) { s = append(s, 99); s[0] = 7 }

a := []int{0, 0, 0}
fmt.Println(a)
mod1(a); fmt.Println(a)
mod2(a); fmt.Println(a)
Ответ

[0 0 0]
[1 0 0]
[1 0 0]
mod1 пишет в общий backing → видим. mod2 делает append, который превышает cap=3 → создаётся новый массив, изменения s[0]=7 не отражаются в исходном.

C-004. Захват переменной цикла горутиной (до Go 1.22)

for i := 0; i < 3; i++ {
    go func() {
        fmt.Print(i, " ")
    }()
}
time.Sleep(time.Second)
Ответ

До Go 1.22: «3 3 3» (или 3 разных значения, но скорее всего 3 3 3, т.к. цикл успевает закончить раньше горутин). С Go 1.22: «0 1 2» (порядок не гарантирован), потому что переменная цикла теперь свежая на каждой итерации.

C-005. Take address of map element

m := map[string]int{"one": 1}
p := &m["one"]
fmt.Println(p)
Ответ

Compile error: cannot take the address of m["one"]. Map в Go может быть переаллоцирован при росте, поэтому адреса элементов нестабильны.

Под капотом: мапа — это массив bucket'ов, в каждом до 8 пар. Когда среднее заполнение превышает 6.5 элементов на bucket, runtime аллоцирует новый массив в 2 раза больше и постепенно переносит старые bucket'ы (incremental rehash). После эвакуации указатель на m["one"] указывал бы в старый массив, а реальное значение уже в новом — компилятор просто запрещает такую операцию заранее.

Если нужен «адрес» — храни указатели как values:

m := map[string]*Item{"one": {V: 1}}
m["one"].V = 42 // ок: меняем поле через указатель


Часть 2. defer

C-006. Defer вычисляет аргументы сразу

i := 0
defer fmt.Println("deferred:", i)
i++
fmt.Println("now:", i)
Ответ

now: 1
deferred: 0
Аргументы defer вычисляются в момент defer, а не выполнения.

C-007. Defer в цикле — LIFO

for i := 1; i <= 5; i++ {
    defer fmt.Print(i, " ")
}
fmt.Println("end")
Ответ

end
5 4 3 2 1
defer пушит в стек, при выходе снимается сверху.

C-008. Defer метод vs замыкание

type S struct{ v int }
func (s S) P() { fmt.Println(s.v) }

s := S{v: 1}
defer s.P()
defer func() { s.P() }()
s.v = 2
Ответ

2
1
defer s.P() копирует receiver сразу (s.v=1 на момент defer). defer func() { s.P() }() — замыкание, читает s в момент выполнения (s.v=2). Стек: сначала вторая (LIFO).


Часть 3. nil и интерфейсы

C-009. Typed nil interface

type MyError struct{ msg string }
func (e *MyError) Error() string { return e.msg }

func mayFail() error {
    var p *MyError = nil
    return p
}

err := mayFail()
fmt.Println(err == nil)
Ответ

false. Interface хранит (type, value). После возврата *MyError(nil) type == *MyError (≠ nil), value == nil. err == nil истинно только когда оба компонента nil.

C-010. var s *string; i = s; i == nil

var s *string
var i interface{} = s
fmt.Println(s == nil, i == nil)
Ответ

true false. Тот же случай: interface хранит type *string, поэтому != nil.

C-011. Запись в nil map — panic

var m map[string]int
m["a"] = 1
Ответ

runtime panic: assignment to entry in nil map. Чтение из nil map вернёт zero value (без panic), запись — panic.


Часть 4. Методы и receiver

C-012. Value vs pointer receiver

type Counter struct{ n int }
func (c Counter) IncVal()  { c.n++ }
func (c *Counter) IncPtr() { c.n++ }

c := Counter{}
c.IncVal(); c.IncVal()
c.IncPtr()
fmt.Println(c.n)
Ответ

1. IncVal работает с копией, изменения теряются. Только IncPtr видит исходный c.


Часть 5. Каналы и горутины

C-013. Send и receive в буферизованном канале

ch := make(chan int, 2)
ch <- 1
ch <- 2
close(ch)
for v := range ch {
    fmt.Print(v, " ")
}
Ответ

1 2. Буфер 2 принимает оба send без блокировки. close + range отдаёт всё, что в буфере, и завершается.

C-014. nil канал в select

var ch chan int
select {
case v := <-ch: fmt.Println("got", v)
default: fmt.Println("default")
}
Ответ

default. nil-канал в select никогда не срабатывает.

C-015. Send в закрытый канал

ch := make(chan int, 1)
close(ch)
ch <- 1
Ответ

runtime panic: send on closed channel.

C-016. Receive из закрытого канала

ch := make(chan int, 1)
ch <- 5
close(ch)
v, ok := <-ch
fmt.Println(v, ok)
v, ok = <-ch
fmt.Println(v, ok)
Ответ

5 true
0 false
После исчерпания буфера ok=false, v=zero value.

C-017. Deadlock на unbuffered канале

ch := make(chan int)
ch <- 1
fmt.Println(<-ch)
Ответ

fatal error: all goroutines are asleep - deadlock! Unbuffered канал требует одновременного receiver, его нет.

C-018. select с двумя готовыми кейсами

ch1 := make(chan int, 1); ch1 <- 1
ch2 := make(chan int, 1); ch2 <- 2
select {
case v := <-ch1: fmt.Println("ch1:", v)
case v := <-ch2: fmt.Println("ch2:", v)
}
Ответ

Один из двух — выбирается случайно. Это не баг, это документированное поведение Go runtime.


Часть 6. Конкурентность

M-001. Гонка на counter

var counter int
var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
    wg.Add(1)
    go func() {
        defer wg.Done()
        counter++
    }()
}
wg.Wait()
fmt.Println(counter)

Запусти go run -race. Что увидишь и как исправить?

Ответ

WARNING: DATA RACE. counter++ — это не атомарная операция (load, add, store). Исправление:

var counter int64
atomic.AddInt64(&counter, 1)
Или sync.Mutex вокруг counter++.

M-002. Worker pool — закрытие канала

jobs := make(chan int, 100)
var wg sync.WaitGroup
for w := 0; w < 3; w++ {
    wg.Add(1)
    go func() {
        defer wg.Done()
        for j := range jobs {
            _ = j * 2
        }
    }()
}
for i := 0; i < 10; i++ { jobs <- i }
// что не так?
wg.Wait()
Ответ

wg.Wait() повиснет навсегда. Воркеры в for j := range jobs ждут closed-сигнал. Нужно close(jobs) после последнего send.

M-003. Параллельный fetch с лимитом 10

Что добавить, чтобы одновременно работало не больше 10 горутин?

sem := make(chan struct{}, 10)
for _, url := range urls {
    sem <- struct{}{}     // занять слот, блокируется если > 10 в полёте
    go func(u string) {
        defer func() { <-sem }()
        fetch(u)
    }(url)
}

M-004. Утечка горутин из-за context'а

func work(ctx context.Context) {
    ch := make(chan int)
    go func() {
        time.Sleep(10 * time.Second)
        ch <- 42
    }()
    select {
    case v := <-ch:
        fmt.Println(v)
    case <-ctx.Done():
        fmt.Println("cancelled")
    }
}

В чём утечка?

Ответ

Если ctx отменяется раньше — основная функция выходит, но горутина внутри продолжает спать 10 секунд и пытается отправить в ch, который никто не читает → горутина висит вечно. Исправление: канал с буфером 1, либо select внутри горутины с case <-ctx.Done(): return.


Часть 7. Прочее (часто на собесах)

C-019. type assertion и ok-idiom

var i interface{} = "hello"
n, ok := i.(int)
fmt.Println(n, ok)
Ответ

0 false. Без ok — был бы panic.

C-020. comparing structs

type Point struct{ X, Y int }
p1 := Point{1, 2}; p2 := Point{1, 2}
fmt.Println(p1 == p2)
Ответ

true. Структуры сравнимы поэлементно, если все поля comparable. Сравнение структуры со слайсом — compile error.

C-021. panic в горутине + recover в main

package main

import "fmt"

func main() {
    defer func() {
        if r := recover(); r != nil {
            fmt.Println("recovered:", r)
        }
    }()
    go func() {
        panic("from goroutine")
    }()
    time.Sleep(time.Second)
    fmt.Println("end")
}

Что выведет?

Ответ

Программа упадёт с panic: from goroutine и сразу stack trace. recover в main НЕ поймает panic из другой горутины — каждая горутина имеет свой стек, recover работает только в рамках того же стека.

Чтобы безопасно ловить panic в горутинах, defer recover должен быть внутри самой горутины:

go func() {
    defer func() {
        if r := recover(); r != nil {
            log.Println("recovered:", r)
        }
    }()
    panic("from goroutine")
}()

Стандартная обёртка safeGo(func()) с recover'ом — must-have в проде, чтобы паника одной горутины не уронила сервис.

C-022. changeName через указатель

type Person struct {
    Name string
}

func changeName(person *Person) {
    person = &Person{Name: "Bob"}
}

func main() {
    p := &Person{Name: "Alice"}
    changeName(p)
    fmt.Println(p.Name)
}

Что выведет? Как поправить, чтобы был "Bob"?

Ответ

Alice. В функции мы переприсвоили локальную копию указателя person — это не влияет на p в main. Указатели тоже передаются по значению (по значению-копии адреса).

Чтобы поменять то, на что указывает p, нужно разыменовать:

func changeName(person *Person) {
    *person = Person{Name: "Bob"}  // меняем то, на что указывает
}

Альтернатива — **Person, но это редко нужно и плохо читается. Идиоматично — мутировать поля (person.Name = "Bob") либо возвращать новый объект.

C-023. struct alignment — размер 24 vs 32

type some1 struct {
    a int64  // 8
    b bool   // 1
    c int64  // 8
    d bool   // 1
}

type some2 struct {
    a int64  // 8
    c int64  // 8
    b bool   // 1
    d bool   // 1
}

fmt.Println(unsafe.Sizeof(some1{}), unsafe.Sizeof(some2{}))
Ответ

32 24.

some1: после b bool идёт padding 7 байт (чтобы c int64 начался с 8-байт-выровненного адреса), затем c, затем d bool, затем padding 7 байт в хвосте (чтобы общий размер был кратен 8). Итого: 8 + 1 + 7 + 8 + 1 + 7 = 32 байта.

some2: два int64 подряд (без padding), затем два bool — 2 байта, затем хвостовой padding 6 байт. Итого: 8 + 8 + 1 + 1 + 6 = 24 байта.

Правило: сортируй поля от больших к маленьким. Это снижает размер структуры и улучшает cache locality. На больших слайсах структур экономия 25–40% памяти — не редкость.

Проверить вживую — fieldalignment (часть golang.org/x/tools).


Совет от ментора

Не зубри ответы. Запусти каждую задачу руками в go run. Когда увидишь что-то странное — открой профайлер, обсуди с ментором, прочитай конспект по теме. Цель — понять, а не запомнить.