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

Открытое собеседование Golang разработчика

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

Сегодня мы разберём собеседование стажёра-разработчика на Go, который уже имеет трёхлетний опыт работы с языком и стремится перейти на уровень мидл. Кандидат уверенно отвечает на вопросы по строкам, массивам, слайсам и конкурентности, демонстрирует понимание горутин, каналов, примитивов синхронизации и практический опыт работы с ошибками и контекстом, но при этом признаёт пробелы во внутреннем устройстве мап и каналов, а также в системном дизайне, что в итоге оценивается как уровень «мидл минус» с перспективой роста.

Вопрос 1. Расскажи о своём опыте работы.

Таймкод: 00:00:34

Ответ собеседника: Правильный. Работаю стажёром в Яндексе, пишу на Go уже около трёх лет, в основном на пет-проектах, коммерческого опыта пока нет. Люблю язык Go и готов проходить собеседование.

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

Ответ собеседника корректен и честен — это важно на собеседовании. Самопрезентация дана кратко, без лишних деталей, что уместно для открывающего вопроса.

Как можно усилить ответ при подготовке:

1. Структурировать рассказ

Вместо общего перечисления стоит дать короткую структуру: текущая роль → ключевые задачи → технологический стек → достижения или интересные задачи.

2. Привести конкретику

Даже если опыт — пет-проекты, можно упомянуть, что именно делали: писали HTTP-сервисы, работали с базами данных, настраивали CI/CD, писали тесты. Это показывает практические навыки.

3. Связать с ваканцией

Если известно, на какую позицию идёшь, стоит подчеркнуть релевантный опыт: «В пет-проектах писал микросервисы на Go с использованием gRPC и PostgreSQL — это близко к стеку вашей команды».

Пример усиленного ответа:

> «Пишу на Go около трёх лет. Сейчас стажёр в Яндексе, где занимаюсь [конкретная область]. Параллельно веду несколько пет-проектов — например, написал REST API на Go с PostgreSQL, настроил линтеры и CI через GitHub Actions. Изучаю внутренние процессы и лучшие практики разработки в крупной компании. Хочу развиваться в бэкенд-разработке на Go и присоединиться к команде, где смогу решать боевые задачи».

Главное — честность, структура и демонстрация интереса к предметной области.

Вопрос 2. Есть ли темы в Go, которые тебе более или менее интересны, чтобы сфокусировать собеседование на них?

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

Ответ собеседника: Правильный. Go мне чуть менее интересен, чем системный дизайн, но в Go я не плаваю — понимаю базовые вещи, знаю про конкурентные паттерны. Сказал, что это остаётся на усмотрение собеседующего.

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

Ответ собеседателя уместен — он честно обозначил приоритеты, но при этом не закрыл дверь к вопросам по Go. Передать инициативу собеседующему — разумный ход для стажёра.

Как усилить ответ:

1. Конкретизировать сильные стороны

Вместо расплывчатого «понимаю базовые вещи» лучше назвать конкретные темы, в которых уверен:

  • Горутины и каналы
  • Интерфейсы и композиция
  • Обработка ошибок
  • Работа с пакетом context
  • Написание тестов с testing и testify

2. Показать готовность углубиться

Можно добавить: «Если хотите, могу подробнее рассказать про конкурентные паттерны — реализовывал worker pool и pipeline в пет-проектах».

3. Связать с опытом

Упоминание реальных примеров из пет-проектов добавляет веса ответу.

Пример усиленного ответа:

> «Могу рассказать про конкурентность в Go — реализовывал worker pool и pipeline в пет-проектах. Также знаком с интерфейсами, композицией и обработкой ошибок. Если интересны другие темы — системный дизайн тоже в зоне интереса. Оставляю выбор за вами».

Такой ответ показывает и знание предмета, и готовность к углублённым вопросам.

Вопрос 3. Дана строка из латинских букв. Вернуть все символы, которые встречаются в строке ровно дважды. Как бы ты решал эту задачу?

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

Ответ собеседника: Правильный. Предложил два прохода: первый — посчитать количество вхождений каждого символа в map, второй — пройтись по map и вернуть те символы, у которых счётчик равен 2. Также отметил, что поскольку алфавит ограничен 26 буквами, расход памяти будет O(1) — константа. В итоге реализовал решение с использованием map.

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

Алгоритм

Классический подход — два прохода по строке:

  1. Первый проход: подсчитать частоту каждого символа с помощью хеш-таблицы (map).
  2. Второй проход: собрать символы, у которых счётчик равен 2.

Сложность

  • Время: O(n), где n — длина строки.
  • Память: O(1), так как алфавит ограничен (26 строчных или 52 символа с учётом регистра), размер map не зависит от длины входной строки.

Реализация на Go

func charsAppearingExactlyTwice(s string) []rune {
// Подсчёт частот
freq := make(map[rune]int)
for _, ch := range s {
freq[ch]++
}

// Сбор результата
result := make([]rune, 0)
for ch, count := range freq {
if count == 2 {
result = append(result, ch)
}
}
return result
}

Альтернатива с массивом вместо map

Поскольку алфавит ограничен, можно использовать фиксированный массив вместо map — это даст лучшую производительность за счёт отсутствия хеширования и кэш-дружественности:

func charsAppearingExactlyTwice(s string) []rune {
var freq [26]int
for _, ch := range s {
freq[ch-'a']++
}

result := make([]rune, 0)
for i, count := range freq {
if count == 2 {
result = append(result, rune('a'+i))
}
}
return result
}

Что стоит отметить на собеседовании:

  • Если алфавит ограничен — массив эффективнее map.
  • Если строка может содержать Unicode-символы — нужен map[rune]int вместо массива.
  • Порядок символов в результате зависит от порядка итерации по map (в Go он случайный). Если нужен детерминированный порядок — можно итерироваться по исходной строке или отсортировать результат.

Вопрос 4. Как устроены строки в Go под капотом?

Таймкод: 00:06:07

Ответ собеседника: Неполный. Строка — это массив байтов (слайс) в кодировке UTF-8. Под слайсом лежит массив. Не вспомнил про поле длины в структуре строки, но после подсказки подтвердил, что длина хранится прямо в структуре.

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

Внутреннее представление

Строка в Go — это структура из двух полей (по сути, указатель + длина):

// reflect.StringHeader
type StringHeader struct {
Data uintptr // указатель на массив байтов
Len int // длина строки в байтах
}

Это не слайс. Слайс имеет структуру SliceHeader с тремя полями (Data, Len, Cap), а строка — только два. Строка не хранит capacity, потому что она неизменяема.

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

1. Неизменяемость (immutable)

Строки в Go иммутабельны. Любая «модификация» строки создаёт новую строку. Это позволяет безопасно передавать строки между горутинами без синхронизации.

2. Кодировка UTF-8

Go хранит строки как последовательность байтов в UTF-8. Один символ (rune) может занимать от 1 до 4 байтов. Поэтому len("Привет") вернёт 12 (байтов), а не 6 (символов). Для подсчёта символов используется utf8.RuneCountInString().

3. Нулевой байт не нужен

В отличие от C, строки в Go не завершаются нулевым байтом \0. Длина хранится явно в структуре.

4. Пустая строка vs nil

Пустая строка "" — это валидная строка с Len = 0. Переменная типа string по умолчанию инициализируется как "", а не nil.

Примеры

s := "Hello"
// s — это StringHeader{Data: <указатель>, Len: 5}

// Получение подстроки — не копирует данные, а создаёт новый StringHeader
sub := s[1:3] // "ell" — указывает на ту же область памяти

// Итерация по байтам
for i := 0; i < len(s); i++ {
fmt.Println(s[i]) // байты: 72, 101, 108, 108, 111
}

// Итерация по рунам (символам)
for _, r := range s {
fmt.Println(r) // символы: H, e, l, l, o
}

Почему это важно на практике

  • Операция s[i] возвращает байт, а не символ — для многобайтовых символов это может быть не то, что ожидается.
  • Конкатенация строк в цикле создаёт много промежуточных объектов — для этого лучше использовать strings.Builder.
  • Подстрока разделяет память с оригиналом — если держать маленькую подстроку от огромной строки, оригинал не будет собран GC.

Вопрос 5. Как посчитать количество символов (рун) в строке с не-ASCII символами (например, UTF-8)?

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

Ответ собеседника: Правильный. Можно использовать range по строке — он итерируется по рунам, корректно обрабатывая UTF-8. Также можно использовать пакет utf8 и его функцию RuneCountInString. Назвал оба способа.

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

Собеседник верно назвал оба основных способа. Раскроем детали.

Способ 1: utf8.RuneCountInString

Самый прямой и идиоматичный способ:

import "unicode/utf8"

s := "Привет, мир!"
count := utf8.RuneCountInString(s) // 12

