Собеседование на Go-разработчика в 2026 году
Сегодня мы разберём мок-собеседование в формате стрима, где кандидат Гриша, разработчик с опытом работы на PHP и Go, проходит техническое интервью под руководством опытного интервьюера Рамиля. В ходе собеседования кандидат решает алгоритмические задачи на Go, работает с конкурентностью и каналами, а также выполняет код-ревью реального сервиса обработки платежей, демонстрируя практические навыки и теоретическую подготовку на уровне middle-разработчика.
Вопрос 1. Расскажи о себе и своем опыте работы.
Таймкод: 00:00:00
Ответ собеседника: Правильный. Гриша, backend-разработчик, работает преимущественно с PHP и Go, ищет возможность полностью перейти на Go.
Правильный ответ:
На собеседовании на позицию Go-разработчика ожидается более структурированный и содержательный ответ. Вот как можно сформулировать сильный ответ.
Ключевые элементы хорошего ответа:
1. Профессиональный бэкграунд
Кандидат должен кратко описать свой опыт работы с Go: сколько лет, в каких проектах, какую роль занимал, какие задачи решал. Например:
«Я backend-разработчик с X лет опыта. Последние Y лет активно работаю с Go — разрабатывал микросервисы, участвовал в проектировании API, писал высоконагруженные системы. Ранее работал с PHP, но сейчас фокусируюсь именно на Go, потому что язык отлично подходит для конкурентных и распределённых систем.»
2. Технический стек и экспертиза
Стоит упомянуть конкретные технологии и инструменты:
- Go-специфичное: стандартная библиотека (net/http, context, sync, goroutines, channels, interfaces)
- Базы данных: PostgreSQL, MySQL, Redis, MongoDB
- Инфраструктура: Docker, Kubernetes, gRPC, Protocol Buffers
- Паттерны: CQRS, Clean Architecture, graceful shutdown, circuit breaker
3. Мотивация перехода на Go
Объяснить, почему Go привлекает:
- Простота и читаемость кода
- Встроенная поддержка конкурентности (goroutines, channels)
- Быстрая компиляция и статическая типизация
- Отличная стандартная библиотека
- Сильное комьюнити и развитая экосистема инструментов
4. Примеры проектов
Привести конкретные достижения:
«В последнем проекте разработал сервис обработки событий с использованием Kafka и goroutine worker pool, что позволило увеличить throughput в 3 раза. Также внедрил graceful shutdown и health check endpoints для корректной работы в Kubernetes.»
5. Что искать в новой роли
Показать осознанный подход к выбору компании:
«Ищу команду, где смогу глубже погрузиться в Go, работать с распределёнными системами и микросервисной архитектурой. Важно для меня — code review, тестирование и возможность влиять на технические решения.»
Пример хорошего ответа:
«Я backend-разработчик с 4 годами опыта. Последние 2 года активно работаю с Go — разрабатывал микросервисы для платформы электронной коммерции. Участвовал в проектировании REST API, писал обработчики с использованием net/http, работал с PostgreSQL через pgx, реализовывал кэширование через Redis. Ранее работал с PHP (Laravel), но перешёл на Go из-за его простоты и производительности. Сейчас ищу позицию, где смогу глубже погрузиться в Go, работать с конкурентностью и микросервисной архитектурой. Интересно развиваться в сторону распределённых систем.»
Этот ответ демонстрирует техническую зрелость, конкретный опыт и осознанный подход к карьерному развитию.
Вопрос 2. Найти пересечение двух слайсов (массивов), вернуть только уникальные значения без дубликатов, с линейной сложностью.
Таймкод: 00:00:48
Ответ собеседника: Правильный. Решение с использованием мапы: сначала построить мапу из элементов первого массива, затем итерироваться по второму массиву, проверяя наличие элементов в мапе. Для исключения дубликатов в результате — удалять найденный элемент из мапы после добавления в результат.
Правильный ответ:
Задача на нахождение пересечения двух слайсов с линейной сложностью O(n + m) — классическая задача на использование хеш-таблицы (map в Go).
Алгоритм:
- Создать map из элементов первого слайса (ключи — элементы, значения — true)
- Итерироваться по второму слайсу
- Если элемент найден в map — добавить в результат и удалить из map (чтобы избежать дубликатов)
- Вернуть результат
Реализация на Go:
package main
import "fmt"
func intersection(nums1, nums2 []int) []int {
// Создаём map из первого слайса
seen := make(map[int]bool, len(nums1))
for _, v := range nums1 {
seen[v] = true
}
result := make([]int, 0)
// Итерируемся по второму слайсу
for _, v := range nums2 {
if seen[v] {
result = append(result, v)
// Удаляем из map, чтобы избежать дубликатов
delete(seen, v)
}
}
return result
}
func main() {
nums1 := []int{1, 2, 2, 3, 4, 5}
nums2 := []int{2, 2, 4, 6, 8}
fmt.Println(intersection(nums1, nums2)) // [2 4]
}
Сложность:
- Временная: O(n + m), где n и m — длины слайсов. Один проход по каждому слайсу.
- Пространственная: O(n) для хранения map.
Альтернативный подход — использование двух map:
Если нужно учитывать количество вхождений (например, элемент встречается 2 раза в обоих слайсах), можно использовать map[int]int для подсчёта:
func intersectionWithCount(nums1, nums2 []int) []int {
count := make(map[int]int, len(nums1))
for _, v := range nums1 {
count[v]++
}
result := make([]int, 0)
for _, v := range nums2 {
if count[v] > 0 {
result = append(result, v)
count[v]--
}
}
return result
}
Ключевые моменты для интервью:
- Использование map для достижения линейной сложности
- Удаление элемента из map после добавления в результат предотвращает дубликаты
- Предварительное выделение памяти через
make(map[int]bool, len(nums1))улучшает производительность - Для отсортированных слайсов можно использовать два указателя без дополнительной памяти
Вопрос 3. Что такое capacity (cap) у слайса в Go и зачем она нужна.
Таймкод: 00:12:56
Ответ собеседника: Правильный. Слайс состоит из указателя на массив, длины (количество элементов) и capacity (размер внутреннего массива). Capacity позволяет динамически расширять слайс без частых аллокаций памяти — при достижении предела выделяется новый массив большего размера (обычно в 2 раза), и данные копируются.
Правильный ответ:
Устройство слайса в Go
Слайс — это структура данных, которая содержит три поля:
type slice struct {
array unsafe.Pointer // указатель на массив
len int // длина (количество элементов)
cap int // вместимость (размер внутреннего массива)
}
- len (length) — количество элементов, к которым можно обратиться
- cap (capacity) — общее количество элементов в базовом массиве, начиная с первого элемента слайса
Зачем нужна capacity
Capacity позволяет оптимизировать работу с памятью:
-
Амортизированная стоимость append. При добавлении элементов через
append, если capacity не исчерпана, новый массив не выделяется. Когда capacity заканчивается, Go выделяет новый массив (обычно с удвоением размера) и копирует данные. -
Предсказуемая производительность. Зная capacity заранее, можно избежать лишних аллокаций с помощью
make([]T, 0, capacity).
Примеры работы с capacity:
package main
import "fmt"
func main() {
// Создаём слайс с предвыделенной capacity
s := make([]int, 0, 5)
fmt.Println(len(s), cap(s)) // 0 5
// Добавляем элементы — capacity не меняется
for i := 0; i < 5; i++ {
s = append(s, i)
}
fmt.Println(len(s), cap(s)) // 5 5
// Превышаем capacity — происходит реаллокация
s = append(s, 5)
fmt.Println(len(s), cap(s)) // 6 10 (удвоение)
}
Стратегия роста в Go
В современных версиях Go стратегия роста следующая:
- Для маленьких слайсов (cap < 256) — удвоение
- Для больших слайсов (cap >= 256) — рост примерно на 25% (множитель ~1.25)
Это позволяет сбалансировать использование памяти и количество реаллокаций.
Практические советы:
- Предвыделение памяти:
// Плохо — множественные реаллокации
var result []int
for i := 0; i < 10000; i++ {
result = append(result, i)
}
// Хорошо — одна аллокация
result := make([]int, 0, 10000)
for i := 0; i < 10000; i++ {
result = append(result, i)
}
- Создание подслайса с сохранением ссылки на оригинал:
original := []int{1, 2, 3, 4, 5}
sub := original[:2] // len=2, cap=5 — ссылается на тот же массив!
sub = append(sub, 99)
// original теперь [1, 2, 99, 4, 5] — данные изменены!
Для избежания этого используйте full slice expression:
sub := original[:2:2] // len=2, cap=2 — ограниченная capacity
- Копирование слайса:
original := []int{1, 2, 3}
copySlice := make([]int, len(original))
copy(copySlice, original) // независимая копия
Ключевые моменты для интервью:
- Capacity — это размер базового массива, а не количество свободных ячеек
- Append при нехватке capacity создаёт новый массив и копирует данные
- Предвыделение памяти через
make([]T, 0, cap)— важная оптимизация - Подслайсы разделяют базовый массив с оригиналом — это источник багов
Вопрос 4. Реализовать функцию, которая принимает слайс целых чисел и количество воркеров. Каждый воркер обрабатывает свой непрерывный кусок слайса (например, умножает на 2) и отправляет результаты в канал. Главная горутина собирает все результаты и возвращает итоговый слайс.
Таймкод: 00:14:40
Ответ собеседника: Неполный. Кандидат начал реализацию с каналом и воркерами, но сначала неправильно понял задачу (думал, что каждый воркер берёт по одному элементу из канала). После уточнения начал делить слайс на чанки по длине/количество воркеров, передавать каждому воркеру свой чанк. В процессе обсуждения были выявлены проблемы: гонка данных на передачу start/end в замыкание, обработка случая workers=0. Кандидат понимает общий подход, но решение содержит ошибки и не доведено до рабочего состояния.
Правильный ответ:
Это классическая задача на параллельную обработку данных с разделением на чанки (chunking). Ключевые аспекты: корректное разделение слайса, передача параметров в горутины, сбор результатов.
Полная реализация:
package main
import (
"fmt"
"sync"
)
// processChunk обрабатывает часть слайса и отправляет результаты в канал
func processChunk(chunk []int, results chan<- []int, wg *sync.WaitGroup) {
defer wg.Done()
// Создаём новый слайс для результатов (не модифицируем оригинал)
processed := make([]int, len(chunk))
for i, v := range chunk {
processed[i] = v * 2 // пример обработки
}
results <- processed
}
// parallelProcess распределяет работу между воркерами
func parallelProcess(data []int, workers int) []int {
if workers <= 0 {
workers = 1
}
if len(data) == 0 {
return []int{}
}
// Ограничиваем количество воркеров размером данных
if workers > len(data) {
workers = len(data)
}
results := make(chan []int, workers)
var wg sync.WaitGroup
// Вычисляем размер чанка
chunkSize := len(data) / workers
remainder := len(data) % workers
start := 0
for i := 0; i < workers; i++ {
// Распределяем остаток по первым воркерам
end := start + chunkSize
if i < remainder {
end++
}
wg.Add(1)
// ВАЖНО: передаём параметры явно, чтобы избежать гонки данных
go processChunk(data[start:end], results, &wg)
start = end
}
// Закрываем канал после завершения всех воркер goroutine
go func() {
wg.Wait()
close(results)
}()
// Собираем результаты
var output []int
for chunk := range results {
output = append(output, chunk...)
}
return output
}
func main() {
data := []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}
result := parallelProcess(data, 3)
fmt.Println(result) // [2 4 6 8 10 12 14 16 18 20]
}
Ключевые моменты реализации:
1. Корректное разделение на чанки
chunkSize := len(data) / workers
remainder := len(data) % workers
// Распределяем остаток по первым воркерам
// Например: 10 элементов / 3 воркера = [4, 3, 3]
for i := 0; i < workers; i++ {
end := start + chunkSize
if i < remainder {
end++ // первые 'remainder' воркеров получают +1 элемент
}
// ...
}
2. Передача параметров в горутину (избегаем гонки данных)
// НЕПРАВИЛЬНО — гонка данных на переменной start
go func() {
// start может измениться до выполнения горутины
processChunk(data[start:end], results, &wg)
}()
// ПРАВИЛЬНО — передаём явно
chunk := data[start:end]
go processChunk(chunk, results, &wg)
3. Закрытие канала через отдельную горутину
go func() {
wg.Wait()
close(results)
}()
Это позволяет главной горутине читать из канала через for range без блокировки.
4. Обработка edge cases
if workers <= 0 {
workers = 1
}
if workers > len(data) {
workers = len(data)
}
Альтернативный подход — с сохранением порядка:
Если важен порядок элементов в результате:
func parallelProcessOrdered(data []int, workers int) []int {
if workers <= 0 {
workers = 1
}
if len(data) == 0 {
return []int{}
}
if workers > len(data) {
workers = len(data)
}
// Создаём слайс для результатов заданного размера
result := make([]int, len(data))
var wg sync.WaitGroup
chunkSize := len(data) / workers
remainder := len(data) % workers
start := 0
for i := 0; i < workers; i++ {
end := start + chunkSize
if i < remainder {
end++
}
wg.Add(1)
// Передаём start для записи в правильную позицию
go func(s, e int) {
defer wg.Done()
for j := s; j < e; j++ {
result[j] = data[j] * 2
}
}(start, end)
start = end
}
wg.Wait()
return result
}
Ключевые моменты для интервью:
- Разделение слайса на непрерывные чанки с учётом остатка
- Явная передача параметров в горутины для избежания гонки данных
- Использование sync.WaitGroup для синхронизации
- Закрытие канала в отдельной горутине
- Обработка edge cases (workers <= 0, пустой слайс)
- Если важен порядок — использовать прямую запись в результирующий слайс по индексам
Вопрос 5. Что произойдёт при записи в закрытый канал в Go? Что произойдёт при чтении из закрытого канала?
Таймкод: 00:37:10
Ответ собеседния: Правильный. При записи в закрытый канал — panic. При чтении из закрытого канала — получается zero-value и второе значение false (флаг того, что канал закрыт).
Правильный ответ:
Запись в закрытый канал
При попытке записи в закрытый канал происходит runtime panic: panic: send on closed channel.
ch := make(chan int, 1)
close(ch)
ch <- 42 // panic: send on closed channel
Чтение из закрытого канала
Поведение зависит от типа чтения:
1. С проверкой второго значения (ok/comma ok idiom):
ch := make(chan int, 2)
ch <- 1
ch <- 2
close(ch)
val, ok := <-ch // val=1, ok=true
val, ok = <-ch // val=2, ok=true
val, ok = <-ch // val=0 (zero-value), ok=false
Когда канал закрыт и буфер пуст, чтение возвращает:
- Zero-value типа канала (0 для int, "" для string, nil для указателей)
- false как второй возвращаемый значение
2. Без проверки (одиночное значение):
val := <-ch // val = 0 (zero-value), если канал закрыт и пуст
3. Через range:
for val := range ch {
fmt.Println(val) // завершится автоматически после закрытия
}
Range по каналу завершается, когда канал закрыт и все значения прочитаны.
4. Небуферизованный закрытый канал:
ch := make(chan int)
close(ch)
val, ok := <-ch // val=0, ok=false — немедленно
Примеры для понимания:
package main
import "fmt"
func main() {
// Буферизованный канал с данными
ch1 := make(chan int, 3)
ch1 <- 10
ch1 <- 20
close(ch1)
// Читаем все данные + ещё раз
for i := 0; i < 4; i++ {
val, ok := <-ch1
fmt.Printf("val=%d, ok=%v\n", val, ok)
}
// val=10, ok=true
// val=20, ok=true
// val=0, ok=false
// val=0, ok=false
// Небуферизованный закрытый канал
ch2 := make(chan string)
close(ch2)
val, ok := <-ch2
fmt.Printf("val=%q, ok=%v\n", val, ok)
// val="", ok=false
}
Zero-value для разных типов:
| Тип | Zero-value |
|---|---|
| int | 0 |
| string | "" |
| bool | false |
| float64 | 0.0 |
| pointer | nil |
| struct | структура с zero-value полей |
Практические рекомендации:
1. Кто закрывает канал?
Правило: отправитель закрывает канал, никогда не получатель. Если получатель закроет канал, отправитель получит panic при следующей записи.
2. Используйте select для безопасного чтения:
select {
case val, ok := <-ch:
if !ok {
// канал закрыт
return
}
process(val)
case <-time.After(5 * time.Second):
// таймаут
}
3. sync.Once для безопасного закрытия:
var once sync.Once
closeOnce := func() {
once.Do(func() {
close(ch)
})
}
Ключевые моменты для интервью:
- Запись в закрытый канал → panic
- Чтение из закрытого канала → zero-value + false (при пустом буфере)
- Чтение из закрытого канала с данными → данные + true, пока буфер не опустеет
- Range по каналу завершается при закрытии
- Закрывать канал должен отправитель, не получатель
- Закрытие уже закрытого канала тоже вызывает panic
Вопрос 6. Если горутина-писатель заблокирована на записи в полный буферизированный канал, кто и когда её разбудит?
Таймкод: 00:37:30
Ответ собеседника: Правильный. Горутина-читатель разбудит писателя, когда прочитает значение из канала и освободит место в буфере. Внутри канала есть очередь писателей (sendq), где лежит структура sudog с указателем на горутину. При чтении вызывается метод ready, который переводит горутину-писателя в состояние runnable, и планировщик может её возобновить.
Правильный ответ:
Это вопрос на глубокое понимание внутреннего устройства каналов и планировщика Go (runtime).
Внутреннее устройство канала
Канал в runtime представлен структурой hchan:
type hchan struct {
qcount uint // количество элементов в очереди
dataqsiz uint // размер буфера
buf unsafe.Pointer // кольцевой буфер
sendx uint // индекс для записи
recvx uint // индекс для чтения
recvq waitq // очередь ожидающих читателей (sudog)
sendq waitq // очередь ожидающих писателей (sudog)
mutex mutex // мьютекс для защиты структуры
}
Механизм блокировки и пробуждения
1. Писатель блокируется:
Когда буфер полный (qcount == dataqsiz), горутина-писатель:
- Создаёт структуру
sudogс информацией о горутине и указателем на данные - Добавляет
sudogвsendq(очередь писателей) - Вызывает
gopark()— переводит себя в состояние_Gwaiting
2. Читатель пробуждает писателя:
Когда читатель забирает значение из канала:
- Проверяет
sendq— если есть ожидающие писатели - Берёт первый
sudogизsendq - Копирует данные из
sudogпрямо в буфер (или напрямую читателю, если он ждал) - Вызывает
goready(sudog.g)— переводит горутину-писателя в состояние_Grunnable - Горутина-писатель попадает в очередь планировщика
Схема работы:
Писатель хочет записать → буфер полный → gopark() (блокировка)
↓
sudog добавлен в sendq
Читатель читает → освобождает место → проверяет sendq
↓
Находит sudog → копирует данные
↓
goready() → горутина runnable
Структура sudog:
type sudog struct {
g *g // указатель на горутину
elem unsafe.Pointer // указатель на данные для передачи
next *sudog // следующий в очереди
prev *sudog // предыдущий в очереди
c *hchan // канал, на котором ожидает
}
Очередь waitq (двусвязный список):
type waitq struct {
first *sudog
last *sudog
}
Важные нюансы:
1. Прямая передача без буферизации:
Если читатель ждал до того, как писатель попытался записать (или наоборот), данные передаются напрямую из стека писателя в стек читателя — без использования буфера.
2. Выбор горутины для пробуждения:
Когда освобождается место в канале, пробуждается первая горутина из соответствующей очереди (FIFO).
3. Планировщик решает:
goready() переводит горутину в _Grunnable, но когда именно она выполнится — решает планировщик. Это может быть:
- Сразу же (на том же потоке OS)
- После завершения текущего кванта времени
- Когда будет вытеснена другая горутина
Пример для понимания:
ch := make(chan int, 2) // буфер на 2 элемента
go func() {
fmt.Println("писатель: запись 1")
ch <- 1
fmt.Println("писатель: запись 2")
ch <- 2
fmt.Println("писатель: запись 3 (заблокируется)")
ch <- 3 // блокируется здесь
fmt.Println("писатель: запись 3 завершена")
}()
time.Sleep(time.Second) // даём писателю заблокироваться
fmt.Println("читатель: читаем")
val := <-ch // пробуждает писателя
fmt.Printf("читатель: получили %d\n", val)
Ключевые моменты для интервью:
- Блокированная горутина хранится в
sudogвнутриsendq/recvqканала gopark()переводит горутину в состояние ожиданияgoready()переводит горутину в состояние_Grunnable- Данные могут передаваться напрямую между горутинами (минуя буфер)
- Планировщик определяет, когда именно возобновится горутина
- Очереди
sendqиrecvqработают по принципу FIFO
Вопрос 7. Что произойдёт, если внутри горутины выполнить блокирующий системный вызов (например, чтение из файла)? Как планировщик об этом узнает?
Таймкод: 00:39:00
Ответ собеседника: Неполный. Блокирующий системный вызов блокирует горутина. Через ~10 мс, если вызов не завершился, системный поток (M) отсоединяется от P. P берёт другой M. Когда системный вызов завершится, горутина вернётся в очередь выполнения. На вопрос как именно планировщик узнаёт о завершении системного вызова — кандидат не смог ответить, предположил что-то про опрос M.
Правильный ответ:
Это вопрос на понимание модели планирования Go (GPM) и работы с системными вызовами.
Модель GPM:
- G (goroutine) — легковесная горутина
- P (processor) — логический процессор, управляет очередью горутин
- M (machine) — системный поток OS
Что происходит при блокирующем системном вызове:
1. Горутина блокируется:
Когда горутина делает блокирующий syscall (read, write, и т.д.):
- M (системный поток) блокируется на этом вызове
- G не может продолжить выполнение
2. P отсоединяется от M:
Планировщик обнаруживает блокировку и:
- P отсоединяется от текущего M
- P ищет или создаёт новый M для продолжения работы
- Другие горутины из очереди P продолжают выполняться
3. Как планировщик узнаёт о завершении syscall:
Здесь ключевой механизм — netpoller и sysmon:
netpoller — это компонент runtime, который использует механизмы OS для неблокирующего опроса:
- Linux: epoll
- macOS/BSD: kqueue
- Windows: IOCP (I/O Completion Ports)
sysmon — специальная системная горутина, которая:
- Работает в отдельном M (не привязана к P)
- Просыпается каждые ~10 мс (или когда нет других горутин)
- Проверяет состояние заблокированных M и syscall
Механизм пробуждения:
Блокирующий syscall → M блокируется → P отсоединяется
↓
sysmon периодически опрашивает
завершённые syscall через netpoller
↓
syscall завершился → G помещается
в глобальную очередь runqueue
↓
Любой P может подобрать G
Важные нюансы:
1. Не все syscall блокируют M:
Go runtime старается использовать неблокирующие операции:
- Операции с файлами в Linux — блокирующие (нет полноценного AIO)
- Сетевые операции — через netpoller (не блокируют M)
- Операции с каналами — через runtime
2. Таймер sysmon:
// В runtime есть системный монитор
func sysmon() {
for {
// Проверка каждые ~10 мс
usleep(10000)
// Опрос netpoller на завершённые операции
netpoll(0) // неблокирующий вызов
// Перехват давно работающих горутин (preemption)
retake(now)
// Принудительный GC если нужно
if shouldGC {
gcStart()
}
}
}
3. netpoll возвращает список готовых горутин:
// Неблокирующий опрос готовых к работе горутин
func netpoll(delay int64) gList {
// Вызывает epoll_wait/kqueue с нулевым таймаутом
// Возвращает список G, готовых к выполнению
}
Пример:
go func() {
// Блокирующий syscall — чтение файла
data, _ := os.ReadFile("/tmp/largefile")
fmt.Println("чтение завершено")
}()
// Планировщик:
// 1. M блокируется на ReadFile
// 2. P отсоединяется от M, берёт другой M
// 3. sysmon через netpoll обнаруживает завершение чтения
// 4. Горутина помещается в runqueue
// 5. Любой P подбирает её и возобновляет выполнение
Ключевые моменты для интервью:
- Блокирующий syscall блокирует M, но не P
- P отсоединяется от заблокированного M и продолжает работу
- sysmon — системная горутина, которая периодически проверяет состояние
- netpoller использует epoll/kqueue/IOCP для неблокирующего опроса
- Завершённые syscall обнаруживаются через netpoll
- Горутина помещается в глобальную runqueue и может быть подобрана любым P
- Сетевые операции в Go не блокируют M благодаря netpoller
- Операции с файлами в Linux — блокирующие (это ограничение OS)
Вопрос 8. Если все M (системные потоки) заняты блокирующими вызовами и создать новую M невозможно, что будет с P и его локальной очередью горутин?
Таймкод: 00:41:16
Ответ собеседника: Правильный. P будет простаивать и ждать, когда какой-то M освободится. Горутины в локальной очереди P останутся в состоянии runnable, но не смогут выполняться. Теоретически может прийти другой P и сделать work-stealing — забрать половину горутин из очереди.
Правильный ответ:
Ограничение на количество M
В Go runtime есть лимит на количество системных потоков:
// runtime2.go
var (
allm *m // список всех M
maxmcount int32 = 10000 // максимум 10000 потоков
)
По умолчанию максимум 10000 M, но это можно изменить через runtime/debug.SetMaxThreads().
Что происходит при исчерпании лимита M:
1. Блокирующий syscall без свободных M:
G делает блокирующий syscall
↓
Нет свободного M
↓
Достигнут maxmcount
↓
Горутина остаётся на текущем M
P отсоединяется от M
↓
P остаётся без M
↓
Горутины в локальной очереди P не выполняются
2. Поведение P без M:
Когда P теряет M (из-за блокирующего syscall):
- P переходит в состояние
_Pidle - Помещается в глобальный список
allp - Локальная очередь горутин P остаётся нетронутой
- Горутины в очереди остаются в состоянии
_Grunnable
3. Work-stealing от других P:
Другие P могут выполнять work-stealing:
// proc.go — упрощённая логика
func stealWork(p *p) *g {
// Пробуем украсть у случайного P
for i := 0; i < 4; i++ {
p2 := allp[fastrand() % GOMAXPROCS]
if p2 == p { continue }
// Берём половину горутин из очереди
if n := p2.runqtail - p2.runqhead; n > 0 {
return runqsteal(p, p2, n/2)
}
}
return nil
}
Однако work-stealing работает только для P, которые имеют M. P без M не может сам инициировать work-stealing.
4. Что происходит с заблокированными M:
M1 ──блокировка──→ ждёт завершения syscall
M2 ──блокировка──→ ждёт завершения syscall
...
M10000 ─блокировка─→ ждёт завершения syscall
P1 без M → простаивает, горутины ждут
P2 без M → простаивает, горутины ждут
5. Разрешение deadlock:
Когда любой syscall завершается:
- M возвращается к своему P (или ищет новый P)
- P получает M и продолжает выполнение горутин
- Если у P не было очереди — делает work-stealing
Пример проблемы:
// Можем создать deadlock, если все M заблокированы
func main() {
runtime.GOMAXPROCS(1) // только 1 P
// Блокируем единственный M
syscall.Select(nil, nil, nil, &syscall.Timeval{Sec: 100})
// Эта горутина не сможет выполниться,
// потому что P без M, а свободных M нет
go func() {
fmt.Println("никогда не выполнится")
}()
time.Sleep(time.Hour)
}
Ключевые моменты для интервью:
- Лимит M по умолчанию — 10000
- P без M переходит в состояние
_Pidle - Горутины в очереди P остаются runnable, но не выполняются
- Work-stealing может забрать горутины у idle P
- Проблема возникает при массовых блокирующих syscall (например, много файловых операций)
- Сетевые операции через netpoller не блокируют M — это главное преимущество Go
Практические рекомендации:
- Избегать массовых блокирующих syscall в горутинах
- Использовать
runtime.LockOSThread()только когда необходимо - Контролировать количество одновременных файловых операций
- Использовать worker pools для ограничения параллелизма
Вопрос 9. Провести code review функции processPayment: найти проблемы в коде, объяснить их и предложить исправления.
Таймкод: 00:43:16
Ответ собеседника: Неполный. Кандидат нашёл несколько проблем: 1) Ошибки при старте транзакции лучше выносить в переменную для сравнения. 2) При чтении баланса в транзакции нужно использовать SELECT FOR UPDATE (пессимистичная блокировка) для предотвращения race condition. 3) Запись в Kafka внутри транзакции БД — проблема: транзакция держится открытой во время внешнего вызова, что увеличивает время удержания блокировки и количество открытых транзакций. Предложил паттерн Outbox — писать сообщение в отдельную таблицу в рамках той же транзакции, а отдельный воркер потом отправляет в Kafka. 4) Коммиты и роллбэки лучше делать через defer. 5) Хранение денег во float64 — проблема точности, лучше использовать int (копейки) или decimal. 6) Глобальный мьютекс в функции убивает конкурентность. Не упомянул: rollback после commit вызовет ошибку.
Правильный ответ:
Полный список проблем и исправлений:
1. Rollback после commit — ошибка
// ПРОБЛЕМА
if err := tx.Commit(); err != nil {
tx.Rollback() // после commit вызовет ошибка "already committed"
}
// ИСПРАВЛЕНИЕ
if err := tx.Commit(); err != nil {
// rollback не нужен — транзакция уже завершилась с ошибкой
return err
}
После Commit() транзакция уже завершена. Вызов Rollback() вернёт ошибку ErrTxDone.
2. Хранение денег во float64 — потеря точности
// ПРОБЛЕМА
var balance float64 // 0.1 + 0.2 != 0.3!
// ИСПРАВЛЕНИЕ — используем целые числа (копейки/центы)
type Money int64 // в копейках
const (
KopecksPerRuble = 100
)
func NewMoney(rubles int64, kopecks int64) Money {
return Money(rubles*KopecksPerRuble + kopecks)
}
// Или используем decimal библиотеку
import "github.com/shopspring/decimal"
type Account struct {
Balance decimal.Decimal
}
3. Глобальный мьютекс убивает конкурентность
// ПРОБЛЕМА
var mu sync.Mutex // глобальный — блокирует ВСЕ платежи
// ИСПРАВЛЕНИЕ — блокировка на уровне аккаунта
type AccountService struct {
db *sql.DB
}
func (s *AccountService) ProcessPayment(ctx context.Context, fromID, toID int64, amount Money) error {
// Блокируем конкретные аккаунты, а не всё
// Используем SELECT FOR UPDATE в транзакции
}
4. SELECT FOR UPDATE для предотвращения race condition
// ПРОБЛЕМА — обычный SELECT не блокирует строку
var balance int64
err := tx.QueryRow("SELECT balance FROM accounts WHERE id = $1", fromID).Scan(&balance)
// ИСПРАВЛЕНИЕ — пессимистичная блокировка
err := tx.QueryRow(
"SELECT balance FROM accounts WHERE id = $1 FOR UPDATE",
fromID,
).Scan(&balance)
5. Запись в Kafka внутри транзакции БД
// ПРОБЛЕМА
func processPayment() error {
tx, _ := db.Begin()
// ... операции с БД ...
kafka.Send(...) // внешний вызов внутри транзакции!
tx.Commit() // транзакция держится всё время отправки в Kafka
}
// ИСПРАВЛЕНИЕ — паттерн Transactional Outbox
func processPayment() error {
tx, _ := db.Begin()
// ... операции с БД ...
// Пишем в outbox таблицу в той же транзакции
_, err := tx.Exec(
"INSERT INTO outbox (topic, payload, created_at) VALUES ($1, $2, NOW())",
"payments", payload,
)
tx.Commit() // быстро закрываем транзакцию
return err
}
// Отдельный воркер читает outbox и отправляет в Kafka
func outboxWorker() {
for {
rows := db.Query("SELECT * FROM outbox WHERE processed = false")
for rows.Next() {
// отправляем в Kafka
kafka.Send(...)
// помечаем как обработанное
db.Exec("UPDATE outbox SET processed = true WHERE id = ?", id)
}
time.Sleep(time.Second)
}
}
6. Обработка ошибок — defer для rollback
// ПРОБЛЕМА — много повторяющегося кода
if err != nil {
tx.Rollback()
return err
}
// ИСПРАВЛЕНИЕ
func (s *AccountService) ProcessPayment(ctx context.Context, fromID, toID int64, amount Money) error {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("begin transaction: %w", err)
}
defer func() {
if err != nil {
if rbErr := tx.Rollback(); rbErr != nil && rbErr != sql.ErrTxDone {
log.Printf("rollback error: %v", rbErr)
}
}
}()
// ... логика ...
if err = tx.Commit(); err != nil {
return fmt.Errorf("commit transaction: %w", err)
}
return nil
}
7. Отсутствие контекста (context)
// ПРОБЛЕМА — нет возможности отменить операцию
func processPayment(fromID, toID int64, amount int64) error
// ИСПРАВЛЕНИЕ
func (s *AccountService) ProcessPayment(ctx context.Context, fromID, toID int64, amount Money) error {
tx, err := s.db.BeginTx(ctx, &sql.TxOptions{
Isolation: sql.LevelSerializable, // или RepeatableRead
})
// ...
}
8. Нет проверки на отрицательный баланс
// ПРОБЛЕМА — можно уйти в минус
balance -= amount
// ИСПРАВЛЕНИЕ
if balance < amount {
return ErrInsufficientFunds
}
balance -= amount
// Или на уровне БД:
_, err := tx.Exec(`
UPDATE accounts
SET balance = balance - $1
WHERE id = $2 AND balance >= $1
`, amount, fromID)
9. Нет idempotency key — дублирование платежей
// ИСПРАВЛЕНИЕ
type PaymentRequest struct {
IDempotencyKey string
FromID int64
ToID int64
Amount Money
}
func (s *AccountService) ProcessPayment(ctx context.Context, req PaymentRequest) error {
// Проверяем, был ли уже такой платёж
var exists bool
err := s.db.QueryRowContext(ctx,
"SELECT EXISTS(SELECT 1 FROM payments WHERE idempotency_key = $1)",
req.IDempotencyKey,
).Scan(&exists)
if exists {
return nil // уже обработан
}
// ...
}
10. Нет логирования и метрик
// ИСПРАВЛЕНИЕ
func (s *AccountService) ProcessPayment(ctx context.Context, req PaymentRequest) error {
start := time.Now()
defer func() {
duration := time.Since(start)
metrics.PaymentDuration.Observe(duration.Seconds())
}()
log.Printf("processing payment: from=%d to=%d amount=%d", req.FromID, req.ToID, req.Amount)
// ... логика ...
if err != nil {
metrics.PaymentErrors.Inc()
log.Printf("payment failed: %v", err)
return err
}
metrics.PaymentSuccess.Inc()
return nil
}
Исправленная версия функции:
type Money int64
type PaymentRequest struct {
IDempotencyKey string
FromID int64
ToID int64
Amount Money
}
type AccountService struct {
db *sql.DB
}
var ErrInsufficientFunds = errors.New("insufficient funds")
func (s *AccountService) ProcessPayment(ctx context.Context, req PaymentRequest) error {
tx, err := s.db.BeginTx(ctx, &sql.TxOptions{
Isolation: sql.LevelSerializable,
})
if err != nil {
return fmt.Errorf("begin transaction: %w", err)
}
defer func() {
if err != nil {
if rbErr := tx.Rollback(); rbErr != nil && rbErr != sql.ErrTxDone {
log.Printf("rollback error: %v", rbErr)
}
}
}()
// Блокируем оба аккаунта в определённом порядке для предотвращения deadlock
firstID, secondID := req.FromID, req.ToID
if firstID > secondID {
firstID, secondID = secondID, firstID
}
var balance1, balance2 Money
err = tx.QueryRowContext(ctx,
"SELECT balance FROM accounts WHERE id = $1 FOR UPDATE",
firstID,
).Scan(&balance1)
if err != nil {
return fmt.Errorf("lock account %d: %w", firstID, err)
}
err = tx.QueryRowContext(ctx,
"SELECT balance FROM accounts WHERE id = $1 FOR UPDATE",
secondID,
).Scan(&balance2)
if err != nil {
return fmt.Errorf("lock account %d: %w", secondID, err)
}
// Проверяем баланс отправителя
if req.FromID == firstID && balance1 < req.Amount {
return ErrInsufficientFunds
}
if req.FromID == secondID && balance2 < req.Amount {
return ErrInsufficientFunds
}
// Обновляем балансы
_, err = tx.ExecContext(ctx,
"UPDATE accounts SET balance = balance - $1 WHERE id = $2",
req.Amount, req.FromID,
)
if err != nil {
return fmt.Errorf("debit account: %w", err)
}
_, err = tx.ExecContext(ctx,
"UPDATE accounts SET balance = balance + $1 WHERE id = $2",
req.Amount, req.ToID,
)
if err != nil {
return fmt.Errorf("credit account: %w", err)
}
// Записываем в outbox вместо прямой отправки в Kafka
_, err = tx.ExecContext(ctx,
`INSERT INTO outbox (idempotency_key, topic, payload, created_at)
VALUES ($1, $2, $3, NOW())`,
req.IDempotencyKey, "payments", req,
)
if err != nil {
return fmt.Errorf("insert outbox: %w", err)
}
if err = tx.Commit(); err != nil {
return fmt.Errorf("commit: %w", err)
}
return nil
}
Ключевые моменты для code review:
- Rollback после commit — ошибка
- float64 для денег — потеря точности
- Глобальный мьютекс — убивает конкурентность
- SELECT FOR UPDATE — для предотвращения race condition
- Внешние вызовы внутри транзакции — паттерн Outbox
- defer для rollback — чистый код
- Context для отмены и таймаутов
- Проверка баланса — на уровне БД
- Idempotency key — защита от дублирования
- Логирование и метрики — наблюдаемость
