Перейти к основному содержимому

Mock-собеседование по Go от Старшего разработчика из Авито

· 23 мин. чтения

Сегодня мы разберём собеседование на позицию Go-разработчика, в ходе которого кандидат решает серию задач на понимание внутренней работы языка — от поведения слайсов и указателей до реализации интерфейсов и управления конкурентностью с использованием контекстов и каналов. Интервьюер Олег, старший разработчик Авито, оценивает не только знание синтаксиса, но и глубину понимания подводных камней Go, а кандиСтанислав демонстрирует аналитическое мышление, способность рассуждать вслух и применять лучшие практики при написании кода.

Вопрос 1. Что выведет программа со слайсами и функцией append? Объясните пошагово.

Таймкод: 00:01:43

Ответ собеседника: Правильный. Кандидат подробно разобрал код по шагам, объяснил поведение функции append: она возвращает новый слайс с увеличенной длиной на единицу, пока длина не превысит capacity. Правильно указал, что при совпадении областей памяти у слайсов A1 и A2, вставка семёрки в A2 перезаписывает шестёрку, ранее добавленная в A1, так как оба слайса ссылаются на одну и ту же область памяти. Вывод программы: первый Println выведет 5 элементов (1 2 3 4 5), второй — 6 элементов (1 2 3 4 5 7).

Правильный ответ:

Рассмотрим классическую задачу с передачей слайсов в функции и операцией append. Типичный пример кода:

package main

import "fmt"

func main() {
a := make([]int, 0, 6)
a = append(a, 1, 2, 3, 4, 5)

a1 := append(a, 6)
a2 := append(a, 7)

fmt.Println(a1)
fmt.Println(a2)
}

Пошаговый разбор:

Шаг 1 — Создание исходного слайса a.

make([]int, 0, 6) создаёт слайс с длиной 0 и capacity 6. После append(a, 1, 2, 3, 4, 5) слайс a имеет:

  • len(a) = 5
  • cap(a) = 6
  • Базовый массив: [1, 2, 3, 4, 5, _] (6-я ячейка свободна)

Шаг 2 — Вызов a1 := append(a, 6).

Поскольку len(a) = 5 и cap(a) = 6, свободное место в базовом массиве ещё есть. append записывает значение 6 в 6-ю ячейку того же базового массива и возвращает новый слайс-заголовок с len = 6, указывающий на тот же базовый массив.

После этого:

  • a1len = 6, cap = 6, базовый массив: [1, 2, 3, 4, 5, 6]
  • a — всё ещё len = 5, cap = 6, но его базовый массив теперь [1, 2, 3, 4, 5, 6]

Шаг 3 — Вызов a2 := append(a, 7).

Здесь ключевой момент: a по-прежнему имеет len = 5 и cap = 6. 6-я ячейка базового массива свободна с точки зрения слайса a (его длина равна 5). Поэтому append(a, 7) записывает значение 7 в 6-ю ячейку того же базового массива, перезаписывая значение 6, которое было записано на шаге 2.

После этого:

  • a2len = 6, cap = 6, базовый массив: [1, 2, 3, 4, 5, 7]
  • a1len = 6, cap = 6, базовый массив: [1, 2, 3, 4, 5, 7] (тот же массив!)

Шаг 4 — Вывод.

Оба слайса a1 и a2 ссылаются на один и тот же базовый массив, в котором 6-й элемент равен 7 (значение 6 было перезаписано).

Вывод:

[1 2 3 4 5 7]
[1 2 3 4 5 7]

Ключевые принципы:

  • Слайс — это структура (заголовок), содержащая указатель на базовый массив, длину и capacity.
  • append при наличии свободного capacity не создаёт новый массив, а дописывает в существующий.
  • Если два слайса с разной длиной ссылаются на один базовый массив, запись через один слайс может перезаписать данные, видимые через другой.
  • При переполнении capacity append выделяет новый массив (обычно с удвоением размера), и слайсы перестают разделять память.

Как избежать таких багов:

Если нужно создать независимые копии слайса из одного источника, используйте трюк с полным выражением слайса (a[:len(a):len(a)]), которое ограничивает capacity до длины:

a1 := append(a[:len(a):len(a)], 6)
a2 := append(a[:len(a):len(a)], 7)

В этом случае оба append не смогут записать в исходный массив, так как capacity равна длине, и будет выделен новый массив для каждого.

Вопрос 2. Что изменится, если capacity изначально не задан (равен длине)?

Таймкод: 00:09:35