Функция проходит по байтам строки и декодирует каждую руну, подсчитывая их количество. Сложность — O(n).

Способ 2: Итерация через range

s := "Привет, мир!"
count := 0
for range s {
count++
}

Ключевое отличие range от обычного for i := 0; i < len(s); i++: range по строке декодирует UTF-8 и итерируется по рунам, а не по байтам.

Способ 3: len([]rune(s))

s := "Привет, мир!"
count := len([]rune(s)) // 12

Важное предупреждение: этот способ создаёт копию всей строки в виде []rune, что требует дополнительной аллокации памяти. Для длинных строк это неэффективно. Используйте utf8.RuneCountInString вместо этого.

Типичная ошибка: len(s)

s := "Привет"
fmt.Println(len(s)) // 12 — байты!
fmt.Println(utf8.RuneCountInString(s)) // 6 — символы

Итог:

СпособПамятьКогда использовать
utf8.RuneCountInStringO(1)Всегда предпочтительно
range + счётчикO(1)Когда нужна и итерация, и подсчёт
len([]rune(s))O(n)Избегать — лишняя аллокация

Вопрос 6. Что не так с кодом, где в цикле к строке добавляется символ через += (конкатенация)?

Таймкод: 00:08:27

Ответ собеседника: Правильный. Конкатенация строк через += не производительна, потому что строки в Go неизменяемы, и на каждой итерации выделяется новая память, строка копируется и перевыделяется. Для таких случаев лучше использовать strings.Builder или bytes.Buffer, а в конце конвертировать в string.

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

Собеседник дал полный и точный ответ. Дополним деталями.

Проблема: квадратичная сложность

Поскольку строки иммутабельны, каждая операция s += "x" создаёт новую строку, копируя все предыдущие данные:

// Плохо: O(n²) по времени и памяти
var s string
for i := 0; i < n; i++ {
s += "x"
}

На каждой итерации i копируется i символов. Суммарно: 1 + 2 + 3 + ... + n = O(n²).

Решение 1: strings.Builder

import "strings"

var builder strings.Builder
for i := 0; i < n; i++ {
builder.WriteByte('x')
}
result := builder.String()

strings.Builder использует внутренний буфер ([]byte), который растёт экспоненциально (как слайс), что даёт амортизированную O(n) сложность.

Решение 2: bytes.Buffer

import "bytes"

var buf bytes.Buffer
for i := 0; i < n; i++ {
buf.WriteByte('x')
}
result := buf.String()

bytes.Buffer аналогичен, но имеет чуть больше накладных расходов из-за интерфейса io.Writer.

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

// Benchmark для n = 10000
// Naive concat: ~500000 ns/op ~400 KB/op ~200 allocs/op
// strings.Builder: ~5000 ns/op ~20 KB/op ~1 alloc/op

Разница — порядка 100x.

Когда += допустим

  • Конкатенация фиксированного числа строк: fullName := firstName + " " + lastName — компилятор оптимизирует это.
  • Не в горячем пути и не в цикле.

Итог: в циклах всегда используйте strings.Builder. Это стандартная идиома Go, которую ожидают увидеть на собеседовании.

Вопрос 7. Чем отличаются массивы и слайсы в Go?

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

Ответ собеседника: Правильный. Массив — это область памяти фиксированного размера, являющаяся отдельным типом данных. Массив из 2 int и массив из 3 int — разные типы на уровне компилятора. Слайс содержит под собой массив, а также имеет длину (len) и ёмкость (cap). При выходе за пределы ёмкости память перевыделяется, массив копируется в новую область с увеличенным размером. Также знает синтаксис определения массива фиксированного размера: [N]Type&#123;&#125;.

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

Собеседник дал отличный ответ. Дополним структурой и деталями.

Массив (Array)

Массив — это значение (value type) фиксированной длины. Размер является частью типа.

var a [5]int // массив из 5 нулей
b := [3]int{1, 2, 3} // инициализация
c := [...]int{1, 2, 3} // компилятор сам определит размер: [3]int

Ключевые свойства массива:

  • Размер — часть типа: [3]int и [5]int — несовместимые типы.
  • Передаётся в функцию копией (весь массив копируется на стек).
  • Сравнивается через == (если элементы сравнимы).
  • Размер известен на этапе компиляции.

Слайс (Slice)

Слайс — это динамическая обёртка над массивом. Это структура из трёх полей:

// reflect.SliceHeader
type SliceHeader struct {
Data uintptr // указатель на underlying array
Len int // текущая длина
Cap int // ёмкость (максимальная длина без реаллокации)
}
s := []int{1, 2, 3} // слайс без явного указания размера
s2 := make([]int, 5) // len=5, cap=5, заполнен нулями
s3 := make([]int, 0, 10) // len=0, cap=10

Ключевые свойства слайса:

  • Передаётся в функцию по ссылке (копируется только заголовок SliceHeader — 24 байта), но underlying array общий.
  • При append за пределами cap происходит реаллокация (обычно cap удваивается).
  • Нельзя сравнивать через == (только с nil или через reflect.DeepEqual / slices.Equal).

Сравнительная таблица

ХарактеристикаМассив [5]intСлайс []int
РазмерФиксирован, часть типаДинамический
Передача в функциюКопия всего массиваКопия заголовка (24 байта)
Сравнение ==ДаТолько с nil
Использование на практикеРедко (хеши, фиксированные буферы)Повсеместно

Типичная ловушка с передачей массива:

func modify(a [3]int) {
a[0] = 999 // модифицирует копию
}

func modifySlice(s []int) {
s[0] = 999 // модифицирует underlying array
}

Итог: в повседневной разработке на Go используются почти исключительно слайсы. Массивы применяются редко — например, для хранения фиксированных хешей ([32]byte), криптографических ключей или когда нужна гарантия отсутствия аллокаций.

Вопрос 8. Что будет лежать в структуре слайса (указатель, длина, capacity) при разных способах инициализации: неинициализированный var, make без параметров и make с указанием длины/ёмкости?

Таймкод: 00:11:38

Ответ собеседния: Неполный. Для неинициализированного слайса: указатель nil, длина 0, capacity 0 — ответил правильно. Для make без параметров: указатель не nil (ссылается на реальный массив), длина 0, capacity 0 — правильно определил, что capacity будет 0, но не был уверен насчёт указателя. Для make с параметрами: указатель не nil, длина и capacity заданы — ответил верно.

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

Разберём все случаи подробно.

1. Неинициализированный слайс (var s []int)

var s []int
// Data: nil
// Len: 0
// Cap: 0

Слайс равен nil. s == nil вернёт true. Это nil-слайс.

2. make([]int, 0) — без третьего параметра

s := make([]int, 0)
// Data: указатель на выделенный (пустой) массив
// Len: 0
// Cap: 0

s == nil вернёт false, потому что указатель Data не nil — Go выделяет underlying array нулевого размера. Это пустой ненулевой слайс.

3. make([]int, 5) — длина 5, ёмкость по умолчанию

s := make([]int, 5)
// Data: указатель на массив из 5 элементов
// Len: 5
// Cap: 5
// Значения: [0, 0, 0, 0, 0] — zero values

4. make([]int, 0, 10) — длина 0, ёмкость 10

s := make([]int, 0, 10)
// Data: указатель на массив из 10 элементов
// Len: 0
// Cap: 10

Слайс пуст, но память под 10 элементов уже выделена. append не вызовет реаллокацию первые 10 раз.

5. Литерал []int{}

s := []int{}
// Data: указатель на выделенный (пустой) массив
// Len: 0
// Cap: 0

Аналогичен make([]int, 0) — пустой ненулевой слайс, s == nil вернёт false.

Сводная таблица

ИнициализацияDataLenCaps == nil
var s []intnil00true
s := []int{}не nil00false
make([]int, 0)не nil00false
make([]int, 5)не nil55false
make([]int, 0, 10)не nil010false

Почему это важно:

  • JSON-сериализация: nil-слайс сериализуется как null, а пустой []int{} — как []. Это частый источник багов.
  • Проверка на пустоту: всегда используйте len(s) == 0, а не s == nil, если не хотите различать nil и пустой слайс.
  • Передача в функции: nil-слайс и пустой слайс ведут себя одинаково в for range и append, но могут вести себя иначе при сериализации или сравнении.

Вопрос 9. При сериализации в JSON, чем будет отличаться неинициализированный слайс (nil) от инициализированного пустого слайса (make)?

Таймкод: 00:14:20

Ответ собеседника: Правильный. Неинициализированный слайс (nil) сериализуется в null, а инициализированный пустой слайс (make) — в пустой массив []. Это частая ошибка на собеседованиях.

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

Собеседник абсолютно прав. Дополним примерами и контекстом.

Демонстрация

package main

import (
"encoding/json"
"fmt"
)

