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

Собеседование на Go-разработчика в 2026 году

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

Сегодня мы разберём мок-собеседование в формате стрима, где кандидат Гриша, разработчик с опытом работы на 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).

Алгоритм:

  1. Создать map из элементов первого слайса (ключи — элементы, значения — true)
  2. Итерироваться по второму слайсу
  3. Если элемент найден в map — добавить в результат и удалить из map (чтобы избежать дубликатов)
  4. Вернуть результат

Реализация на 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 позволяет оптимизировать работу с памятью:

  1. Амортизированная стоимость append. При добавлении элементов через append, если capacity не исчерпана, новый массив не выделяется. Когда capacity заканчивается, Go выделяет новый массив (обычно с удвоением размера) и копирует данные.

  2. Предсказуемая производительность. Зная 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)

Это позволяет сбалансировать использование памяти и количество реаллокаций.

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

  1. Предвыделение памяти:
// Плохо — множественные реаллокации
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)
}
  1. Создание подслайса с сохранением ссылки на оригинал:
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
  1. Копирование слайса:
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
int0
string""
boolfalse
float640.0
pointernil
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

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

  1. Избегать массовых блокирующих syscall в горутинах
  2. Использовать runtime.LockOSThread() только когда необходимо
  3. Контролировать количество одновременных файловых операций
  4. Использовать 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 — защита от дублирования
  • Логирование и метрики — наблюдаемость