Ответ собеседника: Правильный. Кандидат верно объяснил, что при capacity=0 и создании слайса из 5 элементов через распаковку, Go инициализирует слайс с length=5 и capacity=5. При первом append к A1 функция выделяет новую область памяти (capacity увеличивается вдвое до 10), копирует данные и добавляет элемент. Таким образом A1 и A2 оказываются в разных областях памяти, и изменения в одном слайсе не влияют на другой. Вывод: шестёрка сохраняется корректно, так как слайсы независимы.

Правильный ответ:

Рассмотрим изменённый пример, где исходный слайс создаётся без явного указания capacity:

package main

import "fmt"

func main() {
a := []int{1, 2, 3, 4, 5} // len=5, cap=5

a1 := append(a, 6)
a2 := append(a, 7)

fmt.Println(a1) // [1 2 3 4 5 6]
fmt.Println(a2) // [1 2 3 4 5 7]
}

Пошаговый разбор:

Шаг 1 — Создание исходного слайса a.

Литерал []int{1, 2, 3, 4, 5} создаёт слайс с len = 5 и cap = 5. Базовый массив полностью зазанят, свободных ячеек нет.

Шаг 2 — Вызов a1 := append(a, 6).

Поскольку len(a) == cap(a) == 5, свободного места в базовом массиве нет. Функция append вынуждена:

  1. Выделить новый базовый массив с увеличенным capacity (по стратегии Go — обычно удвоение, то есть cap = 10).
  2. Скопировать все 5 элементов из старого массива в новый.
  3. Записать значение 6 в 6-ю ячейку нового массива.
  4. Вернуть новый слайс-заголовок, указывающий на новый базовый массив.

Теперь a1 указывает на новый массив [1, 2, 3, 4, 5, 6, _, _, _, _] с len = 6, cap = 10.

Шаг 3 — Вызов a2 := append(a, 7).

Слайс a по-прежнему имеет len = 5, cap = 5 и указывает на старый базовый массив. Свободного места снова нет, поэтому append:

  1. Выделит ещё один новый базовый массив (тоже cap = 10).
  2. Скопирует 5 элементов из старого массива.
  3. Запишет 7 в 6-ю ячейку.

Теперь a2 указывает на свой собственный новый массив [1, 2, 3, 4, 5, 7, _, _, _, _] с len = 6, cap = 10.

Шаг 4 — Вывод.

a1 и a2 ссылаются на разные базовые массивы. Изменения в одном не влияют на другой:

[1 2 3 4 5 6]
[1 2 3 4 5 7]

Сравнение двух сценариев:

УсловиеCapacity исходного слайсаПоведение appendРезультат
make([]int, 0, 6)Есть свободное местоЗапись в тот же массивОба слайса разделяют память, значение 6 перезаписывается
[]int{1,2,3,4,5}Нет свободного местаВыделение нового массиваСлайсы независимы, оба значения сохраняются

Стратегия роста capacity в Go:

Когда append вынужден расширить массив, Go использует следующую эвристику:

  • Для маленьких слайсов (до 256 элементов) capacity удваивается.
  • Для больших слайсов (свыше 256 элементов) capacity увеличивается примерно на 25%.

Это обеспечивает амортизированную O(1) сложность добавления элемента.

Практический вывод:

Никогда не полагайтесь на то, что append обязательно создаст новый массив или обязательно запишет в существующий. Если вам нужны гарантированно независимые слайсы, создавайте их явно через copy:

a1 := make([]int, len(a)+1)
copy(a1, a)
a1[len(a)] = 6

a2 := make([]int, len(a)+1)
copy(a2, a)
a2[len(a)] = 7

Или используйте полное выражение слайса a[:len(a):len(a)], чтобы принудительно ограничить capacity и вызвать выделение нового массива при первом append.

Вопрос 3. Что такое эвакуация памяти (evacuation) в контексте map в Go?

Таймкод: 00:12:40

Ответ собеседника: Неполный. Кандидат упомянул термин «эвакуация» при работе со слайсами, но не смог вспомнить и объяснить, что такое эвакуация памяти в контексте map. Кандидат сказал: «Сейчас вспомню», но ответ так и не дал.

Правильный ответ:

Внутреннее устройство map в Go

Map в Go реализована как хеш-таблица на основе массива бакетов (buckets). Каждый бакет — это структура, способная хранить до 8 пар ключ-значение. Когда программа выполняет m[key] = value, Go вычисляет хеш ключа, определяет номер бакета и размещает пару в первую свободную ячейку этого бакета. Если бакет заполнен, используется overflow-бакет, связанный цепочкой.