type Response struct {
Items []string `json:"items"`
}

func main() {
// Неинициализированный слайс
r1 := Response{}
b1, _ := json.Marshal(r1)
fmt.Println(string(b1))
// {"items":null}

// Пустой инициализированный слайс
r2 := Response{Items: []string{}}
b2, _ := json.Marshal(r2)
fmt.Println(string(b2))
// {"items":[]}
}

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

Пакет encoding/json при маршалинге проверяет, является ли слайс nil. Если да — записывает null. Если слайс не nil (даже пустой) — записывает [].

Практические последствия

1. Различное поведение на клиенте:

// null — клиент может получить NPE при обращении
response.items.forEach(...) // TypeError: Cannot read property 'forEach' из null

// [] — безопасно
response.items.forEach(...) // просто не выполнится

2. OpenAPI/Swagger-спецификации:

Некоторые валидаторы различают null и [], что может привести к ошибкам валидации API.

3. База данных (SQL):

При использовании sql.NullString или драйверов с поддержкой слайсов аналогичная проблема: nil → NULL, пустой слайс → '{}' (для PostgreSQL array).

Как избежать проблемы

Если хотите гарантировать [] вместо null, инициализируйте слайс:

// Вариант 1: инициализация при создании
r := Response{Items: []string{}}

// Вариант 2: инициализация в конструкторе
func NewResponse() Response {
return Response{Items: make([]string, 0)}
}

// Вариант 3: кастомный маршалер
func (r Response) MarshalJSON() ([]byte, error) {
if r.Items == nil {
r.Items = []string{}
}
type Alias Response
return json.Marshal(Alias(r))
}

Аналогия с картами (map)

Для map поведение аналогично: nil map → null, пустая map map[string]int{}{}.

Итог: всегда инициализируйте слайсы и map в структурах, которые сериализуются в JSON, если не хотите null в ответе. Это best practice в Go.

Вопрос 10. Как устроена map в Go под капотом?

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

Ответ собеседника: Неполный. Знал, что map — это хеш-таблица, слышал про бакеты (buckets), но глубоко не погружался в детали. Верхнеуровнево знал, что новая реализация map производительнее старой. Не смог рассказать подробности про механизм бакетов и эвакуацию.

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

Общее устройство

Map в Go — это хеш-таблица с открытой адресацией на основе бакетов. Исходный код находится в runtime/map.go.

Структура hmap

type hmap struct {
count int // количество элементов
flags uint8 // флаги (resizing, iterating и т.д.)
B uint8 // log2 количества бакетов (2^B бакетов)
noverflow uint16 // количество overflow-бакетов
hash0 uint32 // seed для хеш-функции (рандомизация)
buckets unsafe.Pointer // указатель на массив бакетов
oldbuckets unsafe.Pointer // предыдущие бакеты (при росте)
nevacuate uintptr // прогресс эвакуации
extra *mapextra // дополнительные данные (overflow pointers)
}

Бакет (bucket)

Каждый бакет — это структура, хранящая до 8 пар ключ-значение:

type bmap struct {
tophash [8]uint8 // старшие 8 бит хеша каждого ключа
// далее идут 8 ключей, затем 8 значений
// (расположение памяти: keys[0..7], values[0..7])
overflow uintptr // указатель на overflow-бакет
}

Алгоритм поиска ключа

  1. Вычисляется hash(key).
  2. Определяется бакет: hash & ((1 << B) - 1) — младшие B бит хеша.
  3. Внутри бакета ищется совпадение по tophash (старшие 8 бит хеша) — это быстрая фильтрация.
  4. Если tophash совпал — сравнивается ключ через ==.
  5. Если бакет полный — идём по цепочке overflow-бакетов.

Рост map (resizing)

Когда коэффициент загрузки превышает порог (~6.5 элементов на бакет), map растёт:

  1. Создаётся новый массив бакетов в 2 раза больше текущего.
  2. Элементы переносятся (эвакуируются) из старых бакетов в новые лениво — постепенно при каждой вставке/удалении, а не все сразу.
  3. Поле oldbuckets указывает на старый массив, пока эвакуация не завершена.

Это ленивое перенос позволяет избежать большой паузы при росте map.

Ключевые особенности реализации

1. Порядок итерации случайный

Go намеренно рандомизирует порядок итерации по map: при каждом for range m выбирается случайная начальная точка. Это защищает от зависимости кода от порядка обхода.

2. Нельзя брать адрес элемента map

p := &m["key"] // ошибка компиляции

Это связано с тем, что при росте map элементы перемещаются в новую память, и указатель станет невалидным.

3. Map не потокобезопасен

Параллельная запись в map из нескольких горутин вызывает concurrent map writes panic. Для конкурентного доступа используйте sync.Mutex, sync.RWMutex или sync.Map.

4. Типы ключей

Ключ map должен быть сравниваемым (==, !=). Нельзя использовать слайсы, map и функции как ключи. Можно: строки, числа, указатели, массивы, структуры с сравниваемыми полями.

Производительность

  • Вставка/поиск/удаление: O(1) амортизированно.
  • При росте: O(n), но распределено по операциям.
  • Хеш-функция рандомизирована через hash0 — защита от атак на коллизии (hash flooding).

Вопрос 11. Какие типы можно использовать в качестве ключей map, а какие нельзя? Почему?

Таймкод: 00:16:37

Ответ собеседника: Правильный. Нельзя использовать несравниваемые типы: слайсы, map. Можно использовать указатели (они сравнимы по адресу). Ключ должен быть неизменяемым, иначе при изменении ключа невозможно будет найти привязанные данные. После подсказки также узнал, что нельзя брать адрес элемента map из-за механизма эвакуации.

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

Собеседник дал хороший ответ. Систематизируем и дополним.

Правило: ключ map должен быть сравниваемым

Операторы == и != должны быть определены для типа ключа. Это требование компилятора Go.

Можно использовать как ключи:

ТипПример
Базовые типыint, uint, float64, string, bool, byte, rune
Указатели*int, *MyStruct — сравниваются по адресу в памяти
Массивы[3]int, [2]string — сравниваются element-by-element
Структуры с сравниваемыми полямиstruct{ Name string; Age int }
Интерфейсыдинамический тип и значение должны быть сравнимы

Нельзя использовать как ключи:

ТипПричина
Слайсы ([]int)Не поддерживают ==
Map (map[string]int)Не поддерживают ==
Функции (func())Не поддерживают ==
Структуры, содержащие несравниваемые поляНапример, struct{ Data []int }

Почему именно сравниваемость

Map использует хеш-таблицу. При коллизиях хешей нужно точно определить, совпадает ли ключ. Для этого используется сравнение через ==. Если тип не поддерживает сравнение — компилятор не может гарантировать корректность поиска.

Примеры

// Можно
m1 := map[string]int{"a": 1}
m2 := map[[3]int]string{{1,2,3}: "hello"}
m3 := map[*int]*int{} // указатели сравниваются по адресу

type Point struct{ X, Y int }
m4 := map[Point]string{{0, 0}: "origin"}

// Нельзя — ошибка компиляции
// m5 := map[[]int]string{} // слайс
// m6 := map[map[string]int]int{} // map

type BadKey struct {
Data []int // слайс внутри структуры
}
// m7 := map[BadKey]int{} // ошибка: struct containing []int cannot be compared

Важный нюанс: изменяемость ключа

Если ключ — указатель, и объект по этому адресу изменится, хеш от указателя не изменится (хешируется адрес, а не значение). Но если ключ — структура с полем-указателем, и данные по указателю изменятся так, что структустанет несовпадающей — найти элемент по старому ключу будет невозможно.

type Item struct {
ID int
Tags []string // слайс — нельзя использовать как ключ
}

// Правильно: использовать только сравниваемые поля
m := map[int]Item{} // int — ключ, Item — значение

Вопрос 12. Что произойдёт при чтении из неинициализированной (nil) map и при записи в неинициализированную map?

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

Ответ собеседника: Неполный. Не был уверен в поведении, но после подсказки узнал: чтение из nil map возвращает zero value, а запись в nil map вызывает panic (nil map assignment).

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

Собеседник после подсказки верно определил поведение. Разберём подробно.

Чтение из nil map — безопасно

var m map[string]int
v := m["key"] // v == 0 (zero value для int)
fmt.Println(v) // 0

Чтение из nil map не вызывает panic. Возвращается zero value типа значения. Это аналогично тому, как nil map ведёт себя как пустая map при чтении.

Можно даже проверить наличие ключа:

var m map[string]int
v, ok := m["key"] // v == 0, ok == false

Запись в nil map — panic

var m map[string]int
m["key"] = 42 // panic: assignment to entry in nil map

Это runtime panic. Причина проста: у nil map нет выделенной памяти под бакеты, и записывать данные некуда.

