В хорошем Go-ответе явно названы владельцы: кто меняет значение, закрывает канал, отменяет работу, наблюдает ошибку и гарантирует завершение каждой goroutine.
Вопросы и ответы
100 подробных ответов
01Чем слайс отличается от массива и как слайс устроен под капотом?
junior
Короткий ответ: Массив — это value-тип фиксированного размера, причём размер зашит в сам тип ([3]int и [4]int — разные типы), и он копируется целиком при присваивании и передаче. Слайс — это лёгкий дескриптор поверх массива: заголовок из трёх полей {указатель на backing array, len, cap}. Копируется только заголовок, данные остаются общими.
Подробно:
- Массив —
[N]T, длина — часть типа, данные лежат единым непрерывным блоком прямо по месту объявления (в стеке, структуре или куче) и копируются побайтово. Передал в функцию — получил копию всех элементов. - Слайс —
[]T, три машинных слова:ptrна элемент backing array,len(сколько видно) иcap(сколько влезет до реаллокации). - Срез —
arr[1:3]не копирует данные, а строит новый заголовок, указывающий внутрь того же массива.
s := arr[1:3]
slice header backing array [5]int
┌──────────┐ ┌───┬───┬───┬───┬───┐
│ ptr ────┼────────► │ 0 │ 1 │ 2 │ 3 │ 4 │
│ len = 2 │ ▲ ▲
│ cap = 4 │ └─ ptr └─ len
└──────────┘
⚠️ Частая ошибка: думать, что s2 := s1 даёт независимую копию. Копируется только 24-байтовый заголовок — оба слайса смотрят в один и тот же backing array, и запись s2[0] = x видна через s1.
02Что делает append, когда capacity исчерпана, и почему два слайса могут неожиданно делить один массив?
middle
Короткий ответ: Пока есть запас cap, append пишет в тот же backing array и просто увеличивает len — поэтому другой слайс, смотрящий в тот же массив, увидит чужие мутации (алиасинг). Когда cap исчерпана, рантайм выделяет новый массив, копирует туда данные, и слайсы расходятся — старый указывает на прежнюю память, новый на свежую.
Подробно:
- Есть место (
len < cap) — запись на месте, backing array общий. Классический баг:b := a[:2]; b = append(b, x)затираетa[2]. - Места нет (
len == cap) — новый массив, копирование, старые ссылки не видят новых элементов. - Рост — при
cap < 256ёмкость удваивается; дальше формула плавно спадает от ~2x к ~1.25x (Go 1.18+, деталь реализации — не гарантия языка).
Пишем в слайс с запасом cap:
до: a ─► [1,2,3,_] cap=4 b := a[:2] ─► видит [1,2]
append(b, 9)
после: [1,2,9,_] a[2] стал 9 — сюрприз!
Пишем, когда cap исчерпана:
до: a ─► [1,2,3] cap=3
append(a, 9)
после: a ─► [1,2,3,9] ← НОВЫЙ массив, старый жив отдельно
⚠️ Частая ошибка: передать подслайс s[:k] в функцию, которая делает append, и рассчитывать, что оригинал не тронут. Пока есть cap, append перезапишет хвост оригинала. Защита — full slice expression s[:k:k], обрезающий cap до len.
03Как правильно скопировать слайс, чтобы изменения копии не задели оригинал?
junior
Короткий ответ: Присваивание b := a копирует только заголовок — данные остаются общими. Чтобы получить независимую копию, нужно скопировать сами элементы: copy(dst, src) в предвыделенный слайс, append([]T(nil), src...) или slices.Clone(src) (Go 1.21+).
Подробно:
copy(dst, src)— копируетmin(len(dst), len(src))элементов;dstнадо предвыделить черезmake, иначе скопируется 0 элементов.append([]T(nil), src...)— идиома для копии сразу нужной длины, без ручногоmake.slices.Clone(src)— самый читаемый способ начиная с Go 1.21.- Смежный приём — full slice expression
s[a:b:c]— данных не копирует, но обрезаетcapдоc-a: чужойappendуедет в новый массив и не заденет оригинал.
// НЕ копия — общий backing array:
b := a
// Копии (изменения b не видны в a):
b := make([]int, len(a))
copy(b, a)
b := append([]int(nil), a...)
b := slices.Clone(a) // Go 1.21+
⚠️ Частая ошибка: для всех вариантов копия — поверхностная (shallow). Если T — слайс, мапа или структура с указателем, вложенные данные всё ещё общие; нужна ручная глубокая копия.
04Чем nil-слайс отличается от пустого слайса и где эта разница стреляет?
middle
Короткий ответ: У обоих len == 0 и cap == 0, по обоим можно итерироваться и в оба можно делать append. Разница в двух местах: nil-слайс равен nil (заголовок с нулевым указателем), а пустой — нет; и в encoding/json nil маршалится в null, а пустой — в [].
Подробно:
- nil-слайс —
var s []T. Указательnil,s == nilистинно. - Пустой слайс —
s := []T{}илиmake([]T, 0). Указатель на пустой (не-nil) массив,s == nilложно. - Практическая разница — почти всегда её нет:
len,range,appendведут себя одинаково. Стреляет она в JSON-контрактах и в явных проверках== nil.
| nil-слайс | пустой слайс | |
|---|---|---|
| Объявление | var s []T |
[]T{} |
len / cap |
0 / 0 | 0 / 0 |
s == nil |
true |
false |
append работает |
да | да |
| JSON | null |
[] |
⚠️ Частая ошибка: вернуть nil-слайс из ручки API, где фронт ждёт массив, — и получить в JSON null вместо []. Клиент падает на .map(...). Инициализируйте make([]T, 0), если контракт требует массив.
05Go передаёт аргументы по значению или по ссылке? Что происходит при передаче слайса и мапы в функцию?
middle
Короткий ответ: В Go всё передаётся строго по значению — ссылок нет. Просто у слайса «значение» — это заголовок {ptr, len, cap}, а у мапы — указатель на внутреннюю структуру hmap. Копируется этот дескриптор, но он указывает на те же данные, поэтому мутации элементов видны снаружи.
Подробно:
- Копируется дескриптор — функция получает копию заголовка слайса / копию указателя мапы, а не сами данные.
- Мутации элементов видны —
s[i] = xилиm[k] = vменяют общие данные, вызывающая сторона их видит. - Реассайн заголовка — не виден —
appendс реаллокацией меняет только локальную копию заголовка; чтобы результат вернулся, слайс переприсваивают:s = append(s, x).
func grow(s []int) {
s = append(s, 99) // меняет ЛОКАЛЬНЫЙ заголовок
}
func set(s []int) {
s[0] = 99 // меняет ОБЩИЕ данные
}
a := []int{1, 2, 3}
grow(a) // a по-прежнему [1 2 3]
set(a) // a стал [99 2 3]
⚠️ Частая ошибка: сказать «слайсы и мапы передаются по ссылке». Для интервьюера это красный флаг. Правильно: по значению копируется дескриптор, ссылающийся на общие данные.
06Как устроена мапа в Go изнутри: бакеты, load factor, эвакуация?
senior
Короткий ответ: Классическая (до Go 1.24) реализация — хеш-таблица из бакетов по 8 пар ключ-значение; при коллизиях подцепляются overflow-бакеты. Когда среднее заполнение переваливает за load factor ~6.5 элементов на бакет, мапа растёт вдвое с инкрементальной эвакуацией — старые бакеты переносятся в новые не разом, а порциями на каждой записи. В Go 1.24 мапа переписана на Swiss Tables.
Подробно (классика, до 1.24):
- Бакет — массив из 8 слотов; хранит топ-байт хеша (tophash) для быстрого отсева, затем ключи, затем значения.
- Коллизии — если 8 слотов заняты, к бакету цепляется overflow-бакет.
- Рост — при
count / buckets > 6.5число бакетов удваивается; эвакуация инкрементальная, чтобы не было длинной паузы.
Что изменилось в Go 1.24 (Swiss Tables):
- Группы по 8 с 64-битным control word (по байту на слот, хранит младшие 7 бит хеша) — быстрый SIMD-подобный поиск внутри группы.
- Load factor 7/8 (~87.5%) вместо 6.5 — плотнее, меньше памяти.
- Нет overflow-бакетов — при коллизии поиск переходит к другим группам квадратичным пробированием; большие мапы бьются на несколько таблиц по ≤128 групп.
Классический бакет (до 1.24):
┌────────────── bucket ──────────────┐ overflow
│ tophash[8] │ keys[8] │ vals[8] │ ─► ┌───────┐
└────────────────────────────────────┘ │ ... │
└───────┘
⚠️ Частая ошибка: называть load factor «ровно 6.5» как истину на все времена — с Go 1.24 это уже 7/8 и другая структура. Уточняйте версию Go, о которой говорите.
07Что будет при чтении из nil-мапы? А при записи?
junior
Короткий ответ: Чтение из nil-мапы безопасно — возвращается zero value типа значения, а в comma-ok форме ok == false. Запись в nil-мапу паникует: panic: assignment to entry in nil map.
Подробно:
- Чтение —
v := m[k]вернёт zero value,v, ok := m[k]дастok == false. Итерация по nil-мапе — ноль проходов, тоже без паники. - Запись —
m[k] = vв nil-мапе роняет рантайм паникой. Мапу надо сначала инициализировать:m := make(map[K]V)или литераломmap[K]V{}. - Контраст со слайсом — в nil-слайс
appendработает и возвращает новый слайс, а в nil-мапу писать напрямую нельзя.
var m map[string]int // nil-мапа
n := m["x"] // 0, паники нет
n, ok := m["x"] // 0, false
for range m {} // 0 итераций
m["x"] = 1 // panic: assignment to entry in nil map
⚠️ Частая ошибка: объявить var m map[string]int и сразу писать в неё. Читается как рабочий код, но падает в рантайме — нужен make.
08Почему нельзя взять указатель на значение в мапе (&m[key])?
senior
Короткий ответ: Значения в мапе не адресуемы: при росте и эвакуации бакеты перевыделяются, и элементы физически переезжают в другую память. Указатель на старое место стал бы висячим, поэтому &m[key] — ошибка компиляции: invalid operation: cannot take address of m[key].
Подробно:
- Причина — рантайм волен перемещать пары ключ-значение при resize/эвакуации; адрес, взятый раньше, указывал бы в никуда. Язык запрещает это на этапе компиляции.
- Обход через указатель в значении — храните
map[K]*V, тогда сам указатель стабилен, аVживёт в куче отдельно. - Обход через read-modify-write — прочитать значение, изменить копию, положить обратно:
v := m[k]; v.N++; m[k] = v.
type Counter struct{ N int }
m := map[string]Counter{"a": {}}
// m["a"].N++ // ошибка компиляции: не адресуемо
// p := &m["a"] // ошибка компиляции
v := m["a"]; v.N++; m["a"] = v // read-modify-write
mp := map[string]*Counter{"a": {}}
mp["a"].N++ // ок: указатель адресуем
⚠️ Частая ошибка: пытаться сделать m[key].field = x для структуры-значения. Компилятор откажет; либо кладите указатели, либо переприсваивайте всю структуру.
09Какие типы могут быть ключом мапы и почему слайс — не может?
junior
Короткий ответ: Ключом может быть любой comparable-тип — тот, для которого определён оператор ==. Слайсы, мапы и функции несравнимы (для них == не определён, только сравнение с nil), поэтому ключами быть не могут. Структуры и массивы годятся, если все их поля/элементы тоже comparable.
Подробно:
- Можно — числа, строки, bool, указатели, каналы, интерфейсы, а также структуры и массивы из comparable-полей.
- Нельзя — слайс, мапа, функция: у них нет
==, только проверка наnil. - Почему — ключ надо уметь хешировать и сравнивать на равенство; для слайса равенство неоднозначно (по указателю? поэлементно?), поэтому язык его запрещает.
| Тип | Ключ? |
|---|---|
int, string, bool |
да |
| указатель, канал | да |
массив [N]T (T comparable) |
да |
| структура из comparable-полей | да |
слайс []T |
нет |
| мапа, функция | нет |
⚠️ Частая ошибка: взять interface{} (или any) ключом и положить туда слайс. Компилируется, но паникует в рантайме: panic: runtime error: hash of unhashable type.
10Что произойдёт при конкурентной записи в мапу из нескольких горутин без синхронизации?
middle
Короткий ответ: Рантайм детектит одновременный доступ и валит весь процесс: fatal error: concurrent map writes. Это именно fatal error, а не panic — его нельзя перехватить через recover, программа падает целиком.
Подробно:
- Не тихая гонка — рантайм специально отслеживает конкурентную запись (флаг в hmap) и намеренно падает, чтобы не портить структуру данных незаметно.
- Fatal, а не panic —
recoverне спасает,deferне отработает штатно. Такую гонку заодно поймает и детектор-race. - Решения —
sync.Mutex/sync.RWMutexвокруг доступа, либоsync.Mapдля сценариев «много читателей, точечные записи».
// Падает: fatal error: concurrent map writes
m := map[int]int{}
for i := 0; i < 8; i++ {
go func() { m[i] = i }() // гонка
}
// Безопасно:
var mu sync.Mutex
mu.Lock(); m[i] = i; mu.Unlock()
⚠️ Частая ошибка: сказать, что «будет просто неверное значение» или что «спасёт recover». Интервьюер ждёт слов «fatal error, процесс падает, recover не помогает».
11Почему порядок обхода мапы в Go случайный?
junior
Короткий ответ: Рандомизация сделана намеренно: при каждом range рантайм выбирает случайную стартовую позицию. Так язык не даёт коду завязываться на порядок, который всё равно не гарантирован (бакеты и эвакуации меняют физическое расположение).
Подробно:
- Сделано специально — иначе разработчики начали бы неявно полагаться на «стабильный» порядок, а он поменялся бы при смене версии Go или размера мапы.
- Порядок и так не определён — вставка не сохраняет очерёдность; эвакуация при росте перетасовывает элементы.
- Нужен стабильный порядок — соберите ключи в слайс и отсортируйте.
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
fmt.Println(k, m[k]) // детерминированный порядок
}
⚠️ Частая ошибка: писать тест, который ждёт конкретный порядок вывода мапы. Он будет «мигать» — падать через раз. Сортируйте ключи или сравнивайте как множество.
12Что вернёт len() для строки с кириллицей и как правильно посчитать символы?
junior
Короткий ответ: Строка в Go — это иммутабельная последовательность байтов в кодировке UTF-8, и len() считает байты, а не символы. Кириллическая буква занимает 2 байта, поэтому len("привет") == 12, а не 6. Символы (руны) считают через utf8.RuneCountInString или len([]rune(s)).
Подробно:
len(s)— число байтов. Для ASCII совпадает с числом символов, для многобайтовых — нет.- Подсчёт рун —
utf8.RuneCountInString(s)(без аллокации) илиlen([]rune(s))(с аллокацией слайса рун). rangeпо строке — идёт по рунам, но индексi— байтовый (позиция начала руны), а не порядковый номер символа.
s := "привет"
len(s) // 12 — байты
utf8.RuneCountInString(s) // 6 — руны
len([]rune(s)) // 6 — руны
for i, r := range s { // i — байтовый индекс
fmt.Printf("%d:%c ", i, r)
}
⚠️ Частая ошибка: s[0] = 'x' не скомпилируется — строки неизменяемы. А s[0] возвращает byte (первый байт), а не первый символ; для кириллицы это половина руны.
13В чём разница между make и new?
junior
Короткий ответ: new(T) выделяет зануленную память под любой тип и возвращает указатель *T. make работает только для slice, map и chan — он не просто выделяет память, а инициализирует внутренние структуры и возвращает готовое к работе значение (не указатель).
Подробно:
new(T)— универсален, даёт*Tна zero value.new(int)→*int, указывающий на 0.make(T, ...)— только slice/map/chan; настраивает заголовок слайса / hmap / hchan. Возвращает самT, а не указатель.- Почему так — slice, map и chan бесполезны в «нулевом» виде без внутренней инициализации, поэтому для них есть отдельный
make.
new(T) |
make(T, ...) |
|
|---|---|---|
| Типы | любой | slice, map, chan |
| Возвращает | *T |
T |
| Инициализация | зануление | внутренние структуры |
| Пример | new(int) → *int |
make([]int, 3) → []int |
⚠️ Частая ошибка: new(map[string]int) возвращает *map[string]int, указывающий на nil-мапу. Разыменовали и пишете — panic: assignment to entry in nil map. Для мап нужен make.
14Назовите zero value слайса, мапы, канала, указателя и интерфейса — с какими из них можно работать без инициализации?
concept
Короткий ответ: У всех перечисленных zero value — nil, но ведут они себя по-разному. С nil-слайсом можно работать (len, append), из nil-мапы можно читать, но не писать, nil-канал блокируется навсегда, разыменование nil-указателя паникует, а вызов метода на nil-интерфейсе паникует.
Подробно:
- Слайс —
nil;len,range,appendработают. Самый «дружелюбный» nil. - Мапа —
nil; чтение отдаёт zero value, запись паникует. - Канал —
nil; отправка и приём блокируются навсегда (используется осознанно, чтобы «выключить» ветку вselect). - Указатель —
nil; разыменование*p→panic: nil pointer dereference. - Интерфейс —
nil; сравнение сnilок, вызов метода паникует.
| Тип | zero value | Без init |
|---|---|---|
| слайс | nil |
len/append ок |
| мапа | nil |
чтение ок, запись — паника |
| канал | nil |
блок навсегда |
| указатель | nil |
разыменование — паника |
| интерфейс | nil |
вызов метода — паника |
⚠️ Частая ошибка: валить всё в кучу «nil есть nil». Ключевой нюанс — nil-мапа читается, но не пишется, а nil-канал не паникует, а блокируется.
15Что такое iota и как с его помощью делают enum?
junior
Короткий ответ: iota — это автоинкрементный счётчик внутри блока const: он равен 0 в первой строке блока и увеличивается на 1 с каждой следующей строкой ConstSpec. Обнуляется в каждом новом блоке const. На нём строят enum-подобные наборы констант.
Подробно:
- Базовый enum — перечисляем константы,
iotaподставляет 0, 1, 2… автоматически. - Пропуск значения —
_ = iotaпропускает 0 (часто чтобы zero value означал «не задано»). - Битовые флаги —
1 << iotaдаёт степени двойки для масок.
type Weekday int
const (
Sunday Weekday = iota // 0
Monday // 1
Tuesday // 2
)
type Perm uint
const (
Read Perm = 1 << iota // 1
Write // 2
Exec // 4
)
const (
_ = iota // пропускаем 0
KB = 1 << (10 * iota) // 1024
MB // 1048576
)
⚠️ Частая ошибка: думать, что iota считает константы. Он считает строки ConstSpec в блоке: если в одной строке объявлено несколько констант через запятую, на всех них iota одинаковый, а прирост идёт по строкам.
16Почему интерфейс, в который положили типизированный nil-указатель, не равен nil?
senior
Короткий ответ: Интерфейс внутри — это пара (тип, значение), и он равен nil только когда обе части пусты. Если положить в него типизированный nil-указатель ((*MyErr)(nil)), часть «тип» заполнена, поэтому iface == nil даёт false, хотя данные внутри — nil.
Подробно:
- Пара (тип, значение) — сравнение
i == nilистинно, только если и тип, и указатель на данные нулевые. - Типизированный nil — переменная
var e *MyErrсама равна nil, но при возврате черезerrorв интерфейс уезжает её тип*MyErr. Тип задан → интерфейс не nil. - Классический прод-баг — функция возвращает
error, вызывающий пишетif err != nil, и ветка ошибки срабатывает на «успехе».
type MyErr struct{}
func (e *MyErr) Error() string { return "boom" }
func do() error {
var e *MyErr = nil // типизированный nil
return e // уходит в интерфейс error вместе с типом
}
func main() {
err := do()
fmt.Println(err == nil) // false — ловушка!
}
чистый error(nil) error с (*MyErr)(nil)
┌──────┬───────┐ ┌─────────┬─────────┐
│ type │ value │ │ *MyErr │ nil │
│ nil │ nil │ │ (задан) │ (данные)│
└──────┴───────┘ └─────────┴─────────┘
== nil ✔ != nil ✘
⚠️ Частая ошибка: заводить переменную ошибки конкретного типа (var e *MyErr) и возвращать её из функции с сигнатурой error. Держите переменную как var err error или делайте return nil явно — типизированный nil-указатель не должен утекать в интерфейс.
17Как устроены iface и eface на уровне рантайма?
senior
Короткий ответ: Пустой интерфейс (interface{}/any) представлен структурой eface из двух указателей: на дескриптор типа (*_type) и на данные. Интерфейс с методами — iface: указатель на itab (тип + таблица методов) и указатель на данные. itab строится один раз и кэшируется рантаймом.
Подробно:
- eface —
{*_type, data unsafe.Pointer}. Знает конкретный тип значения, но не несёт таблицы методов. - iface —
{*itab, data unsafe.Pointer}. Здесьitab = {inter (тип интерфейса), _type (конкретный тип), hash, fun[...] — указатели на методы}. - Кэш itab — рантайм строит
itabдля пары(интерфейс, тип)один раз и кладёт в глобальный хэш; последующие присваивания берут готовый. - data — указатель на значение; для значения оно, как правило, боксится (копия в куче).
eface (any) iface (интерфейс с методами)
┌────────────┐ ┌────────────┐
│ *_type │─► тип │ *itab │─► ┌──────────────┐
├────────────┤ ├────────────┤ │ inter (тип I)│
│ data │─► данные │ data │ │ _type (тип T)│
│ unsafe.Ptr │ │ unsafe.Ptr │ │ hash │
└────────────┘ └────────────┘ │ fun[0..n] ───│─► методы
└──────────────┘
⚠️ Частая ошибка: считать any/interface{} бесплатным. Упаковка значения в интерфейс часто вызывает аллокацию (боксинг) и мешает инлайну — на горячем пути это заметно.
18Как в Go реализован полиморфизм без классов и наследования?
junior
Короткий ответ: Полиморфизм даёт неявная (структурная) реализация интерфейсов: тип удовлетворяет интерфейсу автоматически, если у него есть нужный набор методов — никакого implements. Вместо наследования — композиция через встраивание (embedding), а инкапсуляция — регистром первой буквы имени.
Подробно:
- Структурная типизация (duck typing) — «если крякает как утка». Тип и интерфейс нигде явно не связывают; связь проверяется по сигнатурам методов на этапе компиляции.
- Композиция вместо наследования — встраивание продвигает методы встроенного типа наружу, но это
has-a, а неis-a. - Инкапсуляция — имя с заглавной буквы экспортируется, со строчной — приватно внутри пакета.
type Stringer interface{ String() string }
type Point struct{ X, Y int }
func (p Point) String() string { // Point реализует Stringer
return fmt.Sprintf("(%d,%d)", p.X, p.Y)
} // без "implements Stringer" — достаточно совпадения метода
⚠️ Частая ошибка: называть embedding «наследованием». Здесь нет подтипов и виртуальных вызовов: внешний тип не приводится к встроенному, а метод встроенного типа не «переопределяется» и не видит полей внешнего.
19Как сделать type assertion, не рискуя паникой?
junior
Короткий ответ: Используйте comma-ok форму v, ok := i.(T): при несовпадении типа ok == false, а v получает нулевое значение — без паники. Для разбора нескольких вариантов берите type switch. Одиночная форма i.(T) при несовпадении паникует.
Подробно:
- Одиночная форма
i.(T)— паникует, если динамический тип неT. Годится, только когда тип гарантирован. - Comma-ok
v, ok := i.(T)— безопасна; всегда проверяйтеokперед использованиемv. - Type switch
switch v := i.(type)— идиома для нескольких типов сразу.
var i any = "hello"
s := i.(string) // ок; но i.(int) → panic
s, ok := i.(string) // ok == true, s == "hello"
n, ok := i.(int) // ok == false, n == 0 (без паники)
switch v := i.(type) {
case string: fmt.Println("строка", v)
case int: fmt.Println("число", v)
default: fmt.Println("другой тип")
}
⚠️ Частая ошибка: писать одиночную v := i.(T) по данным, тип которых не гарантирован (запросы, JSON, плагины). Один неожиданный тип роняет сервис паникой — используйте comma-ok или type switch.
20Value receiver против pointer receiver: в чём разница и как выбор влияет на реализацию интерфейса?
middle
Короткий ответ: Pointer receiver (*T) может менять оригинал и не копирует структуру при вызове; value receiver (T) работает с копией. Для интерфейсов ключевое: method set у *T содержит и value-, и pointer-методы, а у T — только value-методы. Поэтому если метод объявлен на *T, значение типа T интерфейс не удовлетворяет.
Подробно:
- Мутация — pointer-метод меняет получателя; value-метод правит копию, и изменения теряются.
- Копирование — value receiver копирует всю структуру на каждый вызов; для больших структур это накладно.
- Method set — определяет, что подходит под интерфейс:
| Ресивер метода | В method set T |
В method set *T |
|---|---|---|
func (t T) M() |
✔ | ✔ |
func (t *T) M() |
✘ | ✔ |
Отсюда: если M объявлен на *T, то var _ I = T{} не скомпилируется — нужно var _ I = &T{}. При прямом вызове t.M() на адресуемой переменной Go сам берёт &t, но для удовлетворения интерфейса эта магия не работает.
⚠️ Частая ошибка: смешивать value- и pointer-ресиверы на одном типе, а потом удивляться, почему T{} не подходит под интерфейс. Выбирайте один вид ресивера на тип; есть хоть один pointer-метод — обычно делают pointer у всех.
21Как работает встраивание структур и что будет, если два embedded-типа имеют одинаковый метод?
middle
Короткий ответ: Встраивание (embedding) — включение типа в структуру без имени поля; его методы и поля продвигаются (promotion) на внешнюю структуру. Метод внешней структуры затеняет одноимённый встроенный. Если два встроенных типа несут метод с одним именем на одной глубине — селектор становится неоднозначным (ambiguous), и компилятор ругается при вызове без явного пути.
Подробно:
- Promotion —
a.Method()вызывает метод встроенного типа, если у внешнего своего нет. - Затенение (shadowing) — метод/поле внешнего уровня перекрывает встроенное; выигрывает ближайший по глубине.
- Ambiguous selector — два кандидата на одной глубине → ошибка компиляции; разрешается явным путём
a.Inner.Method().
type A struct{}
func (A) Hello() string { return "A" }
type B struct{}
func (B) Hello() string { return "B" }
type C struct{ A; B } // оба встроены на одной глубине
func main() {
c := C{}
// c.Hello() // ошибка компиляции: ambiguous selector
_ = c.A.Hello() // "A" — явный путь
_ = c.B.Hello() // "B"
}
⚠️ Частая ошибка: ждать от embedding переопределения, как в ООП. Виртуальных вызовов нет: если метод A внутри зовёт Hello(), он позовёт именно A.Hello(), а не «переопределённый» на уровне C.
22Чем горутина отличается от потока ОС?
junior
Короткий ответ: Горутина — это лёгкая единица выполнения, которой управляет рантайм Go, а не ядро ОС. Её стек стартует с ~2 КБ и растёт динамически, тогда как поток ОС резервирует мегабайты фиксированного стека. Рантайм мультиплексирует тысячи горутин на небольшое число потоков по модели M:N, поэтому в одном процессе спокойно живут сотни тысяч горутин.
Подробно:
- Стек — горутина: ~2 КБ с ростом/сжатием по мере надобности; поток ОС: фиксированные 1–8 МБ, зарезервированные заранее.
- Планировщик — горутины планирует рантайм Go (модель GMP: G — горутина, M — поток ОС, P — процессор-контекст) в user space; потоки планирует ядро.
- Стоимость переключения — переключение горутины не заходит в ядро и на порядки дешевле переключения контекста потока.
- Масштаб — потоков реалистично тысячи, горутин — сотни тысяч и больше.
| Критерий | Горутина | Поток ОС |
|---|---|---|
| Стек | ~2 КБ, растёт | 1–8 МБ, фиксирован |
| Кто планирует | рантайм Go (M:N) | ядро ОС |
| Переключение | user space, дёшево | системный вызов, дорого |
| Сколько на процесс | сотни тысяч | тысячи |
⚠️ Частая ошибка: ответить «это лёгкий поток» и остановиться. Без упоминания планировщика рантайма и модели M:N ответ звучит по-джуновски — интервьюер ждёт именно механику.
23В чём разница между буферизированным и небуферизированным каналом?
junior
Короткий ответ: Небуферизированный канал передаёт значение синхронно, из рук в руки: send блокируется, пока другая горутина не сделает receive, и наоборот. Буферизированный канал позволяет отправить значение без ожидания получателя, пока в буфере есть место, и блокирует send только когда буфер полон (а receive — когда буфер пуст).
Подробно:
- Небуферизированный (
make(chan T)) — точка синхронизации:sendиreceiveвстречаются в один момент, обе стороны выступают барьером друг для друга. - Буферизированный (
make(chan T, N)) — очередь на N элементов: отправитель может уйти вперёд получателя на размер буфера. - Когда буфер помогает — сгладить всплески нагрузки, развязать производителя и потребителя по темпу; но буфер прячет проблемы бэкпрешера, если выбран «на глаз».
func main() {
ch := make(chan int) // небуферизированный
ch <- 1 // блок навсегда: получателя нет
fmt.Println(<-ch) // сюда уже не дойдём
}
// fatal error: all goroutines are asleep - deadlock!
⚠️ Частая ошибка: отправить в небуферизированный канал в той же горутине, где потом собираешься читать. send заблокируется до появления получателя, а получатель — это следующая строка той же горутины, до которой управление не доходит → deadlock.
24Что будет при отправке в закрытый канал, чтении из него и повторном close?
middle
Короткий ответ: Отправка в закрытый канал вызывает панику. Чтение из закрытого канала сначала возвращает оставшиеся в буфере значения, а затем — нулевое значение типа с ok == false. Повторный close того же канала тоже паникует.
Подробно:
- Send в закрытый —
panic: send on closed channel. Поэтому канал закрывает горутина, которая владеет жизненным циклом отправки и знает, что новых значений точно не будет; получатель этого знать не может. - Receive из закрытого — буфер дренируется как обычно; когда он пуст, чтения мгновенно возвращают zero value и
ok == falseбез блокировки. - Повторный close —
panic: close of closed channel; двойнойclose— типичный симптом отсутствия единого владельца. - Close nil-канала — тоже паника (
close of nil channel).
| Операция над каналом | Открытый | Закрытый |
|---|---|---|
ch <- v |
ок / блок | panic |
<-ch |
ок / блок | остатки буфера, затем zero + ok=false |
close(ch) |
закрывает | panic |
⚠️ Частая ошибка: считать, что чтение из закрытого канала блокируется. Наоборот — оно возвращается мгновенно, поэтому закрытый канал в select в цикле будет «стрелять» на каждой итерации, если не занулить его переменную.
25Что произойдёт при чтении или записи в nil-канал и как это используют в select?
middle
Короткий ответ: И send, и receive на nil-канале блокируются навсегда. Это не баг, а инструмент: присвоив переменной канала nil, вы «выключаете» соответствующую ветку select — она перестаёт быть готовой и больше не выбирается.
Подробно:
- Семантика nil-канала —
<-nilChиnilCh <- vблокируются вечно;close(nilCh)паникует. - Зачем это в select — закрытый канал в
selectготов мгновенно и в цикле «стреляет» zero value без конца. Чтобы отключить исчерпанный вход, его переменную зануляют — тогда ветка навсегда не готова. - Паттерн fan-in — сливаем два канала в один и по мере закрытия источников выключаем их ветки.
for a != nil || b != nil {
select {
case v, ok := <-a:
if !ok { a = nil; continue } // выключаем ветку a
out <- v
case v, ok := <-b:
if !ok { b = nil; continue } // выключаем ветку b
out <- v
}
}
close(out)
⚠️ Частая ошибка: оставить закрытый канал в select как есть. Ветка case <-closedCh будет срабатывать на каждой итерации и крутить busy-loop на 100% CPU, вместо того чтобы обслуживать оставшиеся источники.
26Как понять при чтении, что канал закрыт?
junior
Короткий ответ: Через форму v, ok := <-ch: после закрытия и полного дренажа буфера ok становится false. Либо через for v := range ch — цикл сам завершается, когда канал закрыт и опустошён.
Подробно:
- Comma-ok —
v, ok := <-ch: пока канал открыт или в буфере есть данные,ok == true; после закрытия и опустошенияv— нулевое значение типа,ok == false. - range —
for v := range chчитает до закрытия и выходит без лишнего кода; идиоматичный способ для потребителя. - Зачем именно ok — отличить «прислали настоящий zero value» (например,
0или"") от «канал закрыт» можно только поok; само значение неотличимо.
// форма 1: comma-ok
v, ok := <-ch
if !ok {
// канал закрыт и пуст
}
// форма 2: range — выходит сам при close(ch)
for v := range ch {
process(v)
}
⚠️ Частая ошибка: проверять «канал закрыт?» сравнением значения с нулём (if v == 0). Настоящий 0, отправленный в канал, неотличим от zero value закрытого канала — единственный надёжный сигнал это ok.
27Кто должен закрывать канал — отправитель или получатель? Почему?
middle
Короткий ответ: Закрывать канал должен отправитель, и только он. close — это сигнал «данных больше не будет», а его вправе давать лишь тот, кто пишет. Если канал закроет получатель, отправитель на следующем send получит panic: send on closed channel.
Подробно:
- Владелец = писатель — тот, кто создал и пишет в канал, отвечает за его закрытие; читатель просто читает до конца.
- Несколько отправителей — ни один из них не закрывает канал напрямую (иначе двойной
close→ паника). Нужен координатор:WaitGroupдожидается всех писателей, отдельная горутина делает единственныйclose. - Зачем закрывать вообще — чтобы разбудить
range/comma-okу получателей; если получатель и так знает, когда остановиться, канал можно не закрывать (GC приберёт).
var wg sync.WaitGroup
for _, w := range workers {
wg.Add(1)
go func(w Worker) { defer wg.Done(); w.emit(out) }(w)
}
// единственный close — после всех отправителей
go func() { wg.Wait(); close(out) }()
for v := range out { process(v) }
⚠️ Частая ошибка: закрывать канал на стороне получателя «чтобы освободить». Это провоцирует панику у любого пишущего и ломает контракт — освобождение делает сборщик мусора, а не close.
28Как select выбирает ветку, если готовы несколько каналов, и что меняет default?
middle
Короткий ответ: Если готовы сразу несколько веток, select выбирает одну из них псевдослучайно — это защита от starvation, чтобы один постоянно готовый канал не вытеснял остальные. Если не готова ни одна ветка, select блокируется до появления готовой; ветка default делает select неблокирующим — при отсутствии готовых он мгновенно уходит в default.
Подробно:
- Несколько готовых — равновероятный случайный выбор среди готовых веток (не по порядку в коде).
- Ни одной готовой, без default — блокировка до первой готовности.
- default — исполняется сразу, если ничего не готово; так делают неблокирующие приём/отправку и poll-циклы.
- Таймаут — классический паттерн через
time.After, который присылает значение по истечении срока.
select {
case res := <-work:
return res, nil
case <-time.After(2 * time.Second):
return nil, errors.New("timeout")
case <-ctx.Done():
return nil, ctx.Err()
}
⚠️ Частая ошибка: класть time.After внутрь горячего цикла на каждой итерации. До Go 1.23 каждый такой Timer жил в куче до срабатывания — на высокой частоте это была заметная утечка; с Go 1.23 несработавшие таймеры собирает GC, но аллокация на каждой итерации осталась, поэтому для повторов берут time.NewTimer/NewTicker и переиспользуют.
29Что такое утечка горутин и как её найти в работающем сервисе?
middle
Короткий ответ: Утечка горутин — это горутина, которая заблокирована навсегда и никогда не завершится: send в канал без читателя, receive из канала, куда никто не пишет, ожидание без отмены по контексту. Такие горутины держат свои стеки и захваченную память, а их число монотонно растёт. Ищут по профилю pprof goroutine и по метрике runtime.NumGoroutine().
Подробно:
- Причины — блокирующая операция без пути выхода: заброшенный канал, отсутствие
ctx.Done(), забытыйWaitGroup. - Диагностика —
net/http/pprofдаёт goroutine-профиль со стеками (видно, где именно висят);runtime.NumGoroutine()в метриках показывает монотонный рост. - Профилактика — у каждой горутины должен быть гарантированный путь выхода:
ctx.Done(),done-канал или закрытие входного канала.
// УТЕЧКА: получатель ушёл по timeout, отправитель висит на send навсегда
func leak() <-chan int {
ch := make(chan int) // небуферизированный
go func() { ch <- expensive() }() // некому читать → блок навсегда
return ch
}
// ФИКС: буфер на 1 даёт отправителю уйти, даже если получателя нет
func fixed() <-chan int {
ch := make(chan int, 1)
go func() { ch <- expensive() }()
return ch
}
⚠️ Частая ошибка: запустить горутину-писателя в небуферизированный канал и вернуть его наружу, где получатель может уйти по таймауту. Отправитель зависнет на send навсегда — классическая утечка на каждый такой вызов.
30Уронит ли паника в одной горутине весь процесс?
middle
Короткий ответ: Да. Необработанная паника в любой горутине разворачивает её стек до конца и, если её никто не поймал через recover, завершает всю программу целиком. Паника не изолируется в пределах одной горутины.
Подробно:
- Масштаб — паника поднимается по стеку своей горутины; дойдя до верха без
recover, роняет весь процесс с трейсом. - recover только «свой» —
recover()работает лишь вdeferвнутри той же горутины, где случилась паника. Поймать панику соседней горутины нельзя — там нет общего стека. - Практика для воркеров — каждую горутину, куда может прилететь паника, оборачивают в
defer/recover, чтобы одна сбойная задача не убила сервис.
func safeGo(task func()) {
go func() {
defer func() {
if r := recover(); r != nil {
log.Printf("recovered: %v", r)
}
}()
task() // паника здесь не уронит процесс
}()
}
⚠️ Частая ошибка: думать, что «умрёт только эта горутина». Наоборот — падает весь процесс; и recover в главной горутине не спасёт от паники в дочерней, ловить нужно там же, где паникуешь.
31Какая классическая ловушка была с захватом переменной цикла в горутинах и что поменял Go 1.22?
middle
Короткий ответ: До Go 1.22 переменная цикла была одна на все итерации, и горутины, замкнувшие её, видели её общее текущее значение — к моменту их запуска цикл обычно уже завершался, и все печатали последнее значение. В Go 1.22 семантику поменяли: теперь на каждой итерации создаётся своя копия переменной, и ловушка исчезла.
Подробно:
- Причина (до 1.22) —
i(илиvвrange) — единственная переменная, переиспользуемая между итерациями; замыкание захватывает её по ссылке, а не значение на момент создания. - Симптом — вывод вроде
3 3 3вместо0 1 2: горутины стартуют после цикла, когда переменная уже равна финалу. - Go 1.22 — переменная цикла стала per-iteration; старый код «просто заработал правильно» при
go 1.22вgo.mod. - Фикс для старых версий —
i := i(shadowing) или передача аргументом в горутину.
// До Go 1.22 печатало 3 3 3; с 1.22 — 0 1 2
for i := 0; i < 3; i++ {
go func() { fmt.Println(i) }()
}
// Фикс, работающий на любой версии — передать аргументом:
for i := 0; i < 3; i++ {
go func(i int) { fmt.Println(i) }(i)
}
⚠️ Частая ошибка: на собеседовании уверенно сказать «выведет 0 1 2», не уточнив версию Go. Правильный ответ зависит от неё: до 1.22 — почти наверняка 3 3 3, с 1.22 — 0 1 2 (в неопределённом порядке).
32Как корректно остановить запущенные горутины снаружи?
middle
Короткий ответ: Убить горутину извне нельзя — в Go нет kill. Остановка только кооперативная: горутина сама периодически проверяет сигнал отмены и выходит. Идиоматично — через отмену context; альтернатива — закрытие общего done-канала, который работает как broadcast сразу всем слушателям.
Подробно:
- Нет принудительного kill — рантайм не даёт остановить чужую горутину; она обязана сотрудничать.
- context (идиоматично) — родитель вызывает
cancel(), горутина ловит<-ctx.Done()в рабочемselectи возвращается; заодно прокидывается таймаут/дедлайн. - done-канал —
close(done)мгновенно разблокирует всех, кто читает<-done(broadcast); удобно, когда контекст избыточен. - Обязательный дренаж — при выходе нужно не оставить зависших писателей/читателей на других каналах.
func worker(ctx context.Context, in <-chan Job) {
for {
select {
case <-ctx.Done(): // сигнал остановки снаружи
return
case job, ok := <-in:
if !ok { return }
process(job)
}
}
}
⚠️ Частая ошибка: ждать, что cancel() немедленно прервёт работу горутины. Он лишь закрывает ctx.Done(); если в цикле нет проверки этого канала, горутина продолжит крутиться как ни в чём не бывало.
33Как net/http обрабатывает входящие запросы — что там с конкурентностью?
middle
Короткий ответ: Сервер net/http запускает отдельную горутину на каждое входящее соединение (и, соответственно, на каждый запрос). Поэтому хендлеры вызываются конкурентно, и обращаться к общему изменяемому состоянию из них нужно под синхронизацией — мьютексом или через атомики/каналы. Отмена со стороны клиента видна через r.Context(), который отменяется при разрыве соединения.
Подробно:
- Горутина на соединение —
Server.Serveв цикле принимает соединение и запускаетgo c.serve(...); хендлеры разных запросов выполняются параллельно. - Требование к хендлерам — они обязаны быть безопасны для конкурентного вызова: общее состояние (кэш, счётчики, map) — под
sync.Mutexилиsync/atomic. - r.Context() — отменяется при разрыве клиента или дедлайне; используйте его, чтобы прервать дорогие операции (запрос в БД, вызов апстрима) и не жечь ресурсы на брошенный запрос.
// ГОНКА: конкурентная запись в map из многих горутин-хендлеров
var cache = map[string]int{}
func bad(w http.ResponseWriter, r *http.Request) {
cache[r.URL.Path]++ // concurrent map writes → fatal
}
// ФИКС: защитить общий доступ мьютексом
var mu sync.Mutex
func good(w http.ResponseWriter, r *http.Request) {
mu.Lock(); cache[r.URL.Path]++; mu.Unlock()
}
⚠️ Частая ошибка: держать общее состояние в хендлере без синхронизации, считая, что «запросы идут по очереди». Они идут параллельно; конкурентная запись в map роняет процесс с fatal error: concurrent map writes.
34В чём разница между sync.Mutex и sync.RWMutex и когда RWMutex реально оправдан?
junior
Короткий ответ: Mutex даёт эксклюзивный доступ — одна горутина в критической секции в любой момент. RWMutex разделяет блокировку на две: под RLock() читатели заходят параллельно, а Lock() писателя эксклюзивен и ждёт, пока все читатели выйдут. RWMutex оправдан только там, где чтений заметно больше, чем записей.
Подробно:
- Mutex — простой замок:
Lock()/Unlock(), всегда один владелец. Дефолтный выбор. - RWMutex —
RLock()для читателей (можно много одновременно),Lock()для писателя (один, эксклюзивно). - Когда переходить на RWMutex — только если профилирование показало contention на чтениях и соотношение read:write велико (условно 10:1 и выше), а критическая секция не микроскопическая.
| Критерий | sync.Mutex | sync.RWMutex |
|---|---|---|
| Читатели | по одному | параллельно |
| Писатель | эксклюзивно | эксклюзивно |
| Накладные расходы | ниже | выше (сложнее внутри) |
| Когда | по умолчанию | чтений >> записей |
⚠️ Частая ошибка: ставить RWMutex «на всякий случай» и пугать голоданием писателей. В Go его нет: RWMutex write-preferring — ожидающий Lock() блокирует новых читателей (побочный эффект: рекурсивный RLock() в одной горутине может дать дедлок). Реальная цена — более дорогой захват: на коротких секциях обычный Mutex часто быстрее.
35Как работает sync.WaitGroup и какая классическая ошибка с wg.Add()?
middle
Короткий ответ: WaitGroup — это счётчик горутин: Add(n) увеличивает его, Done() уменьшает на единицу, Wait() блокируется, пока счётчик не дойдёт до нуля. Классическая ошибка — вызывать Add(1) внутри уже запущенной горутины: Wait() может увидеть нулевой счётчик и пройти дальше до того, как горутина успела инкрементировать его.
Подробно:
- Правильный порядок —
Add(1)вызывается в родительской горутине доgo func(), аDone()ставится черезdeferв самой горутине. - Почему гонка — если
Addвнутри горутины, планировщик может датьWait()отработать раньше запуска — тогдаwgуже «пуст» и ждать нечего. - Go 1.25 — появился
wg.Go(fn): он сам делаетAdd(1)до старта иDone()после, закрывая этот класс багов. Убедись, что проект уже на 1.25, прежде чем использовать.
// НЕПРАВИЛЬНО — Add внутри горутины, гонка с Wait
for _, u := range users {
go func(u User) {
wg.Add(1) // может не успеть до Wait()
defer wg.Done()
process(u)
}(u)
}
wg.Wait()
// ПРАВИЛЬНО — Add до go, Done через defer
for _, u := range users {
wg.Add(1)
go func(u User) {
defer wg.Done()
process(u)
}(u)
}
wg.Wait()
⚠️ Частая ошибка: забыть defer wg.Done() при раннем return/panic — счётчик не дойдёт до нуля и Wait() повиснет навсегда (дедлок).
36Что гарантирует sync.Once и где его типично применяют?
junior
Короткий ответ: sync.Once гарантирует, что переданная в Do(f) функция выполнится ровно один раз за всё время жизни программы, даже если Do дёргают из множества горутин одновременно. Остальные вызывающие блокируются, пока первый не завершит f, и только потом идут дальше. Типичный кейс — ленивая инициализация синглтона.
Подробно:
- Гарантия — «ровно один раз» плюс happens-before: после возврата из
Doрезультатfвиден всем горутинам без гонок. - Синхронность — конкурентные вызовы не проскакивают мимо, а ждут завершения первого; это не «попытался — и ладно».
- Где применяют — ленивое создание конфига, пула соединений, клиента к внешнему сервису — когда инициализация дорогая и нужна один раз.
- Go 1.21+ —
sync.OnceValue(f)иsync.OnceFunc(f)возвращают функцию с той же семантикой, но без ручного объявленияOnceи флага.
var (
once sync.Once
instance *DB
)
func GetDB() *DB {
once.Do(func() {
instance = connect() // выполнится один раз
})
return instance
}
// Go 1.21+: то же самое короче
var GetDB = sync.OnceValue(func() *DB { return connect() })
⚠️ Частая ошибка: повторно использовать одну Once для «переинициализации» — сбросить её нельзя, второй Do просто ничего не сделает. Для этого нужен другой механизм.
37Когда sync/atomic уместнее мьютекса?
middle
Короткий ответ: sync/atomic уместен, когда нужно атомарно менять одно машинное слово — счётчик, флаг, указатель — без блокировки. Такие lock-free операции (в основе — CAS, compare-and-swap) дешевле мьютекса, потому что не усыпляют горутину и не ходят в планировщик. Как только инвариант охватывает несколько полей сразу, atomic уже не спасает — нужен Mutex.
Подробно:
- Одиночное слово — инкремент счётчика, установка флага, подмена указателя на конфиг:
atomicбыстрее и не блокирует. - CAS —
CompareAndSwapменяет значение, только если оно совпало с ожидаемым; на нём строят lock-free алгоритмы, но их легко написать с багами. - Составной инвариант — если надо согласованно изменить два и более полей (например
balanceиhistory), atomic не гарантирует их совместную атомарность — толькоMutex. - Go 1.19+ — типизированные
atomic.Int64,atomic.Bool,atomic.Pointer[T]: безопаснее и читабельнее старых функцийatomic.AddInt64(&x, ...).
| Ситуация | atomic | Mutex |
|---|---|---|
| Счётчик/флаг | да, дёшево | избыточно |
| Подмена указателя | atomic.Pointer[T] |
можно, но дороже |
| Несколько полей вместе | нельзя | да |
| Сложная критическая секция | нельзя | да |
⚠️ Частая ошибка: собирать «согласованное» состояние из нескольких atomic-переменных. По отдельности каждая атомарна, но вместе они читаются в рассогласованном виде — это уже гонка на уровне логики.
38Зачем нужен sync.Map и почему это не универсальная замена map с мьютексом?
middle
Короткий ответ: sync.Map — специализированная конкурентная мапа, оптимизированная под два сценария: ключ пишется один раз и потом много читается, либо разные горутины работают с непересекающимися наборами ключей. Внутри она снижает contention на многих ядрах. В общем же случае обычная map под RWMutex быстрее, типобезопаснее и понятнее.
Подробно:
- Под что заточена — read-heavy кэши со стабильными ключами; внутри есть отдельная read-only копия, которую читают без блокировки.
- Почему не универсальна — при активных вставках/удалениях разных ключей
sync.Mapпроигрываетmap+RWMutexиз-за промахов мимо read-only копии и её перестроений. - Типобезопасность — API работает с
any: на каждом обращении идёт упаковка в интерфейс и приведение типа, компилятор не проверит ключ и значение. - Правило выбора — начинай с
mapподRWMutex; переходи наsync.Map, только если профиль подтвердил один из её двух паттернов.
| Критерий | map + RWMutex | sync.Map |
|---|---|---|
| Типы | статические, проверяет компилятор | any, приведения в рантайме |
| Записи разных ключей | быстро | медленнее |
| Read-heavy, стабильные ключи | ок | быстрее |
| Читабельность | выше | ниже |
⚠️ Частая ошибка: тащить sync.Map по умолчанию «потому что concurrent». В большинстве задач map под RWMutex и проще, и быстрее.
39Как работает флаг -race и почему он не ловит все гонки?
middle
Короткий ответ: -race включает динамический детектор гонок на основе ThreadSanitizer: компилятор инструментирует каждый доступ к памяти, и рантайм строит happens-before, отслеживая, что два обращения к одной ячейке из разных горутин не упорядочены и хотя бы одно — запись. Ключевое ограничение: он видит только те гонки, которые реально произошли на исполненном пути в этом запуске.
Подробно:
- Как включить —
go test -race,go run -race,go build -race; место ему в тестах и CI, не в проде. - Динамический, не статический — если код с гонкой не выполнился (не тот вход, не тот тайминг планировщика), детектор промолчит. Отсюда — гонять под нагрузкой и разными сидами.
- Цена — замедление по CPU в 2–20 раз и рост потребления памяти в 5–10 раз; поэтому только в тестах, не на боевом трафике.
- Вывод — при поимке печатает стек читателя, писателя и место аллокации, что и указывает на баг.
==================
WARNING: DATA RACE
Write at 0x00c0000b4010 by goroutine 7:
main.incr()
/app/main.go:14 +0x44
Previous read at 0x00c0000b4010 by goroutine 6:
main.read()
/app/main.go:9 +0x38
==================
⚠️ Частая ошибка: считать «прошло под -race» доказательством отсутствия гонок. Это отсутствие доказательства, а не доказательство отсутствия — непокрытый путь останется незамеченным.
40Почему нельзя копировать sync.Mutex (или структуру с ним) и как go vet это ловит?
middle
Короткий ответ: Копия мьютекса дублирует его внутреннее состояние (флаг захвата, счётчик ожидающих), и получаются два независимых замка вместо одного — взаимное исключение молча ломается, разные горутины считают, что «залочили», работая с разными копиями. go vet через анализатор copylocks помечает любую передачу такой структуры по значению.
Подробно:
- Что ломается — после копирования оригинал и копия не синхронизированы между собой; защита пропадает без единой ошибки в рантайме.
- Как ловит vet —
copylocksсмотрит на типы, реализующиеLock/Unlock(sync.Locker), и ругается на присваивание, передачу в функцию или возврат по значению. - Правило — тип с мьютексом внутри держат и передают только по указателю, а методы объявляют с pointer receiver (
func (s *Store)), не value receiver.
type Counter struct {
mu sync.Mutex
n int
}
// БАГ: value receiver копирует Counter вместе с mu на каждом вызове
func (c Counter) Inc() { c.mu.Lock(); c.n++; c.mu.Unlock() }
// go vet: Inc passes lock by value: Counter contains sync.Mutex
// Правильно: pointer receiver — работаем с одним замком
func (c *Counter) Inc() { c.mu.Lock(); c.n++; c.mu.Unlock() }
⚠️ Частая ошибка: объявить метод с value receiver у структуры с sync.Mutex. Замок копируется на каждый вызов, инкремент теряется под гонкой — а go vet в CI сразу это подсветит.
41Зачем нужен context и чем отличаются WithCancel, WithTimeout и WithDeadline?
middle
Короткий ответ: context переносит через границы API и горутин три вещи: сигнал отмены, дедлайн и request-scoped значения. Контексты образуют дерево — отмена родителя отменяет всех потомков. WithCancel даёт ручную отмену, WithDeadline — отмену в абсолютный момент времени, WithTimeout — это сахар над WithDeadline (дедлайн = «сейчас + длительность»).
Подробно:
- WithCancel — возвращает
ctx, cancel; отменяешь вручную, когда работа больше не нужна (например, первый ответ из нескольких горутин уже пришёл). - WithDeadline — отмена наступит не позже указанного
time.Time; удобно, когда дедлайн задан извне и абсолютен. - WithTimeout — то же, что
WithDeadline(now + d); удобно для относительных таймаутов запроса. - Общее — все три возвращают
cancel, который надо звать черезdefer, чтобы освободить ресурсы контекста; отмена распространяется вниз по дереву.
| Конструктор | Триггер отмены | Когда |
|---|---|---|
| WithCancel | ручной вызов cancel() |
отмена по событию/условию |
| WithDeadline | наступление time.Time |
абсолютный дедлайн извне |
| WithTimeout | истечение длительности | относительный таймаут |
⚠️ Частая ошибка: не вызвать defer cancel(). Даже если контекст отменится по таймауту, без cancel() внутренний таймер и связанные ресурсы держатся до срабатывания дедлайна — go vet предупреждает о потерянном cancel.
42В чём разница между context.Background() и context.TODO()?
junior
Короткий ответ: Функционально они идентичны — оба возвращают пустой (но не nil) контекст без отмены, дедлайна и значений. Разница чисто семантическая, для читателя кода: Background() — осознанный корень дерева контекстов, а TODO() — маркер «здесь нужен контекст, но какой именно — ещё не решено».
Подробно:
- Background — стартовая точка:
main, инициализация, верхний уровень входящего запроса. Ты сознательно говоришь «это корень». - TODO — заглушка при рефакторинге: настоящий
ctxдо этого места ещё не пробросили, а передавать nil нельзя. Линтеры и ревьюеры видят, что тут долг. - Технически — под капотом оба
emptyCtx; выбор влияет не на поведение, а на намерение, читаемое из кода.
| context.Background() | context.TODO() | |
|---|---|---|
| Поведение | пустой контекст | пустой контекст |
| Смысл | осознанный корень | «ещё не решил» |
| Где | main, init, корень запроса | заглушка при рефакторинге |
⚠️ Частая ошибка: сыпать TODO() по коду просто чтобы «скомпилировалось». Он для временной заглушки; в готовом пути должен течь реальный ctx из вызывающего кода.
43Почему context.WithValue — антипаттерн для передачи бизнес-параметров?
middle
Короткий ответ: WithValue хранит значения как any по ключу-any: зависимость становится невидимой — она не отражена в сигнатуре функции, компилятор не проверит ни наличие, ни тип, а забытое или неверно приведённое значение выстрелит паникой или nil только в рантайме. Бизнес-параметры (userID, лимит, фильтр) надо передавать явными аргументами. Легально в контексте живут только request-scoped метаданные.
Подробно:
- Нет типобезопасности — на извлечении делаешь
ctx.Value(k).(T); ошибся типом или ключом — паника илиnilв проде, а не ошибка компиляции. - Скрытая зависимость — функция принимает
ctx, но что она из него достаёт, по сигнатуре не видно; рефакторить и тестировать тяжело. - Что класть можно — сквозные метаданные запроса: trace/request ID, информацию об аутентификации, локаль — то, что пронизывает все слои и не является бизнес-входом.
- Ключи — только собственного неэкспортируемого типа, чтобы исключить коллизии между пакетами.
// Ключ собственного типа — защита от коллизий
type ctxKey string
const traceIDKey ctxKey = "traceID"
func WithTraceID(ctx context.Context, id string) context.Context {
return context.WithValue(ctx, traceIDKey, id)
}
func TraceID(ctx context.Context) (string, bool) {
id, ok := ctx.Value(traceIDKey).(string)
return id, ok
}
⚠️ Частая ошибка: класть ключом строку или встроенный тип (ctx.Value("user")). Два пакета с одинаковым ключом молча перезатрут значения друг друга — ключ обязан быть неэкспортируемого типа.
44Почему context передают первым аргументом, а не хранят в поле структуры?
middle
Короткий ответ: Контекст живёт в рамках одного вызова или запроса и должен течь явно по цепочке вызовов — поэтому его передают первым параметром ctx context.Context. В поле структуры его время жизни размывается: объект переживёт запрос, и один сохранённый ctx начнёт случайно обслуживать другие, посторонние запросы, ломая отмену и дедлайны.
Подробно:
- Явный поток —
ctxпервым аргументом делает область его действия видимой: понятно, что этот вызов подчинён этому контексту и его отмене. - Проблема поля — долгоживущий объект (сервис, клиент) с полем
ctxпривяжется к контексту первого запроса; следующие запросы будут наследовать чужой дедлайн или уже отменённый контекст. - Конвенция — прямо зафиксирована в документации пакета
context: «Do not store Contexts inside a struct type; instead, pass a Context explicitly». - Исключения — осознанные:
http.Requestносит контекст внутри, потому что сам объект строго request-scoped и живёт ровно один запрос.
// Идиоматично: ctx — первый параметр, течёт по цепочке
func (s *Service) FetchUser(ctx context.Context, id string) (*User, error) {
return s.repo.Get(ctx, id)
}
// Антипаттерн: ctx спрятан в поле долгоживущего сервиса
type Service struct {
ctx context.Context // переживёт запрос, обслужит чужие
}
⚠️ Частая ошибка: сохранить ctx в поле, чтобы «не таскать его всюду». Долгоживущий объект залипнет на контексте первого запроса — отмена и таймаут поедут для всех последующих.
45Мьютекс, канал, atomic или иммутабельность — как выбрать способ разделить состояние между горутинами?
concept
Короткий ответ: Выбор по природе задачи, а не по вкусу. atomic — для одиночных слов (счётчики, флаги, указатели). Mutex — когда надо согласованно защитить составной инвариант из нескольких полей. Каналы — для передачи владения данными и оркестрации горутин («share memory by communicating»). Иммутабельность снимает проблему совсем: неизменяемые данные можно читать из любого числа горутин без синхронизации.
Подробно:
- atomic — одна ячейка, простая операция; максимально дёшево, но только слово целиком.
- Mutex — несколько полей должны меняться и читаться как единое целое; классическая защита критической секции.
- Каналы — данные перемещаются между стадиями/воркерами, нужна координация, backpressure, сигнал завершения; владение передаётся, а не разделяется.
- Иммутабельность — построил значение один раз и только читаешь (или заменяешь указатель через
atomic.Pointer); гонок нет по определению.
| Инструмент | Идеальный случай | Не для этого |
|---|---|---|
| atomic | счётчик, флаг, указатель | составной инвариант |
| Mutex | несколько полей вместе | передача владения |
| Канал | пайплайн, воркеры, сигналы | простой общий счётчик |
| Иммутабельность | read-only конфиг/снапшот | частые точечные записи |
⚠️ Частая ошибка: отвечать «каналы всегда лучше». Интервьюер слушает про уместность: общий счётчик под каналом — это переусложнение, а составной инвариант через atomic — гонка. Инструмент выбирают под форму данных.
46Расскажите про модель G-M-P: как планировщик Go распределяет горутины?
senior
Короткий ответ: Планировщик Go — это M:N-модель поверх трёх сущностей. G — горутина (задача со стеком), M — поток ОС (machine), P — логический процессор (processor) с локальной очередью готовых горутин. M исполняет G, только пока держит P; число P ограничено GOMAXPROCS, поэтому параллельно Go-код крутят ровно столько потоков, сколько P.
Подробно:
- P — это право на исполнение. У каждого P своя runqueue (локальная очередь, до 256 G). M забирает G из очереди своего P и запускает.
- Work stealing. Когда локальная очередь P опустела, он крадёт половину горутин из очереди чужого P или тянет из глобальной очереди — так нагрузка балансируется без единого глобального лока.
- Глобальная очередь. Переполнение локальной очереди и «разбуженные» горутины уходят в общий
globrunq; чтобы он не голодал, P периодически (каждый 61-й тик) заглядывает туда. - Развязка M и P. Когда M блокируется, P отцепляется и достаётся другому M — горутины не простаивают.
G G G G G G G G G
┌─────┐ ┌─────┐ ┌─────┐ global runq
│ P0 │ │ P1 │ │ P2 │ [G G G ...]
└──┬──┘ └──┬──┘ └──┬──┘ ▲
│ steal◄───────┘ │ │ (каждый 61-й тик)
┌──┴──┐ ┌─────┐ ┌──┴──┐
│ M0 │ │ M1 │ │ M2 │ ← потоки ОС
└─────┘ └─────┘ └─────┘
⚠️ Частая ошибка: говорить «горутина = поток». Горутин могут быть сотни тысяч, а потоков (M) — единицы; именно P мультиплексирует множество G на небольшое число M.
47Что задаёт GOMAXPROCS и чему он равен по умолчанию?
junior
Короткий ответ: GOMAXPROCS задаёт число P — максимум потоков ОС, одновременно исполняющих Go-код. По умолчанию он равен числу логических CPU (runtime.NumCPU()) — так с Go 1.5.
Подробно:
- Что именно ограничивает. Только параллельное исполнение Go-кода. Горутины, заблокированные на syscall или сетевом I/O, в лимит не входят — потоков в процессе может быть куда больше, чем
GOMAXPROCS. - Как менять. Через переменную окружения
GOMAXPROCSили в рантаймеruntime.GOMAXPROCS(n). - Ловушка контейнеров. Исторически дефолт брал число ядер всей ноды, игнорируя CPU-лимит cgroup: на 64-ядерной ноде с лимитом «2 CPU» рантайм поднимал 64 P — лишние переключения контекста и троттлинг. Классическое лечение —
uber-go/automaxprocs. - Свежий Go. С Go 1.25 рантайм по умолчанию учитывает CPU-лимит cgroup — на Linux дефолт округляется вверх от лимита, поэтому проверяйте версию, прежде чем тащить костыль.
// GOMAXPROCS=4 ./app — через переменную окружения
runtime.GOMAXPROCS(4) // или в рантайме
n := runtime.GOMAXPROCS(0) // 0 — прочитать текущее значение, не меняя
⚠️ Частая ошибка: на Go < 1.25 положиться на дефолт в Kubernetes с CPU-лимитом. Без automaxprocs рантайм видит все ядра ноды, а не выделенную квоту.
48Планировщик Go кооперативный или вытесняющий? Что изменилось в Go 1.14?
senior
Короткий ответ: До Go 1.14 планировщик был кооперативным — горутина отдавала P только в safepoint'ах (в основном на вызовах функций). С Go 1.14 добавлено асинхронное вытеснение сигналами: sysmon шлёт потоку SIGURG, и горутину снимают с P принудительно, даже если она сама не уступает.
Подробно:
- Кооперативная модель (до 1.14). Переключение возможно только там, где компилятор вставил проверку — на вызовах функций, аллокации, операциях с каналами. Тесный цикл без единого вызова не давал планировщику точки для вытеснения.
- Симптом. Горутина с
for {}могла занять P навсегда: другие горутины на этом P голодают, а сборка мусора не может дождаться STW, потому что не все горутины доходят до safepoint. - Асинхронное вытеснение (1.14+). Поток-монитор sysmon замечает, что G крутится дольше ~10 мс, и посылает потоку сигнал
SIGURG; обработчик безопасно снимает горутину и возвращает P в пул.
| До Go 1.14 | Go 1.14+ | |
|---|---|---|
| Модель | кооперативная | + асинхронное вытеснение |
| Точка снятия | только safepoint (вызовы) | в любой точке по сигналу |
| Порог | — | ~10 мс на G |
for {} |
вешал P/GC | вытесняется штатно |
⚠️ Частая ошибка: считать, что вытеснение стало абсолютным. Сигнал может прийти в любой момент, но на unsafe-point'ах (короткие участки без метаданных для сканирования стека, внутренности рантайма) снятие откладывается; для прикладного кода планировщик можно считать вытесняющим.
49Что происходит с P и M, когда горутина уходит в блокирующий системный вызов?
senior
Короткий ответ: При блокирующем syscall M блокируется вместе со своей G, но P от него отцепляется (hand-off) и достаётся другому — свободному или заново созданному — M, чтобы остальные горутины этого P продолжали работать. Сетевой I/O — отдельная история: он идёт через netpoller и не блокирует M вовсе.
Подробно:
- Hand-off P. Перед syscall рантайм помечает P как «в syscall». Если вызов затягивается, sysmon отцепляет P и отдаёт другому M; горутины из локальной очереди не простаивают.
- Возврат из syscall. Когда M выходит из вызова, он пытается снова захватить P (свой или любой свободный). Не удалось — G кладётся в глобальную очередь, а M паркуется в пул.
- Netpoller. Сетевые операции (сокеты) регистрируются в epoll (Linux) / kqueue (BSD/macOS). Горутина паркуется, M освобождается сразу; когда fd готов, netpoller возвращает G в очередь готовых. Один M обслуживает тысячи соединений.
Блокирующий syscall (файл/диск): Сетевой I/O:
┌─────┐ syscall ┌─────┐ ┌─────┐ ┌──────────┐
│ G │────────►│ M │(блок) │ G │──►│ netpoller│ epoll
└─────┘ └─────┘ └─────┘ │(kqueue) │
P ─hand-off─► M2 (работает дальше) M свободен └────┬─────┘
fd ready ──────┘► G в runq
⚠️ Частая ошибка: думать, что тысяча блокирующих файловых операций поднимет ровно GOMAXPROCS потоков. Каждый блокирующий syscall держит свой M, так что потоков ОС может стать заметно больше числа P.
50Как работает сборщик мусора в Go — что такое tricolor mark-and-sweep?
senior
Короткий ответ: GC в Go — конкурентный, non-moving mark-and-sweep с трёхцветной абстракцией. Объекты делятся на белые (кандидаты в мусор), серые (достижимы, но их ссылки ещё не обошли) и чёрные (обойдены полностью). Метка идёт параллельно с работой приложения; корректность при этом держит write barrier, а полностью останавливать мир (STW) приходится лишь на две короткие фазы.
Подробно:
- Три множества. Старт: всё белое. Корни (стеки, глобалы) красятся серым. Из серого множества берём объект, красим его ссылки серым, а сам объект — чёрным. Повторяем, пока серых не останется.
- Инвариант. Всё, что осталось белым, недостижимо → выметается (sweep). Sweep ленивый: память возвращается по мере аллокаций.
- Write barrier. Пока мутатор бегает параллельно, он может записать ссылку на белый объект в чёрный. Гибридный write barrier (с Go 1.8) перекрашивает такой объект, чтобы живой объект не смели по ошибке.
- STW. Только на границах: включение барьера (mark start) и завершение метки (mark termination). Обе паузы — суб-миллисекундные.
● white — кандидат в мусор (пока не достигнут)
◐ grey — достигнут, ссылки ещё не обойдены
◉ black — достигнут, ссылки обойдены
roots ──► ◉ ──► ◐ ──► ● волна метки движется
(обойден) (в работе) (ждёт) слева направо
⚠️ Частая ошибка: называть Go-шный GC «stop-the-world» или generational. Он конкурентный (мир стоит лишь на суб-мс границах) и не поколенческий/не перемещающий — объекты остаются на месте, поэтому указатели в C-коде через cgo стабильны.
51Что настраивают GOGC и GOMEMLIMIT и какой trade-off за этим стоит?
middle
Короткий ответ: GOGC задаёт, насколько куча может вырасти между сборками: при дефолте GOGC=100 цикл GC запускается, когда живая куча удвоилась. GOMEMLIMIT (Go 1.19+) — мягкий потолок общего объёма памяти рантайма, который включает GC чаще по мере приближения к лимиту и спасает от OOM в контейнерах.
Подробно:
- GOGC — про частоту.
GOGC=100→ следующая сборка при +100% к живой куче. Ниже значение = меньше пиковая память, но GC ходит чаще и суммарно съедает больше CPU; выше = реже GC, но жирнее RSS.GOGC=offвыключает GC совсем. - GOMEMLIMIT — про потолок. Мягкий лимит на суммарную память (куча + стек + метаданные рантайма). По мере приближения рантайм ужимает целевой размер кучи и чаще собирает, лишь бы не вылезти за лимит.
- Как комбинировать. Типичный прод-рецепт: оставить
GOGC=100для нормального режима и задатьGOMEMLIMITчуть ниже лимита пода — тогда под давлением GC станет агрессивнее вместо OOM-килла.
| Параметр | Что задаёт | Ниже/жёстче → | Риск перегиба |
|---|---|---|---|
| GOGC=100 | рост кучи между GC | меньше RAM, больше CPU | GC-thrashing |
| GOMEMLIMIT | мягкий потолок памяти | защита от OOM | death spiral: GC жрёт CPU у самого лимита |
⚠️ Частая ошибка: ставить GOMEMLIMIT ровно на хард-лимит контейнера и полностью выключать GOGC. У самого потолка рантайм срывается в «GC death spiral» — сборка почти не освобождает память и съедает CPU. Держите зазор.
52Что такое escape analysis и как узнать, ушла ли переменная в кучу?
middle
Короткий ответ: Escape analysis — это анализ компилятора, который решает, где разместить переменную: на стеке (дёшево, освобождается автоматически при выходе из функции) или в куче (нагрузка на GC). Если компилятор не может доказать, что переменная не переживёт кадр функции, она «убегает» (escapes) в кучу. Проверяют флагом go build -gcflags='-m'.
Подробно:
Типичные причины escape:
- Возврат указателя наружу — вернули
&local, значит объект должен пережить функцию. - Захват замыканием — переменную использует горутина/замыкание, живущее дольше кадра.
- Упаковка в интерфейс — присваивание конкретного значения в
interface{}часто вынуждает аллокацию (например, аргументыfmt.Println). - Неизвестный размер на этапе компиляции — слайс/массив с размером, известным лишь в рантайме.
func newUser() *User {
u := User{Name: "Go"} // -m: moved to heap: u
return &u // указатель убегает наружу
}
// $ go build -gcflags='-m' ./...
// ./main.go:2:2: moved to heap: u
⚠️ Частая ошибка: думать, что & (взятие адреса) само по себе означает кучу. Локальный указатель, не покидающий функцию, спокойно живёт на стеке — решает именно escape analysis, а не наличие &.
53Как растёт стек горутины и что будет при превышении лимита?
middle
Короткий ответ: Горутина стартует с крохотного стека ~2 КБ. Когда его не хватает, рантайм выделяет стек вдвое больше, целиком копирует туда данные и правит указатели (contiguous stacks). Есть жёсткий потолок ~1 ГБ на 64-битных платформах; при его превышении — fatal error: stack overflow, который recover не перехватывает.
Подробно:
- Дешёвый старт. ~2 КБ на горутину — поэтому их можно держать сотнями тысяч, в отличие от потоков ОС с мегабайтными стеками.
- Рост копированием. На входе в функцию пролог проверяет, влезет ли кадр. Не влезает →
morestack: аллоцируется новый, больший стек, старый копируется, указатели внутри стека переписываются. Стек и растёт, и (при сборке) может ужиматься. - Жёсткий лимит.
maxstacksize— ~1 ГБ на 64-битных (около 250 МБ на 32-битных). Упёрлись — рантайм валит процесс. - Не паника, а фатал.
stack overflow— это fatal error рантайма, а неpanic;defer/recoverего не ловят, программа падает целиком.
[2KB] ──переполнен──► выделить [4KB] ──► copy old→new ──► fix pointers
▲ │
└──────────── contiguous stack, растёт ×2 ─────────────────┘
глубокая/бесконечная рекурсия ──► ~1GB cap ──► fatal: stack overflow
⚠️ Частая ошибка: рассчитывать «поймать» бесконечную рекурсию через recover. Переполнение стека — фатальная ошибка рантайма, а не паника; её recover не спасает.
54В каком порядке выполняются несколько defer и когда вычисляются их аргументы?
middle
Короткий ответ: Отложенные вызовы выполняются в порядке LIFO — последний объявленный defer срабатывает первым, при выходе из функции. А вот аргументы defer вычисляются сразу, в момент выполнения оператора defer, а не в момент фактического вызова.
Подробно:
- Стек, а не очередь — каждый
deferкладётся на стек функции; при возврате они снимаются сверху вниз (LIFO). Это удобно для парных операций:Lock/Unlock,Open/Close— освобождаешь ресурсы в обратном порядке захвата. - Аргументы фиксируются сразу — выражения-аргументы вычисляются в точке
defer, а тело вызова откладывается. Поэтомуdefer fmt.Println(i)захватывает текущееi, а не финальное. - Замыкание откладывает чтение —
defer func(){ use(x) }()читаетxв момент вызова, а не объявления. Нюанс: с Go 1.22 переменная цикла своя на каждой итерации, поэтому и замыкание в цикле напечатает2 1 0.
func main() {
for i := 0; i < 3; i++ {
defer fmt.Println(i) // аргумент i вычислен сейчас
}
}
// Вывод: 2 1 0 (LIFO + i зафиксирован на каждой итерации)
⚠️ Частая ошибка: отвечать 3 3 3, будто defer читает общий i при выходе из функции. Аргумент — снимок в момент объявления defer; к тому же с Go 1.22 у каждой итерации своя переменная i.
55Может ли defer изменить возвращаемое значение функции?
senior
Короткий ответ: Да, но только при именованных возвращаемых значениях. return x сначала присваивает значение именованной переменной, затем выполняются defer'ы, и лишь потом функция реально отдаёт результат — поэтому defer успевает его переприсвоить.
Подробно:
- Механика return —
returnне атомарен: он (1) записывает значения в named-переменные результата, (2) запускает отложенные функции, (3) отдаёт итог. Отложенная функция видит и меняет эти переменные. - Только именованные — при анонимном результате (
func() error) менять нечего: defer работает с копией и на возврат не влияет. - Прикладной паттерн — превратить панику в
errorна границе API:recover()внутри defer + присваивание named return.
func parse(data []byte) (err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("parse panic: %v", r)
}
}()
doRiskyParse(data) // паникует внутри — наружу уйдёт err
return nil
}
⚠️ Частая ошибка: ждать того же трюка от func() error. Без имени у результата defer меняет локальную копию, а вызывающий получает исходное значение из return.
56Где именно должен быть вызван recover(), чтобы он перехватил панику?
middle
Короткий ответ: recover() работает только при непосредственном вызове внутри отложенной функции той же горутины, где произошла паника. Вызванный вне defer или из функции, которую отложенная функция сама вызывает, он возвращает nil и ничего не перехватывает.
Подробно:
- Только напрямую в defer — паника ловится, если
recover()вызван прямо в теле функции, которуюdeferпоставил на выполнение. Не в функции, которую эта отложенная функция вызвала. - Только своя горутина — паника в другой горутине твоим
recoverне перехватывается; каждая горутина ловит панику сама, иначе падает весь процесс. - Вне паники —
nil— если паники нет,recover()просто вернётnil, так что проверкаif r := recover(); r != nilбезопасна.
func catch() { _ = recover() }
defer func() { _ = recover() }() // работает
defer catch() // тоже работает: catch — сама отложенная функция
defer func() { catch() }() // НЕ работает: recover на кадр глубже, вернёт nil
⚠️ Частая ошибка: вызвать хелпер с recover() изнутри deferred-замыкания — defer func(){ catch() }(). recover оказывается не в самой отложенной функции, а на кадр глубже и вернёт nil. А вот defer catch() работает: тут catch и есть отложенная функция.
57В чём разница между errors.Is и errors.As?
middle
Короткий ответ: errors.Is идёт по цепочке Unwrap и сравнивает ошибку с конкретным значением-сентинелом (например, sql.ErrNoRows). errors.As ищет в цепочке ошибку нужного типа и извлекает её в переданный target, чтобы достучаться до полей.
Подробно:
errors.Is(err, target)— отвечает на вопрос «эта ошибка (или любая в её цепочке) равна вот этому значению?». Для проверки на известные сентинелы.errors.As(err, &target)— отвечает «есть ли в цепочке ошибка типа T?»; если да — присваивает её вtarget, и ты читаешь поля (.Code,.Field).target— обязательно указатель.- Обе разворачивают цепочку
%w, так что оборачивание контекстом не ломает проверки.
errors.Is |
errors.As |
|
|---|---|---|
| Ищет | конкретное значение | конкретный тип |
| Аргумент | сентинел | указатель на переменную типа |
| Даёт доступ к полям | нет | да |
| Типичный кейс | Is(err, io.EOF) |
As(err, &pathErr) |
if errors.Is(err, sql.ErrNoRows) { /* нет строки */ }
var perr *os.PathError
if errors.As(err, &perr) { log.Print(perr.Path) }
⚠️ Частая ошибка: сравнивать ошибки через == или err.Error() == "...". После оборачивания %w прямое сравнение сломается — нужен errors.Is/As.
58Как обернуть ошибку, добавив контекст, но сохранив цепочку для errors.Is/As?
junior
Короткий ответ: Через fmt.Errorf с глаголом %w: fmt.Errorf("read config: %w", err). Именно %w вшивает исходную ошибку в цепочку Unwrap, так что errors.Is/errors.As продолжают её видеть. Глаголы %v и %s вставляют только текст и обрывают цепочку.
Подробно:
%wсохраняет цепочку — оборачивает ошибку, оставляя доступ к оригиналу черезUnwrap. Добавляй короткий контекст: что делал код, а не пересказ ошибки.%v/%sрвут цепочку — годятся только когда исходную ошибку намеренно скрываешь от вызывающего (граница абстракции).- Контекст по слоям — на каждом уровне добавляй свой префикс; получается читаемый след
open user file: read config: permission denied.
if err != nil {
return fmt.Errorf("read config %q: %w", path, err)
}
// errors.Is(err, os.ErrPermission) по-прежнему работает
⚠️ Частая ошибка: обрабатывать ошибку дважды — и залогировать, и вернуть её наверх. Каждый слой либо оборачивает и возвращает, либо (на самом верху) логирует — не одновременно, иначе логи задваиваются.
59Когда функция должна вернуть error, а когда — паниковать?
junior
Короткий ответ: Ожидаемые, восстановимые сбои — всегда error как обычное возвращаемое значение: файл не найден, сеть отвалилась, ввод невалиден. panic — для программистских ошибок и нарушенных инвариантов, из которых нет осмысленного продолжения: выход за границы, разыменование nil, «невозможное» состояние.
Подробно:
- error — штатный путь — всё, что вызывающий может предвидеть и обработать, возвращается явным
error. Это часть контракта функции. - panic — баг, не сценарий — сигнализирует, что программа в неконсистентном состоянии и продолжать нельзя. Рантайм паникует на записи в
nilmap, делении на ноль, индексе за границей. - recover — только на границе — например, чтобы HTTP-сервер не падал целиком из-за паники в одном хендлере.
Вернуть error |
panic |
|
|---|---|---|
| Причина | ожидаемый сбой | баг / нарушенный инвариант |
| Примеры | нет файла, таймаут, плохой ввод | index out of range, nil deref |
| Кто виноват | внешний мир | программист |
| Ожидание | вызывающий обработает | продолжать нельзя |
⚠️ Частая ошибка: использовать panic как control flow для «не найдено» или валидации ввода. Это код-смелл, который интервьюер специально провоцирует: такие случаи — обычный error.
60Sentinel-ошибки против кастомных типов ошибок — что когда выбрать?
middle
Короткий ответ: Sentinel (var ErrNotFound = errors.New(...)) — это простой опознаваемый сигнал без дополнительных данных, проверяется через errors.Is. Кастомный тип с полями нужен, когда вызывающему важны детали сбоя (код, имя поля, статус) — тогда errors.As извлекает ошибку и даёт доступ к её полям.
Подробно:
- Sentinel — флаг — одно значение-константа на весь пакет. Дёшево, читаемо, но несёт только сам факт: «не найдено», «нет прав». Проверка —
errors.Is. - Кастомный тип — структура с данными — когда нужно передать контекст:
ValidationError{Field, Rule},APIError{Code}. Проверка и извлечение —errors.As. - Оба варианта — часть API — и то и другое становится публичным контрактом: вызывающие проверяют их по имени.
| Sentinel | Кастомный тип | |
|---|---|---|
| Несёт данные | нет | да (поля) |
| Проверка | errors.Is |
errors.As |
| Стоимость | минимальна | больше кода |
| Когда | простой сигнал | нужны детали |
var ErrNotFound = errors.New("not found")
type ValidationError struct{ Field, Rule string }
func (e *ValidationError) Error() string { return e.Field + ": " + e.Rule }
⚠️ Частая ошибка: забывать, что sentinel — часть публичного API. Пользователи пишут errors.Is(err, pkg.ErrNotFound), поэтому переименование или удаление ломает их код так же, как смена сигнатуры функции.
61Реализуйте worker pool с graceful shutdown через context. Что проверяет интервьюер?
senior
Короткий ответ: N воркеров читают из общего канала jobs через range; продюсер закрывает jobs, а results закрывает отдельная горутина после wg.Wait(). Внутри воркера — select с ctx.Done(), чтобы бросить работу по отмене. Интервьюер смотрит, кто закрывает каналы и не текут ли горутины.
Подробно:
- Кто закрывает
jobs— только продюсер (отправитель). Закрыть канал со стороны воркера-потребителя — паника наsend/двойномclose. - Кто закрывает
results— отдельная горутина:go func(){ wg.Wait(); close(results) }(). Иначе не понять, когда все воркеры отработали. - Отмена — в каждом воркере
selectмеждуjobsиctx.Done(); по отмене воркер выходит,wg.Done()вdefer. - Утечки — без
ctxзависший продюсер или полныйresultsнавсегда заблокирует воркеров.
func pool(ctx context.Context, jobs <-chan Job, n int) <-chan Result {
results := make(chan Result)
var wg sync.WaitGroup
for i := 0; i < n; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for {
select {
case <-ctx.Done():
return
case job, ok := <-jobs:
if !ok {
return // канал закрыт продюсером
}
select {
case results <- process(job):
case <-ctx.Done():
return
}
}
}
}()
}
go func() { wg.Wait(); close(results) }()
return results
}
⚠️ Частая ошибка: закрыть results внутри воркера или до wg.Wait() — «send on closed channel». Закрывает всегда одна горутина и только после того, как все отправители завершились.
62Как слить несколько каналов в один (fan-in) и не словить панику при закрытии?
middle
Короткий ответ: На каждый входной канал — своя горутина, которая пересылает значения в общий out; WaitGroup считает эти горутины, а close(out) вызывается ровно один раз в отдельной горутине после wg.Wait(). Так выходной канал закрывается, только когда иссякли все входы.
Подробно:
- Горутина на вход —
for v := range in { out <- v }, затемwg.Done(). - WaitGroup —
Add(len(cs))до старта горутин, чтобыWait()не проскочил раньше времени. - Единственный
close—go func(){ wg.Wait(); close(out) }(). Закрывает только тот, кто владеетout. - Отмена (опц.) — для раннего выхода добавь
selectсdoneв отправке вout, иначе горутины повиснут наsend.
func merge[T any](cs ...<-chan T) <-chan T {
out := make(chan T)
var wg sync.WaitGroup
wg.Add(len(cs))
for _, c := range cs {
go func(c <-chan T) {
defer wg.Done()
for v := range c {
out <- v
}
}(c)
}
go func() { wg.Wait(); close(out) }()
return out
}
⚠️ Частая ошибка: закрыть out из каждой входной горутины или раньше, чем иссякли все входы, — получите панику «close of closed channel» или «send on closed channel». Закрытие — строго один раз, после Wait().
63Как ограничить параллелизм — например, максимум 10 одновременных запросов к внешнему API?
middle
Короткий ответ: Буферизированный канал-семафор ёмкостью 10: перед запросом пишем токен (acquire), в defer читаем обратно (release). Когда буфер полон, отправка блокируется и новые горутины не стартуют, пока не освободится слот — это даёт естественный backpressure.
Подробно:
- Семафор —
sem := make(chan struct{}, 10);acquire=sem <- struct{}{},release=<-sem. releaseвdefer— обязательно: паника или раннийreturnвнутри воркера «съест» слот, и пул медленно застынет.- Готовые решения —
errgroup.Group.SetLimit(10)илиgolang.org/x/sync/semaphoreдля взвешенных лимитов. - Не «горутина на запрос» — интервьюер хочет ограниченную одновременность и backpressure, а не 10000 горутин, кладущих чужой API.
sem := make(chan struct{}, 10)
var wg sync.WaitGroup
for _, url := range urls {
sem <- struct{}{} // acquire: ждём свободный слот до запуска горутины
wg.Add(1)
go func(url string) {
defer wg.Done()
defer func() { <-sem }() // release
fetch(url)
}(url)
}
wg.Wait()
⚠️ Частая ошибка: release без defer. Если fetch паникует или делает ранний return, токен не вернётся — свободных слотов станет меньше, и со временем пул встанет колом.
64Как построить pipeline из нескольких стадий на каналах и корректно его останавливать?
middle
Короткий ответ: Каждая стадия — функция вида func(ctx, in <-chan T) <-chan U, которая заводит свою горутину, пишет в собственный out и делает defer close(out). Отмену прокидываем через ctx во все send/receive (select с ctx.Done()), иначе стадии зависнут, если потребитель ушёл раньше.
Подробно:
- Стадия владеет своим выходом — создаёт
out, пишет в него и закрывает; следующий этап только читает. - Закрытие вниз по потоку — когда вход иссяк (
rangeзавершился), стадия закрывает свойout, иcloseкаскадом идёт дальше. - Отмена вверх по потоку — на каждом
sendделаемselect { case out <- v:; case <-ctx.Done(): return }, чтобы ранний выход потребителя разблокировал верхние стадии. - Источник — паттерн из Go blog «Pipelines and cancellation».
generate ──► square ──► sum
nums out result
│ │ │
└──── ctx.Done() отменяет все стадии ────┘
⚠️ Частая ошибка: писать в out без select с ctx.Done(). Если потребитель перестал читать, верхние стадии навсегда блокируются на send — классическая утечка горутин в pipeline.
65Как реализовать rate limiter в Go и чем он отличается от семафора?
middle
Короткий ответ: Семафор ограничивает одновременность (сколько операций идёт прямо сейчас), а rate limiter — частоту во времени (сколько операций в секунду). Для равномерного темпа берут time.Ticker, для всплесков — token bucket из golang.org/x/time/rate.
Подробно:
- Семафор ≠ rate limiter — «10 одновременных запросов» и «10 запросов в секунду» — это разные ограничения.
- Ticker —
t := time.NewTicker(time.Second/10); перед каждым запросом<-t.C. Ровно 10 rps, без burst. - Token bucket —
rate.NewLimiter(10, 20): 10 токенов/сек, burst до 20;limiter.Wait(ctx)блокирует до токена. Стандарт для внешних API. - Backpressure —
Waitждёт токена,Allowсразу говорит «нет» — выбираешь между ожиданием и отбросом (429).
| Критерий | Семафор | Rate limiter |
|---|---|---|
| Ограничивает | одновременность | частоту во времени |
| Единица | N параллельно | N в секунду |
| Инструмент | chan struct{} |
time.Ticker / x/time/rate |
| Burst | нет | да (token bucket) |
⚠️ Частая ошибка: наивный time.Sleep между запросами вместо лимитера. Он не даёт burst, накапливает дрейф и ломается при параллельных воркерах — каждый спит сам по себе.
66Как устроен LRU-кэш и как сделать его потокобезопасным?
middle
Короткий ответ: map[key]*list.Element поверх двусвязного списка (container/list): map даёт доступ за O(1), список хранит порядок использования. На Get двигаем элемент в голову, на Put при переполнении вытесняем хвост. Потокобезопасность — sync.Mutex вокруг обеих структур.
Подробно:
- Две структуры —
map[K]*list.Elementдля поиска за O(1) и*list.List(двусвязный) для порядка «свежести». - Get — нашли в map →
MoveToFront(el)→ вернули значение. - Put — ключ есть → обновить и
MoveToFront; нет →PushFront, а приLen() > capacityудалитьBack()и его ключ из map. - Потокобезопасность — один
Lock()на всю операцию (map и список меняются вместе); при высоком contention — шардирование по хешу ключа.
map[key] двусвязный список (MRU ⇄ LRU)
┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐
│ "a" ─┼──►│ a │⇄ │ c │⇄ │ b │
│ "c" ─┼──►└──────┘ └──────┘ └──────┘
│ "b" ─┼──► head=MRU tail=вытесняем
└──────┘
⚠️ Частая ошибка: RWMutex с RLock на Get. Get двигает элемент в списке — это запись, а не чтение; под RLock две горутины повредят список. Get требует полного Lock.
67Как сделать таймаут операции через select и в чём подвох time.After в цикле?
senior
Короткий ответ: select между рабочим каналом и time.After(d): что сработает первым, то и выиграет. Подвох в горячем цикле: раньше таймер от time.After не освобождался до срабатывания, и на каждой итерации копился новый — утечка. С Go 1.23 такие таймеры собирает GC, но на старых версиях берут time.NewTimer + Stop.
Подробно:
- Базовый таймаут —
select { case v := <-ch: ...; case <-time.After(d): ... }; для отмены по цепочке вызовов лучшеcontext.WithTimeout. - Подвох в цикле — до Go 1.23
time.Afterвforсоздавал таймер, живший всеd, даже если веткаchсрабатывала мгновенно: тысячи итераций → тысячи живых таймеров. - Go 1.23 — таймеры и тикеры собираются GC, даже если
Stopне вызван; каналы таймеров стали небуферизованными.time.Afterв цикле больше не течёт. - Старые версии — свой
time.NewTimer(d)иStop()сразу, как только пришло значение изch.
// До Go 1.23 течёт: новый таймер живёт ~секунду каждую итерацию
for {
select {
case v := <-ch:
handle(v)
case <-time.After(time.Second):
return
}
}
// Безопасно везде: свой таймер, Stop сразу после ch
for {
t := time.NewTimer(time.Second)
select {
case v := <-ch:
t.Stop() // освобождаем таймер немедленно
handle(v)
case <-t.C:
return
}
}
⚠️ Частая ошибка: заявить «time.After всегда течёт». На Go 1.23+ это уже не так — уточни версию рантайма. Но привычка к NewTimer + Stop безопаснее и на старых версиях.
68Какие баги чаще всего прячут в задачах «найди ошибку в конкурентном коде»?
concept
Короткий ответ: Закладки одни и те же: гонка на общей переменной без mutex/atomic, захват переменной цикла (до Go 1.22), wg.Add() внутри горутины, range по незакрытому каналу с deadlock и горутина без recover, роняющая весь процесс. Держи этот список в голове и сканируй код по нему.
Подробно:
- Гонка данных —
counter++из нескольких горутин безsync.Mutex/atomic.go test -raceловит мгновенно. - Переменная цикла (до Go 1.22) —
go func(){ use(i) }()без параметра: все горутины видят финальноеi. С Go 1.22 каждая итерация — свежая переменная. wg.Addвнутри горутины —Wait()может проскочить доAdd.Add(1)всегда доgo.- Незакрытый канал —
for v := range chбезclose(ch)где-то → deadlock, «all goroutines are asleep». - Нет
recover— паника в отдельной горутине не ловится вызывающим и валит весь процесс.
counter++ // 1) гонка: нужен mutex/atomic
for _, v := range xs { go func(){ use(v) }() } // 2) var цикла (до 1.22)
go func(){ wg.Add(1); defer wg.Done() }() // 3) Add внутри горутины
for v := range ch { _ = v } // 4) кто делает close(ch)?
go func(){ mightPanic() }() // 5) нет recover → упадёт процесс
⚠️ Частая ошибка: искать баг глазами вместо go test -race и go vet. Детектор гонок и vet находят половину закладок за секунды — упомяни их первым делом.
69Что такое табличные тесты и почему это идиоматичный стиль в Go?
junior
Короткий ответ: Табличный тест — это слайс структур-кейсов (вход + ожидаемый результат), по которому мы бежим циклом и запускаем каждый кейс через t.Run(name, ...). Так получаются именованные сабтесты, изоляция падений и нулевое дублирование. Это стиль самой стандартной библиотеки, поэтому его ждут на собеседовании.
Подробно:
- Слайс кейсов — каждый кейс это структура с именем, входом и ожиданием; добавить новый сценарий — это одна строка, а не новая функция.
t.Run(tc.name, ...)— оборачивает кейс в сабтест: падение одного не останавливает остальные, а имя попадает в вывод (TestParse/empty_input), так что сразу видно, что сломалось.- Один код проверки — логика ассерта написана один раз, кейсы только меняют данные.
- Бонус:
t.Parallel()внутри сабтеста гонит независимые кейсы параллельно.
func TestAbs(t *testing.T) {
tests := []struct {
name string
in int
want int
}{
{"positive", 3, 3},
{"negative", -3, 3},
{"zero", 0, 0},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if got := Abs(tc.in); got != tc.want {
t.Errorf("Abs(%d) = %d, want %d", tc.in, got, tc.want)
}
})
}
}
⚠️ Частая ошибка: захват переменной цикла в параллельном сабтесте. До Go 1.22 нужен был tc := tc перед t.Run; с Go 1.22 переменная цикла своя на каждой итерации, и костыль больше не нужен.
70Как профилировать Go-сервис через pprof и какие типы профилей бывают?
middle
Короткий ответ: Для живого сервиса добавляют import _ "net/http/pprof" — он вешает эндпоинты на /debug/pprof/, и снимок берут через go tool pprof http://host/debug/pprof/heap. Для офлайн-анализа пишут профиль в файл через runtime/pprof. Ключевые типы: CPU, heap, goroutine, block, mutex.
Подробно:
- Сбор —
net/http/pprofдля боевого сервиса (эндпоинты на debug-порту, не публичном!) либоruntime/pprof/ флагиgo test -cpuprofileдля локального прогона. - Анализ —
go tool pprofв интерактиве:top,list Func,web(граф) и флейм-граф в браузере через-http=:8080. - Выбор профиля под симптом — senior-маркер в том, чтобы знать, какой профиль снимать под какую проблему, а не снимать CPU на всё подряд.
| Профиль | Что ищем |
|---|---|
| cpu | где горит процессор, горячие функции |
| heap | аллокации и потребление памяти |
| goroutine | утечки горутин (растущее число) |
| block | ожидание на каналах/мьютексах |
| mutex | contention на блокировках |
Профили block и mutex по умолчанию выключены — их включают через runtime.SetBlockProfileRate и runtime.SetMutexProfileFraction.
⚠️ Частая ошибка: оставить net/http/pprof на публичном порту. Импорт регистрирует хендлеры на DefaultServeMux, и /debug/pprof/ утекает наружу — держите его на отдельном внутреннем listener'е.
71Что ловит go vet такого, что пропускает компилятор?
junior
Короткий ответ: go vet ловит подозрительные, но синтаксически корректные конструкции, которые компилятор пропускает: несоответствие аргументов формату printf, копирование мьютексов, битые struct-теги, недостижимый код. Компилятор проверяет, что программа собирается; vet проверяет, что она, скорее всего, делает то, что вы имели в виду.
Подробно:
- printf-форматы —
fmt.Printf("%d", "str")компилируется, но vet укажет:Printf format %d has arg of wrong type string. - copylocks — присваивание или передача по значению структуры с
sync.Mutexкопирует мьютекс вместе с его внутренним состоянием и ломает синхронизацию. - struct-теги — опечатка в
json:"..."(кривые кавычки, пробелы) молча ломает маршалинг — vet её видит. - unreachable / lostcancel — недостижимый код, потерянный
cancelотcontext.WithCancel.
| Чек | Пример проблемы |
|---|---|
| printf | Printf("%d", s) — s это string |
| copylocks | передача sync.Mutex по значению |
| structtag | опечатка в json: теге |
| lostcancel | не вызвали cancel() |
В CI обычно гоняют не голый vet, а golangci-lint — агрегатор, который поверх vet подключает staticcheck, errcheck и десятки других линтеров.
⚠️ Частая ошибка: думать, что go test не запускает vet. На самом деле go test по умолчанию прогоняет подмножество проверок vet перед тестами — часть предупреждений вы уже видите там.
72Зачем нужны go.mod и go.sum и как работает Minimal Version Selection?
junior
Короткий ответ: go.mod объявляет путь модуля и требуемые версии зависимостей, go.sum фиксирует криптографические хэши скачанных модулей для верификации целостности. Minimal Version Selection (MVS) выбирает для каждой зависимости минимальную версию, которая удовлетворяет все require в графе, — а не самую свежую. Это даёт воспроизводимую сборку без отдельного lock-файла.
Подробно:
go.mod—module, версияgo, блокrequireс прямыми и косвенными зависимостями и их версиями.go.sum— хэши по каждому модулю и егоgo.mod; при сборкеgoсверяет скачанное с ними и с checksum-базой (sum.golang.org).- MVS — берёт максимум из минимально требуемых версий по всему графу. Если A требует
v1.2.0, а B —v1.3.0, выберетсяv1.3.0; релизv1.9.0в апстриме сборку не сдвинет, пока никто её явно не потребует.
твой модуль
├─ require A v1.2.0 ─► требует lib v1.4.0
└─ require B v1.5.0 ─► требует lib v1.3.0
MVS выбирает lib = max(1.4.0, 1.3.0) = v1.4.0
⚠️ Частая ошибка: называть go.sum lock-файлом. Он не «пинит» версии — версии решает go.mod + MVS; go.sum лишь хранит хэши для проверки, что скачали именно то, что ожидали, и в него могут попадать хэши даже не выбранных версий.
73Как работают дженерики в Go и что означает constraint comparable?
middle
Короткий ответ: С Go 1.18 функции и типы могут иметь параметры типа в квадратных скобках, ограниченные constraint'ом. Constraint — это интерфейс, задающий набор допустимых типов и операций над ними. comparable — встроенный constraint, разрешающий операции == и != (например, чтобы использовать тип как ключ мапы или искать через Contains).
Подробно:
- Параметры типа —
func F[T Constraint](x T); компилятор подставляет конкретный тип на месте вызова, без приведений в рантайме. - Constraint как интерфейс — обычный (метод-сет) или type-set:
[T int | float64]разрешает только эти типы и их операторы. comparable— покрывает типы, поддерживающие==/!=; нужен там, где значение сравнивают или кладут в мапу/множество.- Против
any— дженерик даёт типобезопасность на компиляции без type assertion'ов и боксинга вinterface{}.
func Index[T comparable](s []T, target T) int {
for i, v := range s {
if v == target { // == разрешён благодаря comparable
return i
}
}
return -1
}
⚠️ Частая ошибка: тащить дженерики туда, где хватает обычного интерфейса. Если методы у типов уже общие — интерфейс идиоматичнее; дженерики нужны, когда важен сам конкретный тип (элементы слайса, ключи мапы, арифметика).
74Как мокают зависимости в Go-тестах, если нет monkey-patching?
junior
Короткий ответ: Зависимость выражают маленьким интерфейсом, объявленным на стороне потребителя, и внедряют реализацию через конструктор (DI). В тесте подставляют свою фейковую реализацию этого интерфейса — руками или сгенерированную mockgen. Никакого monkey-patching не нужно.
Подробно:
- Узкий интерфейс у потребителя — код зависит не от конкретного
*sql.DB, а от интерфейса с одним-двумя методами, которые ему реально нужны. - Внедрение через конструктор —
NewService(store Store); в проде передаём боевую реализацию, в тесте — фейк. - Фейк или mock — простой стаб пишут руками; для интерфейсов с проверкой вызовов мок генерируют через
mockgen(gomock, теперь живёт вgo.uber.org/mock) или берутtestify/mock.
type Store interface {
Get(id string) (User, error)
}
type fakeStore struct{ u User }
func (f fakeStore) Get(string) (User, error) { return f.u, nil }
func TestService(t *testing.T) {
svc := NewService(fakeStore{u: User{Name: "Ann"}})
// ... проверяем поведение svc на фейке
}
⚠️ Частая ошибка: мокать чужой огромный интерфейс с двадцатью методами вместо своего узкого. Идиома «accept interfaces, return structs»: интерфейс объявляет тот, кто его потребляет, ровно под свои нужды, — тогда и фейк тривиален.
75Как написать бенчмарк в Go и что показывает флаг -benchmem?
middle
Короткий ответ: Бенчмарк — это функция func BenchmarkX(b *testing.B) в _test.go, где измеряемый код гоняют в цикле нужное число итераций. Запуск: go test -bench=. -benchmem. Флаг -benchmem добавляет к времени метрики памяти: B/op (байт на операцию) и allocs/op (число аллокаций на операцию).
Подробно:
- Классический цикл —
for i := 0; i < b.N; i++; фреймворк сам подбираетb.N, чтобы набрать статистику. b.Loop()(Go 1.24) —for b.Loop()заменяет ручнойb.N, сам исключает setup из замера и не даёт компилятору выкинуть тело цикла.- Чтение вывода —
ns/opэто время,B/opиallocs/op— память; аллокации часто важнее наносекунд, потому что грузят GC.
var sink string
func BenchmarkJoin(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
sink = strings.Join([]string{"a", "b", "c"}, ",")
}
}
// BenchmarkJoin-8 35000000 34.2 ns/op 8 B/op 1 allocs/op
⚠️ Частая ошибка: компилятор видит, что результат не используется, и выкидывает тело цикла — бенчмарк показывает ~0 ns/op. При классическом b.N-цикле результат сохраняют в пакетную sink-переменную; b.Loop() (Go 1.24) решает это за вас.
76Сага: оркестрация или хореография — когда что выбрать?
middle
Короткий ответ: Оркестрация — явный координатор (state machine) ведёт сагу и вызывает шаги; хореография — сервисы реагируют на события друг друга без центра. Оба варианта дают только eventual consistency и требуют компенсирующих транзакций — выбор лишь о том, где жить сложности: в явном оркестраторе или в неявном потоке событий.
Подробно:
| Оркестрация | Хореография | |
|---|---|---|
| Поток | явная state machine в одном месте | неявный: цепочка событий |
| Связанность | сервисы знают оркестратора | слабая: только события |
| Наблюдаемость | статус саги в одном месте | «кто виноват» — собирать по трейсам |
| Риски | оркестратор — точка отказа и узел связности | циклические подписки, каскады событий |
| Когда | длинные саги, много шагов, нужен аудит | 2–3 шага, независимые команды |
- Оркестрация — легко ответить «в каком состоянии заказ №42», просто добавить таймауты и ретраи; цена — все бизнес-потоки стягиваются в один сервис, и его доступность становится критичной.
- Хореография — сервисы деплоятся независимо; цена — поток нигде не записан: новый шаг = «подписаться туда-то», и через год никто не знает всю цепочку.
⚠️ Частая ошибка: подавать хореографию как «более правильную из-за слабой связанности». Связность не исчезает — она переезжает из кода в неявный граф событий, который дороже отлаживать.
77Спроектируйте компенсации для саги «создать заказ → списать оплату → зарезервировать товар», если резервирование падает.
senior
Короткий ответ: Компенсирующие транзакции выполняются в обратном порядке: вернуть деньги → отменить заказ. Каждая компенсация обязана быть идемпотентной и ретраибельной; заказ до конца саги живёт в статусе PENDING (semantic lock) и не виден как подтверждённый; компенсация, которая сама не проходит, уходит в ретраи + алерт + очередь ручного разбора — молча бросить её нельзя.
Подробно:
create order (PENDING) ──► charge payment ──► reserve stock ✗ FAIL
│
cancel order (PENDING→CANCELLED) ◄── refund payment ◄──┘
- Обратный порядок — компенсируем только выполненные шаги: сначала refund, затем cancel; само резервирование не удалось — компенсировать нечего.
- Semantic lock — пока сага не завершилась, заказ в PENDING: пользователь не видит его подтверждённым, «полусостояние» не утекает наружу.
- Идемпотентность компенсаций — refund с ключом идемпотентности (orderId): ретрай после таймаута платёжки не вернёт деньги дважды.
- Компенсация не проходит — платёжный шлюз лежит: ретраи с backoff, после N попыток — алерт и запись в очередь ручного разбора. Деньги клиента нельзя потерять в логах.
⚠️ Частая ошибка: считать компенсацию «откатом транзакции». Это обычная бизнес-операция: она может упасть, задублироваться и должна проектироваться так же тщательно, как прямой шаг.
78Что такое transactional outbox и какую проблему он решает?
middle
Короткий ответ: Проблему dual write: коммит в БД и publish в брокер — две разные системы, атомарности между ними нет. Упади сервис между ними — событие потеряно (или наоборот: событие улетело, а записи нет). Outbox: событие пишется в таблицу аутбокса в той же локальной транзакции, что и бизнес-данные, а отдельный relay публикует его в брокер.
Подробно:
┌──── одна локальная транзакция ────┐
│ INSERT INTO orders … │
│ INSERT INTO outbox (event) … │
└───────────────────────────────────┘
outbox ──► relay (поллер или CDC/Debezium) ──► брокер
- Атомарность бесплатно — обе записи в одной БД: либо есть и заказ, и событие, либо ничего.
- Relay — поллер (
SELECT … FOR UPDATE SKIP LOCKED) или CDC: Debezium читает WAL — события уезжают без нагрузки поллинга. - Следствие: at-least-once — relay может упасть после publish, но до отметки «отправлено» → событие уедет дважды. Значит, на другой стороне обязан стоять идемпотентный консюмер с дедупликацией.
⚠️ Частая ошибка: «опубликуем до коммита — тогда точно не потеряем». Это лишь переворачивает сбой: коммит упал, а событие уже улетело — фантомное событие о заказе, которого не существует.
79Как устроен идемпотентный консюмер (inbox-паттерн)?
middle
Короткий ответ: Консюмер держит таблицу обработанных message id с UNIQUE-ограничением и вставляет id в одной локальной транзакции с бизнес-эффектом. Повторная доставка упирается в constraint — сообщение просто пропускается, эффект не задваивается. Два ключевых условия: id стабилен между ретраями, а дедупликация атомарна с эффектом.
Подробно:
BEGIN;
INSERT INTO processed_messages (message_id) VALUES ($1);
-- дубль → unique_violation → ROLLBACK, сообщение ack'аем
UPDATE accounts SET balance = balance - 100 WHERE id = $2;
COMMIT;
- Одна транзакция — если проверять «обработано?» отдельно от эффекта, между проверкой и записью влезает гонка двух консюмеров или ретраев.
- Стабильный id — его генерирует продюсер (id события из аутбокса), а не брокер при доставке: ретрай обязан прийти с тем же id.
- TTL/чистка — таблица растёт: чистим по времени, но окно хранения должно перекрывать максимальную задержку редоставки (ретраи, DLQ).
- Эффект вне БД — HTTP-вызов этим паттерном не защищается: нужен ключ идемпотентности уже на стороне вызываемого API.
⚠️ Частая ошибка: дедуплицировать в Redis или в памяти рядом с транзакцией в Postgres — «отметка» и эффект снова расходятся; это тот же dual write в профиль.
80Двухфазный коммит: как работает и почему в микросервисах его избегают?
middle
Короткий ответ: Координатор рассылает prepare (каждый участник голосует и держит локи), затем commit. Протокол блокирующий: упади координатор между фазами — участники зависают in-doubt с удержанными локами и не могут ни закоммитить, ни откатиться. Плюс латентность по самому медленному участнику и нетерпимость к партишенам — поэтому в микросервисах вместо 2PC живут сага и Outbox.
Подробно:
координатор: prepare? ──► A: yes B: yes
💥 crash до рассылки commit
A, B: in-doubt — локи держатся, строки заблокированы;
сами решить не могут: вдруг координатор уже
успел записать commit
- Фаза 1 (prepare) — участники пишут redo/undo, голосуют и держат локи до развязки.
- Фаза 2 (commit/abort) — единогласное yes → commit всем; любой no или таймаут → abort.
- Цена — синхронное ожидание всех участников: латентность = самый медленный; блокировка при отказе координатора; при сетевом партишене протокол просто стоит.
- Где встречается — XA-транзакции внутри одного JVM/СУБД-мира существуют, но между сервисами с разными БД и брокерами — почти никогда.
⚠️ Частая ошибка: предлагать 2PC как «честную» альтернативу саге. 2PC покупает атомарность ценой доступности и живучести — в распределённой системе эта цена обычно неприемлема.
81Существует ли exactly-once доставка? Что на самом деле имеют в виду под exactly-once?
senior
Короткий ответ: Exactly-once доставки не существует (задача двух генералов: подтверждение может потеряться, и отправитель обязан ретраить). Достижима exactly-once-семантика обработки: at-least-once доставка + идемпотентный консюмер с дедупликацией — эффект применяется ровно один раз, сколько бы раз ни пришло сообщение.
Подробно:
| Уровень | Гарантия | Как |
|---|---|---|
| at-most-once | нет дублей, возможна потеря | fire-and-forget |
| at-least-once | нет потерь, возможны дубли | ack + ретраи |
| exactly-once processing | эффект ровно один раз | at-least-once + идемпотентность/дедупликация |
- Почему доставка невозможна — между «обработал» и «ack дошёл» всегда есть окно сбоя; брокер обязан передоставить — иначе потеря.
- Kafka «exactly-once» — транзакции покрывают цикл read-process-write внутри Kafka: consume и produce атомарны, читатели с
read_committedне видят абортов. - Главная ловушка — транзакции Kafka не покрывают эффекты вне Kafka: запись в Postgres, HTTP-вызов, отправка письма. Для них нужна своя идемпотентность (inbox, ключи идемпотентности).
⚠️ Частая ошибка: «у нас Kafka с exactly-once, дубли невозможны». Как только консюмер пишет в свою БД или зовёт внешний API — гарантия закончилась на границе Kafka.
82Распределённый лок на Redis через SET key value NX PX — что может пойти не так?
middle
Короткий ответ: Три классики: TTL истёк, пока держатель ещё работает (GC-пауза, медленный I/O) — лок берёт второй, и держателей уже двое; release удаляет чужой лок; failover Redis теряет лок из-за асинхронной репликации. Поэтому value — уникальный токен держателя, release — только атомарный check-and-delete на Lua, а для строгой корректности одного Redis-лока мало.
Подробно:
t=0 A: SET lock tokenA NX PX 10000 → OK, работает
t=10 TTL истёк (A завис в GC / ждёт диск) — A не знает
t=11 B: SET lock tokenB NX → OK — держателей ДВОЕ
t=12 A очнулся и дописал в общий ресурс → конфликт
t=13 A: DEL lock → снёс уже ЧУЖОЙ лок B (DEL без проверки)
- Уникальный токен — value = случайный uuid держателя; release сравнивает и удаляет атомарно (Lua:
if GET == token then DEL), иначе A сносит лок B. - TTL — всегда компромисс — короткий: истекает под живым держателем; длинный: после падения держателя все ждут впустую.
- Failover — репликация асинхронна: мастер упал до реплицирования SET — новый мастер про лок не знает, его возьмут второй раз.
⚠️ Частая ошибка: «взять лок» и считать взаимное исключение гарантированным. Redis-лок с TTL — это lease: он может истечь под тобой, и без fencing token защищаемый ресурс этого даже не заметит.
83За что Клеппман критиковал Redlock и что такое fencing token?
senior
Короткий ответ: Безопасность Redlock опирается на тайминговые допущения — ограниченный дрейф часов и ограниченные паузы процессов, — которые реальные системы нарушают (stop-the-world GC, page fault, сетевые задержки). Зависший держатель с «истёкшим» локом всё равно пишет в ресурс. Лечит fencing token: монотонно растущий номер, который проверяет сам ресурс и отбрасывает записи устаревших держателей.
Подробно:
A берёт лок (token 33) ──► GC stop-the-world 15 c
TTL истёк; B берёт лок (token 34) и пишет
A очнулся, «всё ещё держит лок», пишет
без fencing: запись A затирает запись B 💥
с fencing: хранилище видит 33 < 34 → reject A
- Суть критики — лок «по таймеру» без общего источника порядка не может быть безопасным: длительность паузы процесса и дрейф часов ничем не ограничены, а изнутри пауза невидима.
- Fencing token — выдаётся вместе с локом и строго растёт; проверять обязан ресурс: условный
UPDATE … WHERE token >= $1в БД, CAS в хранилище. Клиент проверить сам себя не может. - Что выбрать — корректность критична: лок на консенсусе (ZooKeeper/etcd; zxid/revision — готовый fencing token) или fencing на самом хранилище. Лок «для эффективности» (не делать работу дважды, дубль не страшен) — одиночный Redis ок.
⚠️ Частая ошибка: тюнить TTL Redlock «с запасом» вместо fencing. Никакой TTL не спасает от паузы неизвестной длины — без проверки на стороне ресурса гарантии нет.
84Advisory locks в Postgres: когда они лучше лока на Redis?
middle
Короткий ответ: Когда все претенденты и так ходят в один Postgres. pg_advisory_xact_lock освобождается автоматически вместе с транзакцией (или сессией) — TTL-гонки не существует в принципе: упал процесс → закрылось соединение → лок снят. Идеально для cron-синглтона, гарда миграций, «одна обработка сущности за раз».
Подробно:
BEGIN;
SELECT pg_advisory_xact_lock(hashtext('billing-cron'));
-- критическая секция; лок снимется сам на COMMIT/ROLLBACK/разрыве
COMMIT;
| Redis SET NX PX | pg advisory lock | |
|---|---|---|
| Освобождение | TTL: гонка «истёк под живым» | автоматически с транзакцией |
| Падение держателя | ждать TTL | мгновенно: соединение закрылось |
| Fencing | нужен отдельно | не нужен в пределах этой БД |
| Масштаб | любые сервисы | только клиенты этого Postgres |
| Цена | отдельная инфраструктура | держит соединение и транзакцию |
Ограничения честно: не работает поверх шардов и нескольких БД; длинная критическая секция = длинная транзакция (мешает vacuum); session-level варианты несовместимы с pgbouncer в transaction mode.
⚠️ Частая ошибка: тащить Redis ради лока в систему, где уже есть один общий Postgres. Advisory lock даёт более сильную гарантию бесплатно — без TTL, без fencing, без новой инфраструктуры.
85Линеаризуемость vs eventual consistency: как обеспечить read-your-writes поверх асинхронных реплик?
senior
Короткий ответ: Линеаризуемость: каждая операция как будто атомарно происходит в некоторый момент между её началом и концом — все видят единую временную шкалу. Нужна для балансов, проверок уникальности, лидер-элекшена. Eventual: реплики сойдутся «когда-нибудь». Read-your-writes поверх реплик: читать данные автора с праймари (session stickiness) либо отслеживать LSN/логический таймстемп записи и ждать, пока реплика его догонит.
Подробно:
| Потребность | Модель | Механика |
|---|---|---|
| баланс, проверка уникальности | линеаризуемость | чтение с лидера / кворумное чтение |
| лента, каталог, счётчики | eventual | любая реплика |
| «я сохранил — я вижу» | read-your-writes | праймари для автора / ожидание LSN |
- Стикнуть к праймари — N секунд после записи читать этого пользователя с праймари; просто, но грузит лидера и требует session-состояния.
- Отслеживать позицию — запомнить LSN коммита (
pg_current_wal_lsn()) и читать с реплики, только когдаpg_last_wal_replay_lsn() >= LSN; точнее, но сложнее в обвязке. - Кворум — R + W > N даёт сильное чтение ценой латентности каждого запроса.
⚠️ Частая ошибка: смешивать модели консистентности с уровнями изоляции БД. Изоляция (read committed, serializable) — про конкурентные транзакции на одном узле; консистентность (линеаризуемость, eventual) — про реплики и распределённость. Это разные оси.
86Event sourcing и CQRS: когда они оправданы и какова цена?
concept
Короткий ответ: Event sourcing: состояние не хранится, а выводится — state = fold(events); первичен журнал событий. CQRS: модель записи и модели чтения разделены. Дают полный аудит, temporal queries («как выглядел заказ вчера»), пересборку проекций с нуля. Цена высокая: версионирование событий, снапшоты, eventually consistent read-модели, дорогой тулинг. Это не архитектура по умолчанию.
Подробно:
events: OrderCreated → ItemAdded → ItemAdded → OrderPaid
state = fold(events) — всегда выводимо заново
проекции: «заказы за день», «топ товаров» — свои read-модели,
можно пересобрать из журнала с нуля
- Когда оправдано — движение денег, леджеры, домены с жёстким аудитом и комплаенсом; «почему баланс стал таким» — вопрос бизнеса, а не логов.
- Цена №1: версионирование — событие живёт вечно; изменилась схема — читать все старые версии (upcasters) или мигрировать журнал целиком.
- Цена №2: чтение — read-модели асинхронны: после команды проекция отстаёт, и UI, тесты, саппорт обязаны это переживать.
- CQRS без ES — легитимен и сильно дешевле: обычная БД записи + денормализованные проекции для чтения.
⚠️ Частая ошибка: предлагать event sourcing для CRUD-приложения «на вырост». Если аудит — не бизнес-требование, вы платите всю цену ES и не получаете ничего, что не дала бы таблица плюс журнал изменений.
87Почему нельзя полагаться на wall-clock между сервисами и что ломается при last-write-wins?
senior
Короткий ответ: Часы машин расходятся несмотря на NTP — дрейф, leap smearing, паузы VM дают от миллисекунд до секунд, — поэтому «больший таймстемп» ≠ «случилось позже». Last-write-wins по таким часам молча выбрасывает конкурентные записи: классический lost update между репликами, причём без единой ошибки в логах.
Подробно:
часы A отстают на 2 с
реально t=10.0 A: UPDATE profile (свой ts = 8.0)
реально t=9.5 B: UPDATE profile (свой ts = 9.5)
LWW: 9.5 > 8.0 → «побеждает» B,
хотя запись A физически была ПОЗЖЕ — и молча исчезла
- Wall clock vs monotonic — wall clock прыгает (NTP умеет корректировать назад); интервалы меряют monotonic clock, но он не сравним между машинами. Общего «сейчас» нет.
- Чем чинить — версии + optimistic locking (
UPDATE … WHERE version = $1); монотонный sequence или fencing token от единственного писателя; vector clocks — концептуально, чтобы отличать конкурентность от порядка; проектировать операции коммутативными (increment вместо set, CRDT-мышление). - Где LWW терпим — телеметрия, кэши, поля, где «последний прав» — честная бизнес-семантика, а потеря записи не страшна.
⚠️ Частая ошибка: «настроим NTP поточнее — и норм». NTP уменьшает дрейф, но не ограничивает его гарантированно: без bounded-clock-инфраструктуры уровня TrueTime корректность на таймстемпах не строится.
88Устройство топика Kafka: партиции, реплики, лидер, ISR — и какие настройки делают запись долговечной?
middle
Короткий ответ: Топик — набор партиций; партиция — append-only лог, порядок гарантирован только внутри неё. У каждой партиции есть лидер (обслуживает чтение и запись) и фолловеры-реплики; ISR — реплики, не отстающие от лидера. Стандарт долговечности: acks=all + min.insync.replicas=2 при replication.factor=3.
Подробно:
topic orders (RF=3)
p0: [0|1|2|3|4] ──► leader: broker1, ISR: {1,2,3}
p1: [0|1|2] ──► leader: broker2, ISR: {2,3}
p2: [0|1|2|3] ──► leader: broker3, ISR: {1,3}
- acks=all — лидер отвечает продюсеру только после репликации записи на все реплики из ISR.
- min.insync.replicas=2 — если живых ISR меньше двух, продюсер получает NotEnoughReplicas вместо тихой записи в единственную копию.
- Вместе с RF=3 — падение одного брокера переживается без потери данных и без остановки записи: в ISR остаются двое.
⚠️ Частая ошибка: «Kafka гарантирует порядок сообщений». Только внутри одной партиции; глобального порядка в топике нет.
89Как консюмер-группа распределяет партиции и что будет, если консюмеров больше, чем партиций?
junior
Короткий ответ: Внутри группы каждая партиция читается максимум одним консюмером. Консюмеров больше, чем партиций — лишние простаивают: максимальный параллелизм группы равен числу партиций. Разные группы читают один топик независимо, у каждой свои оффсеты.
Подробно:
topic orders: p0 p1 p2 p3
group A (3 консюмера):
c1 ◄─ p0, p1 c2 ◄─ p2 c3 ◄─ p3
group A (6 консюмеров):
c1◄p0 c2◄p1 c3◄p2 c4◄p3 c5, c6 — idle
group B: читает те же партиции независимо (свои оффсеты)
- Внутри группы — партиция достаётся ровно одному консюмеру: так сохраняется порядок обработки внутри партиции.
- Между группами — независимое чтение: каждая группа коммитит свои оффсеты в
__consumer_offsets, одни и те же данные обслуживают и биллинг, и аналитику. - Планирование — число партиций задаёт потолок масштабирования группы; закладывайте его с запасом при создании топика.
⚠️ Частая ошибка: «добавим консюмеров — станет быстрее». После числа партиций добавленные консюмеры просто простаивают.
90Что запускает ребалансировку консюмер-группы и чем она опасна?
senior
Короткий ответ: Ребалансировка — перераспределение партиций внутри группы. Триггеры: консюмер вошёл/вышел/упал, истёк session.timeout.ms, превышен max.poll.interval.ms (медленная обработка в poll-цикле — классический самострел), изменилась подписка или число партиций. Опасна тем, что eager-протокол останавливает всю группу.
Подробно:
- Eager (классика) — stop-the-world: все консюмеры отзывают все партиции, обработка группы замирает до конца ребаланса.
- Cooperative / incremental (KIP-429) —
CooperativeStickyAssignor: отзываются только перемещаемые партиции, остальные продолжают работать. - Static membership —
group.instance.id: при rolling-рестарте брокер узнаёт вернувшийся консюмер и не запускает ребаланс. - Шторм ребалансов — тяжёлая работа внутри poll-цикла: превысили
max.poll.interval.ms→ консюмер «объявлен мёртвым» → ребаланс → партиции переехали → новый консюмер тоже не успевает → цикл повторяется.
session.timeout.ms=45000
max.poll.interval.ms=300000
group.instance.id=payments-1
partition.assignment.strategy=CooperativeStickyAssignor
⚠️ Частая ошибка: лечить «умирающий» консюмер бесконечным увеличением таймаутов вместо выноса тяжёлой обработки из poll-цикла.
91Как выбрать ключ партиционирования и что ломается при плохом ключе?
middle
Короткий ответ: Ключ — это ваша единица порядка: сообщения с одним ключом попадают в одну партицию и читаются по порядку. Берите бизнес-идентификатор, вокруг которого нужен порядок (order_id, user_id). Плохой ключ даёт перекос — горячие партиции, которые нельзя разгрузить добавлением консюмеров.
Подробно:
| Ключ | Эффект |
|---|---|
| order_id | порядок событий заказа; ровное распределение |
| user_id | порядок по пользователю; риск горячих «китов» |
| country | 3–5 значений → перекос, hot partitions |
| null | round-robin/sticky: ровно, но порядка нет |
- Перекос — партиция с горячим ключом упирается в единственный консюмер: лаг растёт только на ней, а масштабирование группы не помогает.
- Смена числа партиций — hash(key) % partitions меняется: старые и новые сообщения одного ключа оказываются в разных партициях, порядок по ключу ломается. Число партиций закладывайте заранее.
- null-ключ — годится для событий без сущности-владельца, где порядок не важен.
⚠️ Частая ошибка: случайный ключ «для равномерности» там, где нужен порядок по сущности — дозаказать порядок потом уже негде.
92Стратегии коммита оффсетов: авто или вручную — и как правильно получить at-least-once?
middle
Короткий ответ: enable.auto.commit=true коммитит оффсеты по таймеру (auto.commit.interval.ms), вне связи с фактом обработки: можно закоммитить раньше, чем обработали (потеря при падении), или позже (дубли). Правильный at-least-once: сначала обработать — потом закоммитить вручную.
Подробно:
| Когда коммитим | Семантика | Риск |
|---|---|---|
| до обработки | at-most-once | упали после коммита → сообщение потеряно |
| после обработки | at-least-once | упали до коммита → дубль |
| по таймеру (auto) | непредсказуемо | и то и другое |
- Рабочий паттерн —
enable.auto.commit=false; цикл: poll → обработать батч →commitAsync()(не блокирует poll). - На выходе —
commitSync()в shutdown-хуке и вonPartitionsRevokedребаланс-листенера: последняя позиция гарантированно доедет. - Дубли остаются — at-least-once по определению допускает повторы: дедупликация — обязанность консюмера.
⚠️ Частая ошибка: авто-коммит срабатывает до конца обработки батча — упавший консюмер «съел» сообщения, и никто не заметил (это тихий at-most-once).
93Консюмер обработал сообщение, но упал до коммита оффсета. Что произойдёт?
junior
Короткий ответ: Оффсет не закоммичен — для Kafka сообщение не обработано: после ребаланса партиция достанется другому консюмеру, и он получит то же сообщение снова. Эффект уже применён один раз → обработка задвоится. Именно поэтому Kafka по умолчанию at-least-once, а консюмер обязан быть идемпотентным.
Подробно:
c1: poll ──► обработал (записал в БД) ──► 💥 crash
оффсет НЕ закоммичен
ребаланс ──► c2 читает с последнего коммита
──► то же сообщение доставлено снова
- Это контракт, а не баг — брокер не знает, что обработка завершилась; единственный сигнал — закоммиченный оффсет.
- Защита — идемпотентный консюмер: сохранять стабильный message id в inbox-таблицу с
UNIQUE-ограничением в одной транзакции с бизнес-эффектом. Повторная доставка упирается в constraint и не применяет эффект второй раз. - Обратный порядок хуже — коммит до обработки превращает тот же сбой в потерю сообщения, а не в дубль.
⚠️ Частая ошибка: считать дубли аномалией и не писать дедупликацию — «у нас же Kafka, там всё надёжно».
94Лаг консюмера растёт. Как диагностировать и чинить?
senior
Короткий ответ: Сначала измерить per-partition лаг (kafka-consumer-groups.sh или экспортер в метрики) и понять, что изменилось: вырос входной поток или замедлилась обработка. Дальше чинить по ранжиру: консюмеры до числа партиций, вынести тяжёлую работу из poll-цикла, батчинг, и только потом — увеличение партиций и backpressure.
Подробно:
$ kafka-consumer-groups.sh --describe --group billing
TOPIC PART CURRENT LOG-END LAG
orders 0 91400 91420 20
orders 1 52100 98700 46600 ◄ одна партиция
orders 2 97800 97810 10
- Лаг на одной партиции — горячий ключ или «застрявшее» сообщение: масштабирование не поможет, чинить ключ или обработку.
- Лаг равномерный — скейлить консюмеры до числа партиций; дальше — бессмысленно.
- Медленная обработка — вынести I/O из poll-цикла в пул воркеров (аккуратно: порядок по ключу), батчить записи в БД.
- Системно — увеличить число партиций (планово: ремапит ключи) или договориться с продюсерами о backpressure.
- Пока чините — следите за
max.poll.interval.ms: медленный цикл спровоцирует ребаланс и удвоит лаг.
⚠️ Частая ошибка: первым делом добавить консюмеров сверх числа партиций — новые просто простаивают.
95Как построить ретраи в Kafka, не блокируя партицию?
senior
Короткий ответ: Ретраить на месте, блокируя poll, нельзя: одно ядовитое сообщение остановит всю партицию. Паттерн — ретраи через отдельные топики с нарастающей задержкой (orders-retry-5m → orders-retry-30m) и DLQ после N попыток, с заголовками о причине сбоя.
Подробно:
orders ──✗──► orders-retry-5m ──✗──► orders-retry-30m ──✗──► orders-dlq
│ ok │ ok (после паузы) │ ok │ алерт +
▼ ▼ ▼ ручной разбор
- Основной консюмер — при ошибке публикует сообщение в retry-топик и коммитит оффсет: партиция не блокируется.
- Retry-консюмер — выдерживает задержку (сравнивает timestamp сообщения с now), обрабатывает; новая ошибка — следующий уровень.
- DLQ — после N попыток; в заголовках: причина, стектрейс, счётчик попыток; алерт на непустой DLQ обязателен.
- Цена — порядок — сообщение ушло в ретраи, а следующее по тому же ключу обработалось раньше: per-key порядок сломан. Несите версию/sequence в событии и отбрасывайте устаревшие.
⚠️ Частая ошибка: бесконечный in-place ретрай ядовитого сообщения — партиция стоит, лаг растёт, а таймауты poll добивают группу ребалансами.
96Идемпотентный продюсер и транзакции Kafka: что на самом деле гарантирует каждый механизм?
senior
Короткий ответ: enable.idempotence=true: продюсер получает producer id, каждое сообщение — sequence number, и брокер отбрасывает дубли ретраев — в пределах партиции и сессии продюсера. Транзакции (transactional.id + read_committed) добавляют атомарный consume-transform-produce между топиками Kafka. Ни то ни другое не дедуплицирует бизнес-дубли и не покрывает эффекты вне Kafka.
Подробно:
| Механизм | Что гарантирует | Границы |
|---|---|---|
| идемпотентный продюсер | ретрай send не создаст дубль | партиция + сессия продюсера |
| транзакции | consume + produce атомарны | только топики Kafka |
| read_committed | абортированные записи не видны | сторона консюмера |
- Идемпотентность — защита от сетевых ретраев самого клиента; повторный
send()из кода приложения — это новое сообщение. - Транзакции — оффсеты коммитятся в той же транзакции (
sendOffsetsToTransaction): цикл read-process-write целиком или никак. - Границы — приложение отправило одно бизнес-событие дважды → для Kafka это два разных сообщения; запись в Postgres или HTTP-вызов транзакцией Kafka не покрыты.
⚠️ Частая ошибка: «включили exactly-once — дедупликация не нужна». На границе Kafka гарантия заканчивается; идемпотентный консюмер всё ещё обязателен.
97Kafka или RabbitMQ: как выбрать под конкретную задачу?
middle
Короткий ответ: Kafka — реплицируемый лог с retention и replay: огромная пропускная способность, много независимых консюмер-групп, стриминг и event sourcing. RabbitMQ — умный брокер: маршрутизация exchange'ами, per-message ack/requeue, приоритеты, TTL и отложенная доставка из коробки — классическая очередь задач.
Подробно:
| Требование | Kafka | RabbitMQ |
|---|---|---|
| replay / перечитать историю | ✔ retention | ✘ удаляется после ack |
| сложная маршрутизация | ✘ только топики/ключи | ✔ exchanges: topic, fanout, headers |
| delay / приоритеты | через retry-топики, костыльно | ✔ нативно |
| пропускная способность | миллионы msg/s | десятки–сотни тысяч |
| много независимых читателей | ✔ группы со своими оффсетами | нужно плодить очереди |
| очередь задач | посредственно | ✔ ack/requeue per message |
- Kafka — данные как лог: интеграция сервисов через события, аналитика, стрим-обработка, шина event sourcing.
- Rabbit — команды и задачи: «отправь письмо», ретрай одного сообщения без влияния на соседей, ниже латентность на малых масштабах.
⚠️ Частая ошибка: «Kafka всегда лучше, она масштабнее». Для очереди задач с приоритетами и отложенной доставкой Rabbit проще и точнее по семантике.
98Как гарантировать порядок обработки событий одного заказа end-to-end?
middle
Короткий ответ: Порядок держится цепочкой: ключ партиционирования = order_id (все события заказа в одной партиции), партицию читает один консюмер группы, внутри консюмера нет переупорядочивания (serial по ключу при worker-пуле), а ретраи не обгоняют — в событии есть версия, устаревшие отбрасываются.
Подробно:
- Продюсер — key=order_id: created, paid, shipped одного заказа лягут в одну партицию по порядку записи; плюс идемпотентный продюсер, чтобы сетевой ретрай не переставил сообщения.
- Брокер → группа — партиция назначена ровно одному консюмеру: порядок чтения равен порядку лога.
- Внутри консюмера — асинхронный worker-пул ломает порядок: шардируйте задачи по ключу — один ключ всегда попадает в один воркер и обрабатывается последовательно.
- Ретраи — retry-топики обгоняют основную очередь: несите version/sequence в событии, консюмер отбрасывает устаревшие (state machine, терпимая к out-of-order).
- Между топиками — порядка НЕТ и не будет: payments и shipments не синхронизированы; проектируйте state machine, а не надейтесь на порядок доставки.
⚠️ Частая ошибка: обеспечить ключ и группу, но раздать сообщения в асинхронный пул — порядок тихо ломается уже внутри процесса.
99Зачем ставить очередь между сервисами, если есть HTTP с ретраями?
concept
Короткий ответ: Очередь развязывает сервисы во времени: консюмер может лежать, а продюсер продолжает работать; пики сглаживаются буфером; одно событие фан-аутится многим потребителям; историю можно перечитать. HTTP с ретраями оставляет временнУю связанность: получатель обязан быть жив прямо сейчас.
Подробно:
| HTTP + ретраи | Очередь | |
|---|---|---|
| получатель лежит | ретраи, потом отказ | сообщения копятся, обработаются позже |
| пик нагрузки | бьёт по получателю | буфер; консюмер в своём темпе |
| фан-аут | N вызовов из кода | подписки; продюсер не знает читателей |
| ответ сразу | ✔ | ✘ correlation id, callback |
| консистентность | сильнее (sync) | eventual |
- Очередь — события, фоновые задачи, пиковые нагрузки, несколько независимых потребителей одного события.
- HTTP — нужен немедленный ответ или сильная консистентность в рамках запроса.
- Цена очереди — eventual consistency, обязательная обработка дублей (at-least-once), мониторинг лага, request-response через correlation — сложнее и дороже в отладке.
⚠️ Частая ошибка: тащить очередь туда, где вызывающему нужен синхронный ответ — получите ту же связанность плюс брокер в придачу.
100Cache-aside, write-through, write-behind: как работает каждая стратегия и когда какую выбрать?
middle
Короткий ответ: Cache-aside — приложение читает кэш, на промахе идёт в БД и кладёт результат; на запись пишет в БД и инвалидирует ключ. Write-through — запись синхронно в кэш и в хранилище. Write-behind — ack от кэша, в хранилище сбрасывается асинхронно. Дефолт — cache-aside; остальные две — под конкретные профили нагрузки.
Подробно:
| Cache-aside | Write-through | Write-behind | |
|---|---|---|---|
| Чтение | miss → БД → кэш | из кэша | из кэша |
| Запись | БД + инвалидировать ключ | кэш + БД синхронно | кэш сразу, БД потом |
| Консистентность | окно stale после записи | читатели видят свежее | слабая до сброса |
| Латентность записи | как у БД | БД + кэш: медленнее | только кэш: быстро |
| Риск потери | нет | нет | есть: упал до сброса |
- Cache-aside — кэш не стоит на критическом пути записи и его падение переживается (просто больше промахов); цена — свой протокол инвалидации на каждую запись.
- Write-through — консистентные чтения из коробки; цена — каждая запись ждёт оба хранилища, и кэшируется в том числе то, что никто не прочитает.
- Write-behind — буфер для шквала записей (счётчики, лайки, метрики); без durable-буфера (очередь, AOF) падение узла = потерянные записи.
⚠️ Частая ошибка: выбирать write-behind «для скорости», не ответив, что случится с несброшенными записями при падении процесса.
Источники
Источники и редакционная политика
Материалы RecallDeck сопоставлены с официальной документацией и открытыми публикациями компаний, когда первичный источник доступен. Мы не связаны с упомянутыми работодателями, не публикуем конфиденциальные задания и не продаём места в подборках. Формат найма может меняться — уточняйте его у рекрутера.
От чтения к воспроизведению
Отрепетируйте полный цикл интервью.
RecallDeck возвращает сложные темы по расписанию и помогает удерживать в памяти язык, SQL, архитектуру и поведенческие истории.