Что такое эвакуация (evacuation)

Эвакуация — это процесс перемещения записей (пар ключ-значение) из старых бакетов в новые при росте хеш-таблицы map. Этот процесс запускается, когда Go определяет, что map перегружена (too many buckets for the number of entries).

Когда происходит эвакуация

Go отслеживает коэффициент загрузки map. Эвакуация запускается, когда выполняется одно из условий:

  • Overload factor: количество записей превышает bucketCount * loadFactor, где loadFactor ≈ 6.5 (в текущих версиях Go). Это означает, что в среднем более 6.5 элементов приходится на бакет, что ухудшает производительность поиска.
  • Too many overflow buckets: количество overflow-бакетов становится чрезмерно большим (более bucketCount или более 1<<15 для больших map).

При добавлении нового элемента через m[key] = value, если обнаружена перегрузка, Go инициирует рост таблицы.

Как работает эвакуация

1. Выделение новой памяти. Go создаёт новый массив бакетов вдвое больше текущего. Например, если было 8 бакетов, станет 16.

2. Постепенная (инкрементальная) эвакуация. В отличие от слайсов, где копирование происходит сразу, эвакуация map выполняется лениво — не все записи перемещаются за один вызов. Вместо этого:

  • При каждой операции записи или удаления эвакуируется минимум один бакет (плюс связанные с ним overflow-бакеты).
  • Это распределяет стоимость роста по множеству операций, предотвращая заметные паузы.

3. Перемещение записей. Каждая запись из старого бакета перехешируется и размещается в соответствующем новом бакете. При удвоении количества бакетов запись либо остаётся в том же номере бакета, либо перемещается в бакет с номером oldIndex + oldBucketCount. Это определяется одним дополнительным битом хеша.

4. Очистка старой памяти. После того как все бакеты эвакуированы, старая память освобождается.

Детали реализации бакета

Каждый бакет содержит:

// Упрощённая структура (из runtime/map.go)
type bmap struct {
tophash [8]uint8 // Старшие 8 бит хеша для каждого слота
keys [8]keytype // Ключи
values [8]valuetype // Значения
overflow *bmap // Указатель на overflow-бакет
}

Массив tophash используется для быстрого отсеивания: сначала сравнивается старший байт хеша, и только при совпадении — полный ключ.

Инкрементальная эвакуация на практике

// Предположим, map вырос с 8 до 16 бакетов
// При m["newKey"] = 42:
// 1. Go определяет, что нужна эвакуация
// 2. Эвакуирует один старый бакет (например, бакет №3)
// 3. Все записи из бакета №3 и его overflow-цепочки
// перемещаются в новые бакеты №3 и/или №11 (3+8)
// 4. Записывает "newKey" в соответствующий новый бакет

Влияние на производительность

  • Амортизированная стоимость: благодаря инкрементальному подходу, каждая операция записи при растущей map выполняет немного больше работы, но нет единовременной большой паузы.
  • Итерация во время эвакуации: при итерации по map (for k, v := range m) Go может обходить как старые, так и новые бакеты, что делает порядок итерации ещё менее предсказуемым.
  • Память: во время эвакуации map одновременно держит в памяти старый и новый массивы бакетов, временно удваивая потребление памяти.

Пример, демонстрирующий рост map

package main

import (
"fmt"
"runtime"
)

func printMemStats(label string) {
var m runtime.MemStats
runtime.ReadMemStats(&m)
fmt.Printf("%s: Alloc = %d KB, HeapInuse = %d KB\n",
label, m.Alloc/1024, m.HeapInuse/1024)
}

func main() {
m := make(map[int]int)
printMemStats("Before")

for i := 0; i < 1000000; i++ {
m[i] = i
}
printMemStats("After 1M inserts")

// При удалении элементов память НЕ освобождается автоматически
// (бакеты остаются выделенными)
for i := 0; i < 1000000; i++ {
delete(m, i)
}
printMemStats("After 1M deletes")

// Для реального освобождения — создать новую map
m = make(map[int]int)
runtime.GC()
printMemStats("After recreate + GC")
}

Ключевые отличия от роста слайсов

АспектСлайсMap
КопированиеМгновенное, все элементыИнкрементальное, по бакетам
ПаузаЕдиновременная при переполненииРаспределена по операциям
Старая памятьСразу доступна для GCОсвобождается после полной эвакуации
Коэффициент роста~2x (для больших — ~1.25x)Ровно 2x