Как избежать panic

// Вариант 1: инициализация при объявлении
m := make(map[string]int)
m["key"] = 42 // OK

// Вариант 2: ленивая инициализация
var m map[string]int
if m == nil {
m = make(map[string]int)
}
m["key"] = 42 // OK

// Вариант 3: с sync.Once (для глобальных переменных)
var (
m map[string]int
once sync.Once
)

func getMap() map[string]int {
once.Do(func() {
m = make(map[string]int)
})
return m
}

Почему такая асимметрия

Это осознанное решение дизайна Go:

  • Чтение из nil map безопасно, потому что результат предсказуем (zero value). Это позволяет писать код без лишних проверок: если ключ отсутствует — получите ноль, как и в пустой map.
  • Запись в nil map — это явная ошибка программиста. Если разрешить запись — данные будут потеряны (записывать некуда), и это молча приведёт к багам. Panic делает ошибку явной и обнаружимой.

Аналогия со слайсами

Операцияnil слайсnil map
Чтение (s[i], m[k])panic (index out of range)OK, zero value
Запись (s[i] = v, m[k] = v)panic (index out of range)panic
len()00
append()OK, создаёт новый слайсN/A

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

Если структура содержит map и вы не хотите инициализировать его сразу, используйте ленивую инициализацию или проверяйте на nil перед записью. Особенно это актуально для конструкторов и методов, которые могут быть вызваны до полной инициализации объекта.

Вопрос 13. Дан код: создаётся буферизованный канал на 1000, запускается 100 горутин, каждая пишет случайное число в канал, затем функция читает из канала через range и суммирует. Что произойдёт и какие проблемы есть у этого кода?

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

Ответ собеседника: Правильный. Код приведёт к deadlock, потому что канал никогда не закрывается, и range по каналу будет бесконечно ждать новые данные. Горутины-писатели завершатся, но range-читатель продолжит блокироваться. Предложил корректное решение: использовать WaitGroup для ожидания завершения всех горутин-писателей, а затем закрыть канал в отдельной горутине. Реализовал решение с sync.WaitGroup и закрытием канала через defer в отдельной горутине.

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

Собеседник полностью верно определил проблему и предложил правильное решение. Разберём детально.

Проблема: deadlock из-за открытого канала

// Проблемный код
ch := make(chan int, 1000)

for i := 0; i < 100; i++ {
go func() {
ch <- rand.Intn(100)
}()
}

sum := 0
for v := range ch { // ← блокируется навсегда
sum += v
}

range по каналу продолжает читать до тех пор, пока канал не будет закрыт. Поскольку канал никто не закрывает, после того как все 100 значений прочитаны, range блокируется в ожидании новых данных. Все горутины-писатели уже завершились. Go runtime обнаруживает, что все горутины заблокированы, и выбрасывает panic: all goroutines are asleep - deadlock!

Правильное решение с WaitGroup

func sumFromGoroutines() int {
ch := make(chan int, 1000)
var wg sync.WaitGroup

for i := 0; i < 100; i++ {
wg.Add(1)
go func() {
defer wg.Done()
ch <- rand.Intn(100)
}()
}

// Закрываем канал после завершения всех писателей
go func() {
wg.Wait()
close(ch)
}()

sum := 0
for v := range ch {
sum += v
}
return sum
}

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

  1. WaitGroup отслеживает завершение всех горутин-писателей.
  2. Отдельная горутина ждёт завершения всех писателей и закрывает канал.
  3. range корректно завершается после закрытия канала.

Альтернативные подходы

Подсчёт через слайс с мьютексом:

var (
mu sync.Mutex
sum int
)

for i := 0; i < 100; i++ {
wg.Add(1)
go func() {
defer wg.Done()
val := rand.Intn(100)
mu.Lock()
sum += val
mu.Unlock()
}()
}
wg.Wait()

Использование sync/atomic:

var sum int64

for i := 0; i < 100; i++ {
wg.Add(1)
go func() {
defer wg.Done()
atomic.AddInt64(&sum, int64(rand.Intn(100)))
}()
}
wg.Wait()

Типичные ошибки, которых стоит избегать:

  • Закрытие канала писателем: если несколько горутин пишут в канал и одна из них закроет его — остальные получат panic при попытке записи в закрытый канал. Закрывать канал должен только тот, кто его создал (или координатор).
  • Забыть wg.Add(1) перед go: если wg.Wait() выполнится раньше, чем wg.Add(1) — канал закроется до начала записи.
  • Использовать небуферизованный канал без гарантии читателя: может привести к блокировке писателей.

Вопрос 14. Что такое горутина? Каков её размер стека и как он растёт?

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

Ответ собеседника: Правильный. Горутина — это лёгковесный поток выполнения, управляемый планировщиком Go. Размер стека горутины начинается с ~2 КБ и может расти до гигабайтов. Стек растёт динамически, что было компромиссом между экономией памяти и предотвращением переполнения. Утечка горутин опасна тем, что каждая занимает память стека, и при большом количестве утечек растёт потребление памяти.

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

Собеседник дал хороший ответ. Дополним техническими деталями.

Что такое горутина

Горутина — это зелёный поток (green thread), управляемый рантаймом Go, а не операционной системой. Это ключевое отличие от потоков ОС:

ХарактеристикаГорутинаПоток ОС
Начальный стек~2 КБ~1-8 МБ (фиксирован)
Создание~нс~мкс
Переключение контекстаПользовательское пространствоЯдро ОС
МаксимумСотни тысячТысячи
ПланировщикGo runtime (M:N)Ядро ОС (1:1)

Рост стека

До Go 1.3: стек горутины рос через механизм split stack (сегментированный стек). При нехватке места создавался новый сегмент стека и связывался со старым. Проблемой был hot split — если функция на границе стека вызывалась часто, постоянные аллокации и освобождения сегментов создавали накладные расходы.

С Go 1.4: стек стал contiguous (непрерывным). При нехватке места выделяется новый блок памяти в 2 раза больше, данные копируются, старый блок освобождается. Это решило проблему hot split.

Текущее поведение (Go 1.19+):

  • Начальный стек: 2 КБ (может варьироваться в зависимости от архитектуры).
  • Максимальный стек: 1 ГБ (ограничение через runtime/debug.SetMaxStack).
  • Рост: удвоение размера при нехватке места.

Планировщик Go (GPM модель)

G (goroutine) → P (processor) → M (machine/OS thread)
  • G — горутина с её стеком и состоянием.
  • P — процессор, локальная очередь горутин (по 256 штук). Количество P равно GOMAXPROCS.
  • M — поток ОС, привязанный к P для выполнения горутин.

Планировщик использует work stealing: если у P закончились горутины, он «ворует» половину очереди у другого P.

Утечка горутин

Утечка происходит, когда горутина заблокирована навсегда и не может завершиться:

// Пример утечки
go func() {
ch := make(chan int)
<-ch // блокируется навсегка — никто не пишет в канал
}()

Каждая утечка потребляет минимум ~2 КБ стека + ресурсы рантайма. При тысячах утечек это ощутимо.

Как обнаружить утечки:

  • Профилирование через pprof: go tool pprof http://localhost:6060/debug/pprof/goroutine
  • Мониторинг количества горутин: runtime.NumGoroutine()
  • Логирование старта и завершения горутин.

Вопрос 15. Как работает планировщик горутин в Go? Расскажи про GMP-модель.

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

Ответ собеседника: Правильный. Go использует GMP-модель: G (goroutine), M (machine — поток ОС), P (processor — логический процессор). GOMAXPROCS задаёт количество P. У каждого P есть локальная очередь (local run queue) с горутинами. Также есть глобальная очередь (global run queue). Планировщик периодически проверяет глобальную очередь. Системный монитор (sysmon) отслеживает системные вызовы: если сетевой вызов длится более 10 мс, горутина перемещается на network poller, а машина освобождается для других горутин.

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

Собеседник дал отличный ответ. Дополним деталями и краевыми случаями.

GMP-модель подробно

G (Goroutine)

Струкра, описывающая горутину:

type g struct {
stack stack // текущий стек [lo, hi)
stackguard0 uintptr // граница стека для проверки переполнения
m *m // трид ОС, на котором выполняется (если есть)
sched gobuf // контекст планировщика (SP, PC, ...)
status uint32 // состояние: Grunning, Gwaiting, Grunnable, ...
...
}

M (Machine/OS Thread)

Поток ОС, выполняющий горутины:

type m struct {
g0 *g // горутина планировщика (не пользовательская)
curg *g // текущая выполняемая горутина
p puintptr // привязанный P
nextp puintptr // P для следующей привязки
...
}

У каждого M есть собственная горутина g0 — стек планировщика, на котором выполняется код планировщика.

P (Processor)

Логический процессор, владеющий локальной очередью горутин:

type p struct {
id int32
status uint32 // Pidle, Prunning, Psyscall, ...
m muintptr // привязанный M
runqhead uint32 // голова локальной очереди
runqtail uint32 // хвост локальной очереди
runq [256]guintptr // локальная очередь (кольцевой буфер)
runnext guintptr // следующая горутина для выполнения (приоритет)
...
}

Количество P равно GOMAXPROCS (по умолчанию — количество CPU).

Жизненный цикл горутины

New → Grunnable → Grunning → (Gwaiting / Grunnable / Gdead)
  1. New: создана через go func(), ещё не запланирована.
  2. Grunnable: в очереди, ожидает выполнения.
  3. Grunning: выполняется на M через P.
  4. Gwaiting: заблокирована (канал, мьютекс, I/O, time.Sleep).
  5. Gdead: завершена, ресурсы могут быть переиспользованы.

Механизмы планирования

Work stealing (воровство работы):

Когда у P заканчиваются горутины в локальной очереди:

  1. Проверяется runnext (приоритетная горутина).
  2. Проверяется локальная очередь.
  3. Проверяется глобальная очередь (каждые 61 итерация — чтобы избежать конкуренции).
  4. Воруется половина очереди у случайного P.

Network poller:

Сетевые операции (чтение/запись из сокетов) обрабатываются через неблокирующий I/O + epoll/kqueue/IOCP. Горутина, ожидающая сетевого события, паркуется (Gwaiting), M освобождается для других горутин. Когда данные готовы — горутина возвращается в Grunnable.

Sysmon (system monitor):

Фоновая горутина, которая:

  • Разблокирует горутины, заблокированные на долгих системных вызовах (>20 мкс).
  • Проверяет таймеры и будит горутины, ожидающие time.Sleep.
  • Принудительно запускает GC.
  • Возвращает забытые P из системных вызовов.

Проблема: блокирующий системный вызов

Если горутина делает блокирующий syscall (например, чтение файла), M блокируется вместе с ней. P отвязывается от этого M и привязывается к другому M (или создаётся новый M). Когда syscall завершается — M пытается вернуть себе P.

Предemption (вытеснение)

С Go 1.14+ горутины вытесняются асинхронно через сигналы (SIGURG). Раньше вытеснение происходило только в точках вызова функций (cooperative scheduling), что позволяло горутине без вызовов функций монополизировать CPU.

Вопрос 16. Что означают значения CPU limits/requests в Kubernetes (например, 4 CPU, 0.5 CPU)?

Таймкод: 00:32:27

Ответ собеседника: Неполный. Ответил, что 1 CPU — это логическое ядро процессора, 0.5 CPU — половина ядра. Не был уверен в точной интерпретации дробных значений. Также не смог до конца объяснить проблему тротлинга при совпадении GOMAXPROCS с CPU limit в Kubernetes, но после наводки понял, что потоков запускается больше, чем указано в GOMAXPROCS (системные потоки sysmon и др.), что приводит к тротлингу.

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

CPU в Kubernetes

1 CPU = 1 логическое ядро (vCPU/hyperthread).

Это может быть:

  • 1 ядро на физическом процессоре
  • 1 hyperthread на процессоре с Hyper-Threading
  • 1 vCPU в облаке (AWS, GCP, Azure)

Дробные значения означают долю времени CPU:

  • 0.5 CPU = 500 millicpu = 50% времени одного ядра
  • 0.1 CPU = 100 millicpu = 10% времени одного ядра

Requests vs Limits

ПараметрЧто делаетМеханизм
requestsГарантированный минимум CPUИспользуется планировщиком K8s для выбора ноды
requests (memory)Гарантированная памятьИспользуется планировщиком K8s
limitsМаксимум CPUCFS (Completely Fair Scheduler) bandwidth control — тротлинг
limits (memory)Максимум памятиOOM Killer при превышении

Как работает CPU limit под капотом

Kubernetes использует Linux CFS (Completely Fair Scheduler) через cgroups:

# CPU limit = 1.5 ядра преобразуется в:
# cpu.cfs_period_us = 100000 (100 мс)
# cpu.cfs_quota_us = 150000 (150 мс)
# Контейнер может использовать 150 мс CPU каждые 100 мс периода = 1.5 ядра

Если контейнер превышает квоту — он тротлится (CFS не даёт ему CPU до следующего периода).

Проблема: GOMAXPROCS в Kubernetes

Go runtime определяет GOMAXPROCS по количеству логических ядер на хосте (а не в контейнере), если не задано явно или не используется библиотека automaxprocs.

// На хосте с 32 ядрами, но CPU limit = 2
runtime.GOMAXPROCS() // = 32 (без исправления)

Это означает:

  • Go создаёт 32 потока ОС (M)
  • K8s разрешает использовать только 2 ядра
  • 32 потока конкурируют за 2 ядра → массовый тротлинг

Решение: automaxprocs

import _ "go.uber.org/automaxprocs"

// Автоматически устанавливает GOMAXPROCS = CPU limit контейнера

Или вручную:

import "runtime"
import "github.com/uber-go/automaxprocs/maxprocs"

func main() {
maxprocs.Set()
// теперь GOMAXPROCS соответствует CPU limit
}

Дополнительная проблема: системные потоки

Даже при правильном GOMAXPROCS, Go создаёт дополнительные потоки:

  • sysmon — системный монитор
  • Потоки для блокирующих системных вызовов
  • Потоки для GC

Поэтому общее количество потоков может быть больше, чем GOMAXPROCS. При жёстком CPU limit это приводит к тротлингу даже при корректно настроенном GOMAXPROCS.

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

  1. Всегда используйте automaxprocs в контейнерах.
  2. Устанавливайте requests = limits для CPU (Guaranteed QoS class), если приложение CPU-bound.
  3. Мониторьте container_cpu_cfs_throttled_periods_total в Prometheus.
  4. Учитывайте, что GOMAXPROCS — это минимум потоков ОС, а не максимум.

Вопрос 17. Какие примитивы синхронизации в Go ты знаешь?

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

Ответ собеседника: Правильный. Назвал: каналы (основной примитив), sync.WaitGroup, sync.Once, sync.Mutex/RWMutex, sync.Cond (пользовался один раз в университете), sync.Pool (знает о существовании, но не разбирался глубоко), атомики (atomic). Также упомянул паттерн worker pool.

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

Собеседник перечислил все основные примитивы. Детализируем каждый.

Каналы (Channels)

Основной идиоматичный способ коммуникации между горутинами: «Don't communicate by sharing memory; share memory by communicating».

ch := make(chan int) // небуферизованный
ch := make(chan int, 10) // буферизованный

sync.WaitGroup

Ожидание завершения группы горутин:

var wg sync.WaitGroup
for i := 0; i < 10; i++ {
wg.Add(1)
go func() {
defer wg.Done()
// работа
}()
}
wg.Wait()

sync.Mutex / sync.RWMutex

// Mutex — эксклюзивная блокировка
var mu sync.Mutex
mu.Lock()
defer mu.Unlock()

// RWMutex — множество читателей или один писатель
var rwmu sync.RWMutex
rwmu.RLock() // блокировка чтения
rwmu.RUnlock()
rwmu.Lock() // блокировка записи
rwmu.Unlock()

sync.Once

Гарарантия однократного выполнения:

var once sync.Once
var instance *Singleton

func GetInstance() *Singleton {
once.Do(func() {
instance = &Singleton{}
})
return instance
}

sync.Pool

Пул объектов для переиспользования (уменьшение нагрузки на GC):

var pool = sync.Pool{
New: func() interface{} {
return make([]byte, 1024)
},
}

buf := pool.Get().([]byte)
// используем buf
pool.Put(buf) // возвращаем в пул

Важно: объекты в пуле могут быть собраны GC в любой момент. Не используйте sync.Pool для хранения состояния.

sync/atomic

Безблокировочные атомарные операции:

var counter int64
atomic.AddInt64(&counter, 1)
val := atomic.LoadInt64(&counter)
atomic.CompareAndSwapInt64(&counter, old, new)

sync.Cond

Условная переменная — сигнализация между горутинами:

var mu sync.Mutex
cond := sync.NewCond(&mu)

// Ждём сигнала
mu.Lock()
cond.Wait() // атомарно разблокирует mu и усыпляет горутину
mu.Unlock()

// Сигнализируем
cond.Signal() // будим одну горутину
cond.Broadcast() // будим все горутины

context.Context

Не примитив синхронизации в классическом смысле, но критически важен для управления жизненным циклом горутин:

ctx, cancel := context.WithCancel(context.Background())
go worker(ctx)
cancel() // сигнал завершения

Паттерны использования