Практические рекомендации

  • Если известен приблизительный размер map, используйте make(map[K]V, hint) — это позволит Go сразу выделить достаточное количество бакетов и избежать промежуточных эвакуаций.
  • Удаление элементов из map не уменьшает количество бакетов. Если нужно освободить память — создайте новую map и скопируйте оставшиеся элементы.
  • Не полагайтесь на порядок итерации по map, особенно при параллельных модификациях.

Вопрос 4. Как вернуть ошибку из функции handle без использования внешних пакетов, если функция возвращает интерфейс error?

Таймкод: 00:15:29

Ответ собеседника: Правильный. Кандидат создал собственную структуру MyError с полем Text, реализовал метод Error() string для удовлетворения интерфейса error, и вернул указатель на экземпляр структуры из функции handle. Решение корректное и рабочее.

Правильный ответ:

Интерфейс error в Go — один из самых простых встроенных интерфейсов:

type error interface {
Error() string
}

Чтобы вернуть ошибку, достаточно реализовать метод Error() string для любого типа. Рассмотрим все основные способы сделать это без внешних пакетов.

Способ 1 — Собственный тип ошибки (рекомендуемый)

package main

import "fmt"

// Определяем свой тип ошибки
type MyError struct {
Code int
Message string
}

// Реализуем интерфейс error
func (e *MyError) Error() string {
return fmt.Sprintf("error %d: %s", e.Code, e.Message)
}

func handle() error {
// Возвращаем указатель на структуру, реализующую error
return &MyError{
Code: 42,
Message: "something went wrong",
}
}

func main() {
err := handle()
if err != nil {
fmt.Println(err) // error 42: something went wrong

// Можно извлечь конкретный тип для доступа к полям
if myErr, ok := err.(*MyError); ok {
fmt.Printf("Code: %d\n", myErr.Code)
}
}
}

Способ 2 — Использование встроенного пакета errors (стандартная библиотека)

Хотя кандидат упомянул «без внешних пакетов», пакет errors входит в стандартную библиотеку Go и является идиоматическим способом:

import "errors"

func handle() error {
return errors.New("something went wrong")
}

Для форматированных ошибок:

import "fmt"

func handle(userID int) error {
return fmt.Errorf("user %d not found", userID)
}

Способ 3 — Обёртка ошибок с контекстом (Go 1.13+)

import "fmt"

func handle() error {
if err := doSomething(); err != nil {
// %w позволяет обернуть ошибку, сохраняя оригинал
return fmt.Errorf("handle failed: %w", err)
}
return nil
}

func main() {
err := handle()
if err != nil {
// errors.Is и errors.As позволяют проверять цепочку ошибок
// if errors.Is(err, ErrNotFound) { ... }
// var myErr *MyError
// if errors.As(err, &myErr) { ... }
}
}

Способ 4 — Предопределённые ошибки (sentinel errors)

import "errors"

var ErrNotFound = errors.New("not found")
var ErrInvalidInput = errors.New("invalid input")

func handle(id int) error {
if id <= 0 {
return ErrInvalidInput
}
// ...
return ErrNotFound
}

func main() {
err := handle(-1)
if errors.Is(err, ErrInvalidInput) {
fmt.Println("Invalid input provided")
}
}

Способ 5 — Тип ошибки с дополнительной логикой

type ValidationError struct {
Field string
Value interface{}
Rule string
}

func (e *ValidationError) Error() string {
return fmt.Sprintf("validation failed on field '%s': value %v violates rule '%s'",
e.Field, e.Value, e.Rule)
}

// Реализуем Unwrap для поддержки цепочек ошибок
func (e *ValidationError) Unwrap() error {
return ErrValidation
}

var ErrValidation = errors.New("validation error")

func validateAge(age int) error {
if age < 0 || age > 150 {
return &ValidationError{
Field: "age",
Value: age,
Rule: "must be between 0 and 150",
}
}
return nil
}

Когда какой способ использовать:

СитуацияРекомендуемый способ
Простая текстовая ошибкаerrors.New() или fmt.Errorf()
Ошибка с кодом/полямиСобственный тип с методом Error()
Нужно проверять тип ошибки в вызывающем кодеSentinel errors + errors.Is()
Нужно добавить контекст к существующей ошибкеfmt.Errorf("...: %w", err) + errors.Unwrap()
Ошибка с несколькими полями и сложной логикойСобственный тип с Error(), Unwrap(), дополнительными методами