ПримитивКогда использовать
КаналыПередаданных между горутинами, оркестрация
WaitGroupОжидание завершения N горутин
Mutex/RWMutexЗащита разделяемого состояния
OnceЛенивая инициализация, singleton
AtomicПростые счётчики без блокировок
PoolПереиспользование короткоживущих объектов
CondСложная координация с ожиданием условия

Вопрос 18. Что такое каналы в Go, зачем они нужны и как они работают под капотом?

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

Ответ собеседника: Неполный. Каналы — это основополагающий примитив синхронизации в Go, основанный на парадигме CSP (Communicate Sequential Processes). Каналы реализуют принцип: don't communicate by sharing memory, share memory by communicating. Каналы бывают буферизованные и небуферизованные. В небуферизованных каналах данные передаются напрямую из стека одной горутины в стек другой — это единственный случай в Go, когда нарушается правило изоляции стеков горутин. В буферизованных каналах используется циклическая очередь. Не знал внутреннее устройство канала, но после объяснения узнал, что буфер реализован как циклическая очередь.

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

Собеседник дал хороший верхнеуровневый ответ. Дополним внутренним устройством.

Философия: CSP

Go следует модели CSP (Communicating Sequential Processes) — горутины общаются через каналы, а не разделяют память через мьютексы.

Внутренняя структура: hchan

type hchan struct {
qcount uint // текущее количество элементов в буфере
dataqsiz uint // размер буфера (0 для небуферизованного)
buf unsafe.Pointer // указатель на циклический буфер
sendx uint // индекс для записи
recvx uint // индекс для чтения
recvq waitq // очередь ожидающих читателей (sudog)
sendq waitq // очередь ожидающих писателей (sudog)
lock mutex // мьютекс для защиты структуры
}

Небуферизованный канал (make(chan int))

  • dataqsiz = 0, buf = nil.
  • Запись блокируется, пока нет читателя.
  • Чтение блокируется, пока нет писателя.
  • Данные передаются напрямую от писателя к читателю — копируются из стека одной горутины в стек другой. Это единственный случай в Go, когда данные передаются между стеками горутин напрямую.

Буферизованный канал (make(chan int, N))

  • Буфер — циклическая очередь фиксированного размера.
  • sendx — куда писать, recvx — откуда читать.
  • Запись блокируется, только когда буфер полон.
  • Чтение блокируется, только когда буфер пуст.

Операции с каналом

ch <- v // отправка
v := <-ch // получение
v, ok := <-ch // получение с проверкой закрытия
close(ch) // закрытие

Что происходит при блокировке

Когда горутина блокируется на операции с каналом:

  1. Создаётся структура sudog (дескриптор заблокированной горутины).
  2. sudog добавляется в recvq или sendq.
  3. Горутина переходит в состояние Gwaiting.
  4. Когда появляется партнёр — sudog извлекается, горутина переходит в Grunnable.

Закрытие канала

close(ch)
  • Закрыть канал может только писатель.
  • Запись в закрытый канал → panic.
  • Чтение из закрытого канала → zero value (и ok = false).
  • Закрыть уже закрытый канал → panic.

Select

select {
case v := <-ch1:
// обработка из ch1
case ch2 <- v:
// отправка в ch2
case <-time.After(5 * time.Second):
// таймаут
default:
// неблокирующий вариант
}

select рандомизирует порядок проверки готовности каналов, чтобы избежать голодания.

Типичные ошибки:

  • Закрытие канала читателем или несколькими писателями.
  • Утечка горутин из-за заблокированного чтения из канала, который никто не закроет.
  • Использование небуферизованного канала, когда нужен буфер (и наоборот).

Вопрос 19. За счего работают атомики и на чём построены мьютексы в Go?

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

Ответ собеседника: Правильный. Атомики работают за счёт атомарных операций на уровне процессора (CAS и другие CPU-инструкции), которые выполняются за один шаг и не могут быть прерваны. Мьютексы построены на бинарных семафорах с использованием атомарных операций. Предположил, что мьютексы построены на бинарных семафорах, что было принято как корректный ответ.

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

Собеседник верно ответил на оба вопроса. Дополним деталями.

Атомики: аппаратная база

Атомарные операции реализуются через специальные инструкции процессора:

x86/x64:

  • LOCK XCHG — атомарный обмен
  • LOCK CMPXCHG — Compare-And-Swap (CAS)
  • LOCK ADD, LOCK INC — атомарные арифметические операции

ARM:

  • LDXR / STXR — Load-Exclusive / Store-Exclusive (pair для CAS)
  • LDAXR / STLXR — с acquire/release семантикой
// Go atomic компилируется в CPU-инструкции
atomic.AddInt64(&counter, 1)
// x86: LOCK ADDQ $1, (counter)
// ARM64: LDAXR + ADD + STLXR

CAS (Compare-And-Swap)

Фундаментальная операция для lock-free программирования:

// Атомарно: если *addr == old, то *addr = new
func CompareAndSwapInt64(addr *int64, old, new int64) bool

Пример lock-free счётчика:

func atomicIncrement(p *int64) {
for {
old := atomic.LoadInt64(p)
if atomic.CompareAndSwapInt64(p, old, old+1) {
return
}
// CAS не удался — другой поток изменил значение, повторяем
}
}

sync.Mutex: внутреннее устройство

Мьютекс в Go — это не просто бинарный семафор. Он использует двухфазный подход:

type Mutex struct {
state int32 // бит 0: locked, бит 1: woken, бит 2: starving
sema uint32 // семафор для блокировки горутин
}

Фаза 1: Спиннинг (busy-wait)

При попытке захвата мьютекса горутина сначала пытается CAS несколько раз (до 4 итераций), не блокируясь:

// Упрощённая логика
for i := 0; i < 4; i++ {
if atomic.CompareAndSwapInt32(&m.state, 0, mutexLocked) {
return // захватили мьютекс
}
// спин: ждём, пока мьютекс освободится
runtime.Gosched() или PAUSE-инструкция
}

Фаза 2: Блокировка через семафор

Если спиннинг не помог, горутина паркуется на семафоре (sema):

// Горутина засыпает, пока мьютекс не освободится
runtime_SemacquireMutex(&m.state, ...)

Режим starving (голодание):

Если горутина ждала мьютекс дольше 1 мс, мьютекс переходит в режим starving. В этом режиме:

  • Новые горутины не могут захватить мьютекс через спиннинг.
  • Мьютекс передаётся напрямую следующей горутине в очереди.
  • Это предотвращает голодание горутин.

Иерархия примитивов

CPU atomic instructions (CAS, XCHG)

sync/atomic (Add, Load, CAS, Swap)

sync.Mutex (spinning + semaphore)

sync.RWMutex (на основе Mutex + счётчики)

Каналы (hchan с внутренним мьютексом)

Когда что использовать:

ПримитивНакладные расходыКогда использовать
atomic~нсПростые счётчики, флаги
Mutex~нс (без конкуренции) / ~мкс (с конкуренцией)Защита структур данных
Каналы~мксПередача данных, оркестрация

Вопрос 20. Код-ревью: дана функция сохранения статуса заказа в БД. Какие проблемы ты видишь и что бы исправил?

Таймкод: 00:42:26

Ответ собеседника: Правильный. Обнаружил SQL-инъекцию — параметры подставляются напрямую в SQL-запрос через конкатенацию строк. Предложил использовать плейсхолдеры (placeholders) или метод Exec драйвера БД, который экранирует значения под капотом. Также отметил, что можно возвращать указатель вместо значения из метода. Упомянул про пул соединений с БД. Предложил добавить контекст с тайм-аутом для отмены запросов к БД. Предложил добавить трейсинг и логирование с trace ID из контекста. Знал про партиционирование БД и предложил использовать хеш-функцию по ID для определения партиции. Знал про три столпа observability: трейсинг, метрики, логирование. Знал про Prometheus и Grafana, но не знал их точных ролей (Prometheus собирает метрики, Grafana визуализирует). Пишет юнит и интеграционные тесты, использует GoMock для генерации моков. Знает про утиную типизацию и правило размещения интерфейсов по месту использования.

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

Собеседник продемонстрировал отличное владение темой код-ревью. Систематизируем и дополним.

Критические проблемы

1. SQL-инъекция — критический безопасности

// Плохо — конкатенация строк
query := "UPDATE orders SET status = '" + status + "' WHERE id = " + orderID
db.Exec(query)
// Правильно — параметризованные запросы
query := "UPDATE orders SET status = $1 WHERE id = $2"
db.ExecContext(ctx, query, status, orderID)

Параметризованные запросы не просто экранируют значения — они передают данные отдельно от структуры запроса, что полностью исключает SQL-инъекцию.

2. Отсутствие контекста

// Плохо — нет возможности отменить запрос
func SaveOrderStatus(orderID int, status string) error {
db.Exec("UPDATE ...") // может висеть бесконечно
}