Важные нюансы:

  • Принято возвращать error как последнее значение из функции: func foo() (Result, error).
  • Проверка if err != nil — идиоматический паттерн в Go.
  • Не используйте panic для обработки штатных ошибок — только для действительно неисправимых ситуаций.
  • Начиная с Go 1.13, предпочитайте %w вместо %v при обёртывании ошибок — это позволяет использовать errors.Is() и errors.As() для анализа цепочки.

Вопрос 5. Что произойдёт, если убрать амперсанд (&) при возврате структуры из функции — будет ли это работать? Объясните разницу между value receiver и pointer receiver в контексте интерфейсов.

Таймкод: 00:18:49

Ответ собеседника: Частично правильный. Кандидат предположил, что без амперса код должен работать, так как метод Error() реализован с pointer receiver, а при возврате самой структуры (без &) метод всё равно будет принадлежать структуре. Кандидат затруднился с точной формулировкой. Интервьюер пояснил правило: если все методы интерфейса реализованы с value receiver, то интерфейсу соответствуют и значение, и указатель; если хотя бы один метод реализован с pointer receiver, то интерфейсу соответствует только указатель. Кандидат в итоге согласился с этим объяснением.

Правильный ответ:

Правило соответствия интерфейсу в зависимости от receiver

Это одно из самых важных и часто проверяемых правил в Go:

  • Если все методы интерфейса реализованы с value receiver (func (e MyError) Error()), то интерфейсу соответствуют и значение (MyError), и указатель (*MyError).
  • Если хотя бы один метод интерфейса реализован с pointer receiver (func (e *MyError) Error()), то интерфейсу соответствует только указатель (*MyError).

Почему так происходит

Value receiver работает как с значениями, так и с указателями, потому что Go автоматически разыменовывает указатель: если у вас есть *MyError и метод определён как (e MyError) Error(), Go выполнит (*ptr).Error().

Pointer receiver работает только с указателями, потому что метод может изменять состояние объекта, а значение, переданное по значению, не может быть гарантированно адресуемым (например, временное выражение или результат функции).

Демонстрация проблемы

package main

import "fmt"

type MyError struct {
Code int
Message string
}

// Pointer receiver — метод определён на указателе
func (e *MyError) Error() string {
return fmt.Sprintf("error %d: %s", e.Code, e.Message)
}

func handleWithPointer() error {
// Работает: возвращаем *MyError — указатель реализует error
return &MyError{Code: 42, Message: "something went wrong"}
}

func handleWithValue() error {
// ОШИБКА КОМПИЛЯЦИИ:
// MyError does not implement error (Error method has pointer receiver)
return MyError{Code: 42, Message: "something went wrong"}
}

func main() {
err := handleWithPointer()
fmt.Println(err) // error 42: something went wrong
}

Исправление: value receiver

package main

import "fmt"

type MyError struct {
Code int
Message string
}

// Value receiver — метод определён на значении
func (e MyError) Error() string {
return fmt.Sprintf("error %d: %s", e.Code, e.Message)
}

func handleWithValue() error {
// Работает: MyError реализует error
return MyError{Code: 42, Message: "something went wrong"}
}

func handleWithPointer() error {
// Тоже работает: *MyError тоже реализует error
return &MyError{Code: 42, Message: "something went wrong"}
}

func main() {
fmt.Println(handleWithValue()) // error 42: something went wrong
fmt.Println(handleWithPointer()) // error 42: something went wrong
}

Наглядная таблица совместимости

Метод определён наMyError реализует интерфейс?*MyError реализует интерфейс?
func (e MyError) Error()ДаДа
func (e *MyError) Error()НетДа
Смешано: один на MyError, другой на *MyErrorНетДа

Практический пример с несколькими методами

package main

import "fmt"

type StringerError interface {
Error() string
String() string
}

type AppError struct {
Code int
Msg string
}

// Value receiver
func (e AppError) String() string {
return fmt.Sprintf("[%d] %s", e.Code, e.Msg)
}

// Pointer receiver — достаточно одного!
func (e *AppError) Error() string {
return e.String()
}

func createError() StringerError {
// ОШИБКА: AppError не реализует StringerError,
// потому что Error() определён на *AppError
// return AppError{Code: 1, Msg: "fail"}

// Правильно: возвращаем указатель
return &AppError{Code: 1, Msg: "fail"}
}

func main() {
err := createError()
fmt.Println(err) // [1] fail
}

Когда использовать value receiver, а когда pointer receiver

Value receiver, когда:

  • Метод не изменяет состояние объекта (только читает).
  • Тип маленький и копирование дёшево.
  • Вы хотите, чтобы интерфейсу соответствовали и значения, и указатели.
  • Тип — неизменяемая структура (например, time.Time).

Pointer receiver, когда:

  • Метод изменяет состояние объекта.
  • Структура большая и копирование дорого.
  • Нужна мутабельность (например, sync.Mutex внутри структуры — нельзя копировать).
  • Вы хотите явно ограничить интерфейс только указателями.

Рекомендация по стилю

Для единообразия выбирайте один стиль для всех методов типа:

  • Если хотя бы один метод требует pointer receiver — делайте все методы pointer receiver.
  • Это упрощает понимание кода и предотвращает путаницу с реализацией интерфейсов.

Вопрос 6. Что выведет программа с циклом range по слайсу и сохранением указателей на элементы в слайс указателей?

Таймкод: 00:25:58

Ответ собеседника: Правильный. Кандидат верно объяснил, что в цикле range переменная v создаётся одна и та же на всех итерациях (до Go 1.22), поэтому все указатели в слайсе second указывают на одну и ту же область памяти. В итоге все элементы second содержат указатель на последний элемент исходного слайса (40). Программа выведет 40 и 40. Кандидат также упомянул, что в Go 1.22 это поведение изменилось — теперь v создаётся заново на каждой итерации.

Правильный ответ:

Проблема с циклом range и указателями (Go < 1.22)

Классический пример бага:

package main

import "fmt"

func main() {
first := []int{10, 20, 30, 40}
var second []*int

for _, v := range first {
second = append(second, &v)
}

// Что выведет?
fmt.Println(*second[0]) // ?
fmt.Println(*second[3]) // ?
}

Ответ для Go версии до 1.22: 40 и 40.

Пошаговый разбор (Go < 1.22)

В версиях Go до 1.22 переменная v в цикле range создаётся один раз и переиспользуется на каждой итерации. Её адрес в памяти не меняется.

Итерация 0: v = 10, &v = 0x1000 → second[0] = 0x1000
Итерация 1: v = 20, &v = 0x1000 → second[1] = 0x1000
Итерация 2: v = 30, &v = 0x1000 → second[2] = 0x1000
Итерация 3: v = 40, &v = 0x1000 → second[3] = 0x1000

Все четыре указателя в second указывают на одну и ту же ячейку памяти, в которой на момент вывода лежит значение 40 (последнее присвоенное).

Вывод:

40
40

Как исправить (Go < 1.22)

Способ 1 — Взять указатель на элемент исходного слайса по индексу:

for i := range first {
second = append(second, &first[i])
}

Способ 2 — Создать локальную копию внутри цикла:

for _, v := range first {
v := v // теневое переменная — создаётся новый v на каждой итерации
second = append(second, &v)
}

Способ 3 — Использовать индекс напрямую:

for i, _ := range first {
second = append(second, &first[i])
}

Изменение в Go 1.22 (переменные цикла)

Начиная с Go 1.22 (февраль 2024), поведение изменилось. Теперь переменные цикла for и range создаются заново на каждой итерации. Это часть предложения Go Loopvar Proposal, которое было принято.

С Go 1.22 тот же код выводит:

10
40

Потому что теперь каждая v — это отдельная переменная с собственным адресом.

Проверка версии:

go version
# go version go1.22.0 linux/amd64

Или в go.mod:

module example.com/myapp

go 1.22

Если версия ниже 1.22, можно включить новое поведение через переменную окружения:

GOEXPERIMENT=loopvar go run main.go

Более сложный пример — горутины внутри range

Эта проблема особенно коварна с горутинами:

// Go < 1.22 — БАГ
func main() {
first := []int{10, 20, 30, 40}
var wg sync.WaitGroup

for _, v := range first {
wg.Add(1)
go func() {
defer wg.Done()
fmt.Println(v) // Все горутины напечатают 40!
}()
}
wg.Wait()
}

Исправление:

// Способ 1 — передача как аргумент функции
for _, v := range first {
wg.Add(1)
go func(val int) {
defer wg.Done()
fmt.Println(val) // Корректно: 10, 20, 30, 40 (в произвольном порядке)
}(v)
}

// Способ 2 — теневая переменная
for _, v := range first {
v := v // новая v на каждой итерации
wg.Add(1)
go func() {
defer wg.Done()
fmt.Println(v) // Корректно
}()
}