// Правильно — контекст с тайм-аутом
func SaveOrderStatus(ctx context.Context, orderID int, status string) error {
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
_, err := db.ExecContext(ctx, "UPDATE ...", status, orderID)
return err
}

3. Отсутствие обработки ошибок

// Плохо — ошибка игнорируется
db.Exec(query)

// Правильно — обработка и обёртывание ошибки
result, err := db.ExecContext(ctx, query, status, orderID)
if err != nil {
return fmt.Errorf("save order status: orderID=%d: %w", orderID, err)
}
rows, _ := result.RowsAffected()
if rows == 0 {
return fmt.Errorf("order not found: orderID=%d", orderID)
}

Архитектурные улучшения

4. Интерфейс репозитория вместо прямого вызова DB

type OrderRepository interface {
UpdateStatus(ctx context.Context, orderID int, status string) error
}

type orderRepo struct {
db *sql.DB
}

func (r *orderRepo) UpdateStatus(ctx context.Context, orderID int, status string) error {
// реализация
}

Это позволяет:

  • Подменять реализацию в тестах (моки).
  • Соблюдать принцип размещения интерфейсов по месту использования (утиная типизация Go).

5. Транзакционность

Если обновление статуса — часть более крупной операции:

func (r *orderRepo) UpdateStatus(ctx context.Context, orderID int, status string) error {
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()

_, err = tx.ExecContext(ctx, "UPDATE orders SET status = $1 WHERE id = $2", status, orderID)
if err != nil {
return err
}

_, err = tx.ExecContext(ctx, "INSERT INTO order_log ...", orderID, status)
if err != nil {
return err
}

return tx.Commit()
}

6. Observability

func (r *orderRepo) UpdateStatus(ctx context.Context, orderID int, string) error {
// Трейсинг
span, ctx := otel.Tracer("orderRepo").Start(ctx, "UpdateStatus")
defer span.End()
span.SetAttributes(attribute.Int("order.id", orderID))

// Метрики
start := time.Now()
defer func() {
duration := time.Since(start).Seconds()
statusUpdateDuration.WithLabelValues(status).Observe(duration)
}()

// Логирование
traceID := trace.SpanFromContext(ctx).SpanContext().TraceID().String()
log.Info().Str("trace_id", traceID).Int("order_id", orderID).Msg("updating order status")
}

Тестирование

func TestOrderRepo_UpdateStatus(t *testing.T) {
db, mock, err := sqlmock.New()
require.NoError(t, err)

mock.ExpectExec("UPDATE orders SET status").
WithArgs("shipped", 123).
WillReturnResult(sqlmock.NewResult(0, 1))

repo := &orderRepo{db: db}
err = repo.UpdateStatus(context.Background(), 123, "shipped")
assert.NoError(t, err)
assert.NoError(t, mock.ExpectationsWereMet())
}

Итог: собеседник покрыл практически все аспекты — безопасность, контекст, ошибки, архитектуру, observability и тестирование. Это уровень зрелого разработчика.

Вопрос 21. Что такое observability и три столпа observability? Какие инструменты для сбора метрик и трейсинга знаешь?

Таймкод: 00:51:59

Ответ собеседника: Неполный. Слышал термин observability, но не смог назвать три столпа. После подсказки назвал: трейсинг, метрики и логирование. Знал, что логи выводят в Grafana, слышал про Prometheus. Не знал, что Prometheus собирает метрики, а Grafana их визуализирует. Не знал Jaeger и другие инструменты трейсинга.

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

Observability — это способность системы отвечать на произвольные вопросы о её внутреннем состоянии на основе внешних данных (логов, метрик, трейсов). В отличие от мониторинга (заранее определённые дашборды и алерты), observability позволяет исследовать неизвестные проблемы.

Три столпа observability

1. Логирование (Logs)

Дискретные события с временной меткой. Отвечают на вопрос «что произошло?».

log.Info().
Str("trace_id", "abc123").
Int("order_id", 456).
Str("status", "shipped").
Msg("order status updated")

Инструменты: ELK Stack (Elasticsearch, Logstash, Kibana), Loki, Fluentd, Splunk.

2. Метрики (Metrics)

Числовые измерения во времени. Отвечают на вопрос «сколько?» и «как быстро?».

  • Counter — монотонно возрастающее значение (количество запросов).
  • Gauge — текущее значение (количество горутин, использование памяти).
  • Histogram — распределение значений (латентность запросов).
import "github.com/prometheus/client_golang/prometheus"

var (
requestDuration = prometheus.NewHistogramVec(
prometheus.HistogramOpts{
Name: "http_request_duration_seconds",
Help: "Duration of HTTP requests",
Buckets: prometheus.DefBuckets,
},
[]string{"method", "endpoint", "status"},
)
requestTotal = prometheus.NewCounterVec(
prometheus.CounterOpts{
Name: "http_requests_total",
Help: "Total HTTP requests",
},
[]string{"method", "endpoint", "status"},
)
)

Инструменты: Prometheus (сбор и хранение метрик), Grafana (визуализация), VictoriaMetrics (альтернатива Prometheus).

3. Трейсинг (Traces)

Отслеживание запроса через все сервисы. Отвечают на вопрос «где проблема?».

Trace: abc123
├── Span: API Gateway (50ms)
│ ├── Span: Order Service (30ms)
│ │ ├── Span: DB Query (15ms)
│ │ └── Span: Cache Lookup (1ms)
│ └── Span: Payment Service (100ms) ← тут проблема

Инструменты: Jaeger, Zipkin, Tempo (Grafana Tempo), OpenTelemetry (стандарт инструментирования).

OpenTelemetry (OTel)

Унифицированный стандарт для инструментирования. Позволяет собирать все три столпа через единый API:

import "go.opentelemetry.io/otel"

// Трейсинг
ctx, span := tracer.Start(ctx, "ProcessOrder")
defer span.End()

// Метрики
meter := otel.Meter("order-service")
counter, _ := meter.Int64Counter("orders.processed")
counter.Add(ctx, 1)

// Логи с trace_id автоматически связываются с трейсами

Связь между столпами

Ключевая идея — корреляция. Каждый лог, метрика и трейс должны содержать общие идентификаторы:

  • trace_id — связывает все спаны одного запроса.
  • span_id — идентификатор конкретной операции.
  • service.name — имя сервиса.

Это позволяет перейти от алерта в Grafana (метрики) → к конкретному трейсу в Jaeger → к логам этого запроса в Kibana.

Схема инструментов:

Приложение (OpenTelemetry SDK)

OpenTelemetry Collector
├── Prometheus → Grafana (метрики)
├── Jaeger/Tempo → Grafana (трейсы)
└── Loki/ELK → Grafana/Kibana (логи)

Вопрос 22. Какие типы тестов пишешь на Go? Используешь ли моки и инструменты для их генерации? Что такое утиная типизация в Go и где лучше размещать интерфейсы?

Таймкод: 00:53:38

Ответ собеседника: Правильный. Пишет юнит-тесты и интеграционные тесты. Для генерации моков раньше использовал Mockery, сейчас использует GoMock от Uber. Знает, что интерфейсы лучше размещать по месту использования (а не реализации), что удобнее для тестирования и соответствует утиной типизации Go — структура неявно имплементирует интерфейс, если реализует все его методы. Утиная типизация — это особенность Go, когда не нужно явно указывать, что структура имплементирует интерфейс.

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

Собеседник дал полный и точный ответ. Дополним деталями.

Типы тестов

1. Юнит-тесты — тестирование отдельных функций/методов в изоляции.

func TestCalculateTotal(t *testing.T) {
tests := []struct {
name string
items []Item
expected int
}{
{"empty", nil, 0},
{"single", []Item{{Price: 10}}, 10},
{"multiple", []Item{{Price: 10}, {Price: 20}}, 30},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := CalculateTotal(tt.items)
assert.Equal(t, tt.expected, result)
})
}
}

2. Интеграционные тесты — тестирование взаимодействия компонентов (сервис + БД, сервис + очередь).

func TestOrderRepo_Integration(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test")
}

db := setupTestDB(t) // реальная или контейнерная БД
repo := NewOrderRepo(db)

err := repo.UpdateStatus(context.Background(), 1, "shipped")
assert.NoError(t, err)
}

3. Табличные тесты — идиоматичный подход в Go для тестирования множества сценариев.

Моки и инструменты

GoMock (golang/mock):

//go:generate mockgen -source=order_repo.go -destination=mock_order_repo.go

type OrderRepository interface {
UpdateStatus(ctx context.Context, orderID int, status string) error
}

// В тесте
ctrl := gomock.NewController(t)
defer ctrl.Finish()
mockRepo := NewMockOrderRepository(ctrl)
mockRepo.EXPECT().UpdateStatus(gomock.Any(), 1, "shipped").Return(nil)