Резюме

Версия GoПоведение v в rangeРезультат *second[0]Результат *second[3]
< 1.22Одна переменная, переиспользуется4040
>= 1.22Новая переменная на каждой итерации1040

Практическая рекомендация: Даже с Go 1.22+ явное указание намерений делает код чище и понятнее. Если вам нужен указатель на элемент — берите &first[i] вместо &v.

Вопрос 7. Как сделать вызов непредсказуемого внешнего сервиса (функция discount) более контролируемым, чтобы избежать бесконечного ожидания ответа?

Таймкод: 00:33:24

Ответ собеседника: Правильный. Кандидат предложил использовать контекст с таймаутом (context.WithTimeout), обернул вызов в отдельную функцию, принимающую контекст. Для обработки результата использовал select с case ctx.Done(), чтобы отловить истечение таймаута. Также кандидат предложил возвращать ошибку через ctx.Error() при срабатывании таймаута. Решение корректное и соответствует лучшим практикам Go.

Правильный ответ:

Работа с непредсказуемыми внешними сервисами — одна из ключевых задач в production-коде. Go предоставляет несколько механизмов для контроля времени ожидания.

1. Контекст с таймаутом (context.WithTimeout)

Это основной и наиболее идиоматичный подход в Go:

package main

import (
"context"
"fmt"
"time"
)

// Имитация непредсказуемого внешнего сервиса
func discount(ctx context.Context, userID int) (float64, error) {
// Имитация долгого HTTP-запроса
resultCh := make(chan float64, 1)

go func() {
// Долгая работа: от 1 секунды до 10 секунд
time.Sleep(time.Duration(1+userID%10) * time.Second)
resultCh <- 0.15 // 15% скидка
}()

select {
case result := <-resultCh:
return result, nil
case <-ctx.Done():
return 0, fmt.Errorf("discount service timeout: %w", ctx.Err())
}
}

func main() {
// Таймаут 3 секунды
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()

disc, err := discount(ctx, 42)
if err != nil {
fmt.Println("Error:", err)
return
}
fmt.Printf("Discount: %.0f%%\n", disc*100)
}

2. Контекст с дедлайном (context.WithDeadline)

Отличие от WithTimeout — указывается конкретный момент времени, а не длительность:

deadline := time.Now().Add(3 * time.Second)
ctx, cancel := context.WithDeadline(context.Background(), deadline)
defer cancel()

3. HTTP-клиент с таймаутом

Для HTTP-запросов можно задавать таймаут на уровне клиента:

package main

import (
"context"
"fmt"
"net/http"
"time"
)

func callDiscountService(ctx context.Context, userID int) (float64, error) {
// Клиент с общим таймаутом
client := &http.Client{
Timeout: 5 * time.Second, // Таймаут на весь запрос
}

req, err := http.NewRequestWithContext(ctx, "GET",
fmt.Sprintf("https://api.example.com/discount?user=%d", userID), nil)
if err != nil {
return 0, err
}

resp, err := client.Do(req)
if err != nil {
return 0, fmt.Errorf("discount request failed: %w", err)
}
defer resp.Body.Close()

if resp.StatusCode != http.StatusOK {
return 0, fmt.Errorf("unexpected status: %d", resp.StatusCode)
}

// Парсинг ответа...
return 0.15, nil
}

4. Гранулярные таймауты через http.Transport

Для более точного контроля над этапами HTTP-запроса:

client := &http.Client{
Timeout: 10 * time.Second, // Общий таймаут
Transport: &http.Transport{
DialContext: (&net.Dialer{
Timeout: 2 * time.Second, // Таймаут на TCP-соединение
KeepAlive: 30 * time.Second,
}).DialContext,
TLSHandshakeTimeout: 2 * time.Second, // Таймаут на TLS
ResponseHeaderTimeout: 3 * time.Second, // Таймаут на заголовки ответа
IdleConnTimeout: 90 * time.Second,
},
}

5. Паттерн Circuit Breaker

Для защиты от каскадных отказов, когда сервис постоянно недоступен:

package main

import (
"context"
"errors"
"sync"
"time"
)

var ErrCircuitOpen = errors.New("circuit breaker is open")

type CircuitBreaker struct {
mu sync.Mutex
failureCount int
threshold int // Количество ошибок до размыкания
resetTimeout time.Duration // Время до попытки восстановления
lastFailureTime time.Time
state string // "closed", "open", "half-open"
}