sqlmock — мок для database/sql:

db, mock, _ := sqlmock.New()
mock.ExpectExec("UPDATE orders").WillReturnResult(sqlmock.NewResult(0, 1))

testify/mock — альтернатива GoMock.

Утиная типизация (Structural Typing)

В Go интерфейс реализуется неявно:

type Stringer interface {
String() string
}

type MyStruct struct{}

func (m MyStruct) String() string { return "hello" }

// MyStruct автоматически реализует Stringer — без ключевого слова implements
var s Stringer = MyStruct{} // OK

Размещение интерфейсов по месту использования

// Плохо — интерфейс в пакете реализации
package order_repo

type OrderRepository interface { // реализатор определяет интерфейс
UpdateStatus(ctx context.Context, orderID int, status string) error
}

// Правильно — интерфейс в пакете, который его использует
package order_service

type OrderRepository interface { // потребитель определяет, что ему нужно
UpdateStatus(ctx context.Context, orderID int, status string) error
}

Почему это важно:

  • Потребитель определяет минимальный набор методов, который ему нужен (Interface Segregation Principle).
  • Легче тестировать: можно создать мок только с нужными методами.
  • Меньше зависимостей: реализатор не знает о всех потребителях.

Вопрос 23. Что такое три постулата ООП и как они реализуются в Go?

Таймкод: 00:55:34

Ответ собеседника: Неполный. Слышал про принципы ООП, но не знал, что именно подразумевается под «тремя постулатами». После подсказки вспомнил инкапсуляцию (реализуется через экспортируемые/неэкспортируемые поля — заглавная/строчная буква). Не знал термин «наследование» в контексте трёх постулатов, но после подсказки правильно ответил, что в Go наследование реализуется через композицию, а полиморфизм — через интерфейсы.

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

Три постулата ООП

1. Инкапсуляция (Encapsulation)

Сокрытие внутреннего состояния объекта и предоставление контролируемого доступа через публичный интерфейс.

В Go реализуется через экспортируемые и неэкспортируемые идентификаторы:

package account

type Account struct {
balance int // неэкспортируемое поле — доступно только внутри пакета
owner string // неэкспортируемое поле
}

// Конструктор
func NewAccount(owner string) *Account {
return &Account{balance: 0, owner: owner}
}

// Публичные методы — контролируемый доступ
func (a *Account) Balance() int { // геттер
return a.balance
}

func (a *Account) Deposit(amount int) error { // сеттер с валидацией
if amount <= 0 {
return fmt.Errorf("amount must be positive")
}
a.balance += amount
return nil
}

func (a *Account) Withdraw(amount int) error {
if amount > a.balance {
return fmt.Errorf("insufficient funds")
}
a.balance -= amount
return nil
}

Вне пакета account невозможно напрямую изменить balance — только через Deposit и Withdraw с валидацией.

2. Наследование (Inheritance)

В классическом ООП — механизм «является» (is-a). В Go нет наследования в классическом смысле — вместо этого композиция (has-a) и встраивание (embedding):

// Вместо наследования — композиция
type Animal struct {
Name string
}

func (a *Animal) Speak() string {
return "..."
}

type Dog struct {
Animal // встраивание (embedding) — не наследование!
Breed string
}

// Dog «получает» методы Animal, но не является Animal
func main() {
d := Dog{Animal: Animal{Name: "Rex"}, Breed: "Labrador"}
fmt.Println(d.Name) // поле Animal доступно напрямую
fmt.Println(d.Speak()) // метод Animal доступен
}

Важное отличие от классического наследования:

  • Нет приведения типов «вверх»: *Dog не является *Animal.
  • Нет виртуальных методов в классическом смысле — переопределение работает через перезапись метода.
  • Композиция предпочтительнее: «предпочитайте композицию наследованию» (Go Proverb).

3. Полиморфизм (Polymorphism)

Возможность объектов разных типов обрабатываться единообразно через общий интерфейс.

В Go реализуется через интерфейсы:

type Speaker interface {
Speak() string
}

type Dog struct{ Name string }
func (d Dog) Speak() string { return d.Name + " says Woof!" }

type Cat struct{ Name string }
func (c Cat) Speak() string { return c.Name + " says Meow!" }

type Robot struct{ Model string }
func (r Robot) Speak() string { return r.Model + " says Beep boop!" }

// Полиморфная функция — работает с любым Speaker
func MakeItSpeak(s Speaker) {
fmt.Println(s.Speak())
}

func main() {
speakers := []Speaker{
Dog{Name: "Rex"},
Cat{Name: "Whiskers"},
Model: "R2-D2"},
}
for _, s := range speakers {
MakeItSpeak(s) // каждый ведёт себя по-своему
}
}

Итог по ООП в Go:

ПостулатКлассическое ООПGo
Инкапсуляцияprivate/protected/publicЭкспортируемые/неэкспортируемые (регистр первой буквы)
Наследованиеextends, implementsКомпозиция + embedding
ПолиморфизмВиртуальные методы, перегрузкаИнтерфейсы (structural typing)

Go — не чисто ООП язык. Он берёт из ООП полезное (инкапсуляция, полиморфизм), но отказывается от сложного наследования в пользу простой композиции.

Вопрос 24. Какие подводные камни есть в работе с ошибками в Go? В чём различие между errors.Is и errors.As?

Таймкод: 00:57:39

Ответ собеседника: Неполный. Работает с ошибками, использует fmt.Errorf для враппинга. Не знал различий между errors.Is и errors.As. После объяснения узнал: errors.Is проверяет, содержится ли в цепочке ошибок конкретное значение ошибки; errors.As проверяет наличие ошибки определённого типа и дополнительно кастит её к этому типу. В проекте в Яндексе кастомные типы ошибок не использует, но считает это полезной практикой.

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

Подводные камни работы с ошибками в Go

1. Потеря контекста при оборачивании

// Плохо — потеряли оригинальную ошибку
func process() error {
if err := doSomething(); err != nil {
return fmt.Errorf("process failed") // потеряли err!
}
return nil
}

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

2. Сравнение через == не работает с обёрнутыми ошибками

err := fmt.Errorf("context: %w", ErrNotFound)

err == ErrNotFound // false — обёрнутая ошибка не равна оригиналу
errors.Is(err, ErrNotFound) // true — errors.Is проходит по цепочке

3. Использование %v вместо %w

fmt.Errorf("context: %v", err) // %v — просто форматирует строку, НЕ оборачивает
fmt.Errorf("context: %w", err) // %w — оборачивает, сохраняет цепочку

С %v цепочка ошибок разрывается, и errors.Is / errors.As не смогут найти оригинальную ошибку.

errors.Is vs errors.As

errors.Is — проверяет, содержится ли в цепочке ошибок конкретное значение:

var ErrNotFound = errors.New("not found")

func findUser(id int) error {
return fmt.Errorf("find user %d: %w", id, ErrNotFound)
}

err := findUser(42)
errors.Is(err, ErrNotFound) // true
errors.Is(err, io.EOF) // false

errors.Is рекурсивно вызывает Unwrap() для каждой ошибки в цепочке и сравнивает через == или метод Is(target).

errors.As — проверяет, содержится ли в цепочке ошибок значение определённого типа, и присваивает его:

type ValidationError struct {
Field string
Message string
}

func (e *ValidationError) Error() string {
return fmt.Sprintf("%s: %s", e.Field, e.Message)
}

func validate(input string) error {
if input == "" {
return fmt.Errorf("validation failed: %w", &ValidationError{
Field: "input", Message: "cannot be empty",
})
}
return nil
}

err := validate("")
var valErr *ValidationError
if errors.As(err, &valErr) {
fmt.Println(valErr.Field) // "input"
fmt.Println(valErr.Message) // "cannot be empty"
}

Ключевое отличие:

errors.Iserrors.As
Что ищетКонкретное значение ошибкиОшибку определённого типа
Возвращаетboolbool + присваивает значение в переменную
Аналогия== для цепочкиType assertion для цепочки

Кастомные типы ошибок

type AppError struct {
Code int
Message string
Err error
}

func (e *AppError) Error() string {
return fmt.Sprintf("code=%d: %s", e.Code, e.Message)
}

func (e *AppError) Unwrap() error {
return e.Err
}

// Проверка типа
var appErr *AppError
if errors.As(err, &appErr) {
log.Error().Int("code", appErr.Code).Msg(appErr.Message)
}

Подводный камень с nil:

func getError() error {
var err *AppError = nil
return err // возвращаем nil-указатель, обёрнутый в интерфейс
}

err := getError()
fmt.Println(err == nil) // false! Интерфейс не nil, хотя значение внутри nil

Это одна из самых частых ошибок в Go. Решение — всегда возвращать nil напрямую, а не через типизированную переменную.