func NewCircuitBreaker(threshold int, resetTimeout time.Duration) *CircuitBreaker {
return &CircuitBreaker{
threshold: threshold,
resetTimeout: resetTimeout,
state: "closed",
}
}

func (cb *CircuitBreaker) Call(ctx context.Context, fn func(ctx context.Context) (float64, error)) (float64, error) {
cb.mu.Lock()

if cb.state == "open" {
if time.Since(cb.lastFailureTime) > cb.resetTimeout {
cb.state = "half-open"
} else {
cb.mu.Unlock()
return 0, ErrCircuitOpen
}
}
cb.mu.Unlock()

result, err := fn(ctx)

cb.mu.Lock()
defer cb.mu.Unlock()

if err != nil {
cb.failureCount++
cb.lastFailureTime = time.Now()
if cb.failureCount >= cb.threshold {
cb.state = "open"
}
return 0, err
}

// Успешный вызов — сброс
cb.failureCount = 0
cb.state = "closed"
return result, nil
}

func main() {
cb := NewCircuitBreaker(3, 30*time.Second)

ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()

result, err := cb.Call(ctx, func(ctx context.Context) (float64, error) {
return discount(ctx, 42)
})
if err != nil {
switch {
case errors.Is(err, ErrCircuitOpen):
fmt.Println("Service unavailable, circuit is open")
case errors.Is(err, context.DeadlineExceeded):
fmt.Println("Request timed out")
default:
fmt.Println("Error:", err)
}
return
}
fmt.Printf("Discount: %.0f%%\n", result*100)
}

6. Паттерн Retry с экспоненциальной backoff

package main

import (
"context"
"fmt"
"math"
"math/rand"
"time"
)

func retryWithBackoff(ctx context.Context, maxRetries int, fn func() (float64, error)) (float64, error) {
var lastErr error
baseDelay := 100 * time.Millisecond

for attempt := 0; attempt <= maxRetries; attempt++ {
result, err := fn()
if err == nil {
return result, nil
}
lastErr = err

if attempt < maxRetries {
// Экспоненциальная задержка с jitter
delay := time.Duration(math.Pow(2, float64(attempt))) * baseDelay
jitter := time.Duration(rand.Int63n(int64(delay / 2)))
totalDelay := delay + jitter

select {
case <-time.After(totalDelay):
// Продолжаем следующую попытку
case <-ctx.Done():
return 0, fmt.Errorf("context cancelled during retry: %w", ctx.Err())
}
}
}

return 0, fmt.Errorf("all %d retries exhausted: %w", maxRetries, lastErr)
}

func main() {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()

result, err := retryWithBackoff(ctx, 3, func() (float64, error) {
// Нестабильный вызов
return discount(ctx, 42)
})
if err != nil {
fmt.Println("Failed:", err)
return
}
fmt.Printf("Discount: %.0f%%\n", result*100)
}

7. Комбинированный подход: Timeout + Retry + Circuit Breaker

В production-системе эти паттерны комбинируются:

func getDiscountWithResilience(ctx context.Context, userID int) (float64, error) {
// Внешний контекст с общим таймаутом
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()

// Retry с backoff
result, err := retryWithBackoff(ctx, 3, func() (float64, error) {
// Каждый вызов со своим таймаутом
callCtx, callCancel := context.WithTimeout(ctx, 3*time.Second)
defer callCancel()

// Circuit Breaker
return cb.Call(callCtx, func(c context.Context) (float64, error) {
return discount(c, userID)
})
})

if err != nil {
// Fallback: возвращаем значение по умолчанию
return 0, nil
}
return result, nil
}

Сравнение подходов:

ПодходКогда использоватьСложность
context.WithTimeoutБазовый контроль времени ожиданияНизкая
http.Client.TimeoutHTTP-запросыНизкая
Circuit BreakerЗащита от каскадных отказов при частых ошибкахСредняя
Retry + BackoffВременные сбои сети, нестабильные сервисыСредняя
FallbackКритичность ответа — можно вернуть дефолтное значениеНизкая

Ключевые принципы:

  • Всегда передавайте context.Context как первый аргумент функции.
  • Используйте defer cancel() сразу после создания контекста с таймаутом.
  • Оборачивайте ошибки с %w для сохранения цепочки: fmt.Errorf("discount failed: %w", err).
  • Для критичных путей реализуйте fallback-логику, чтобы сервис продолжал работать даже при недоступности зависимостей.