Разбор банка вопросов

Что спрашивают

Семь практических задач и девять теоретических блоков. Интервьюер меняет условия и смотрит, где кандидат перестает понимать собственное решение.

Теория

  • Конкурентность и рантайм 5
  • Язык и память 2
  • Тесты и профилирование 2

Практика

ЗадачаКонкурентностьСеть и I/OОтмена
Указателине входит в задачуне входит в задачуне входит в задачу
Горутинывходит в задачуне входит в задачуне входит в задачу
Сниппетвходит в задачувходит в задачувходит в задачу
Погодавходит в задачувходит в задачувходит в задачу
10 000 запросоввходит в задачувходит в задачуне входит в задачу
URLвходит в задачувходит в задачувходит в задачу
Тайм-аутвходит в задачувходит в задачувходит в задачу

Практические задачи

01УказателиНебольшая программа проверяет, понимает ли кандидат разницу между изменением объекта и заменой локальной копии указателя.

Условие

  • Определить, что напечатает программа, и объяснить результат.
  • Изменить программу так, чтобы после вызова changeName имя стало Alice.
  • Отдельно объяснить замену самого указателя у вызывающей стороны и изменение поля объекта.
Исходная заготовка
package main

import "fmt"

type Person struct {
    Name string
}

func changeName(person *Person) {
    person = &Person{Name: "Alice"}
}

func main() {
    person := &Person{Name: "Bob"}
    fmt.Println(person.Name)
    changeName(person)
    fmt.Println(person.Name)
}

Куда раскручивают задачу

  • Передачу аргументов по значению.
  • Копирование значения указателя и общую память за ним.
  • Проверку nil и границы изменения данных через API.

Что придется решить

  • Изменение существующего объекта проще и обычно понятнее для API.
  • Замена указателя через **T нужна редко и должна быть явно оправдана контрактом.
  • nil проверяется до разыменования, а ошибка становится частью контракта функции.

Разбор

В Go нет передачи аргумента по ссылке. В changeName попадает копия адреса. Присваивание нового адреса меняет только локальную копию, поэтому исходный person продолжает указывать на Bob.

Если нужно поменять имя существующего объекта, достаточно person.Name = name. Если контракт требует заменить сам указатель у вызывающей стороны, функция принимает **Person и записывает новое значение через *target.

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

Решение

pointers.go
package pointers

type Person struct {
	Name string
}

func ChangeName(person *Person) {
	person.Name = "Alice"
}

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

  • Ожидать, что person = &Person{...} заменит указатель у вызывающей стороны.
  • Объяснять результат фразой про ссылочный тип без модели копирования.
  • Не разделять мутацию объекта и замену владельца ссылки.

Middle

  • Правильно предсказывает Bob, Bob.
  • Исправляет функцию через person.Name = name.
  • Понимает, что аргумент функции копируется.

Middle+

  • Разделяет два контракта: изменить существующий объект и заменить указатель.
  • Показывает вариант с **Person и проверяет nil.
  • Объясняет, кто владеет данными и почему API остается понятным.

Senior

  • Обсуждает, нужен ли вообще изменяемый указатель в публичном API.
  • Выбирает возврат нового значения, мутацию или **T по контракту, а не по привычке.
  • Называет последствия для алиасов, конкурентного доступа и тестируемости.
02Горутины в циклеНужно разобрать программу, которая ищет максимальное четное число, и довести ее до детерминированного результата без гонок.

Условие

  • Объяснить, что может напечатать исходная программа.
  • Исправить завершение main, работу с переменной цикла и конкурентный доступ к max.
  • Назвать инструменты, которыми можно доказать наличие или отсутствие проблем.
Исходная заготовка
package main

import "fmt"

func main() {
    var max int

    for i := 1000; i > 0; i-- {
        go func() {
            if i%2 == 0 && i > max {
                max = i
            }
        }()
    }

    fmt.Printf("Maximum is %d", max)
}

Куда раскручивают задачу

  • Жизненный цикл горутины и ожидание завершения.
  • Гонку данных в составной операции чтения, сравнения и записи.
  • WaitGroup, канал, мьютекс, атомарные операции и выбор подходящего примитива.
  • Изменение семантики переменных цикла в модулях Go 1.22 и новее.

Что придется решить

  • Мьютекс подходит, если сравнение и присваивание находятся в одной критической секции.
  • Вариант с одним владельцем состояния и каналом тоже корректен, но это уже другое решение задачи.
  • Атомарная операция не склеивает составной инвариант автоматически. Нужен цикл CAS или другая модель владения состоянием.

Разбор

Исходный main не ждет горутины и может завершиться до любого присваивания. Несколько горутин одновременно читают и пишут max без отношения happens-before, то есть без гарантии порядка и видимости изменений. Это гонка данных, даже если один запуск выглядит правильным.

В старых модулях замыкание захватывало одну переменную цикла. Начиная с модулей Go 1.22 переменная, объявленная через :=, создается заново на каждой итерации. Явная передача значения все равно ясно показывает, какими данными владеет горутина, и сохраняет пример переносимым.

Решение остается близким к исходнику: каждая итерация запускает горутину, main ждет их через WaitGroup, а проверка и запись max выполняются под одним Mutex. Если защитить только присваивание, гонка останется на чтении max.

Решение

maxeven.go
package maxeven

import "sync"

func Find(upper int) int {
	var max int
	var mu sync.Mutex
	var wg sync.WaitGroup

	for i := upper; i > 0; i-- {
		wg.Add(1)
		go func(i int) {
			defer wg.Done()

			// Проверку и запись max защищаем одним мьютексом.
			mu.Lock()
			if i%2 == 0 && i > max {
				max = i
			}
			mu.Unlock()
		}(i)
	}

	wg.Wait()
	return max
}

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

  • Добавить go перед вызовом и забыть дождаться завершения.
  • Лочить только присваивание, оставив чтение max конкурентным.
  • Вызывать WaitGroup.Add внутри уже запущенной горутины.
  • Повторять старую ловушку с переменной цикла как безусловный факт для Go 1.22.

Middle

  • Находит отсутствие ожидания и гонку данных.
  • Ставит WaitGroup и защищает весь инвариант мьютексом.
  • Проверяет решение через go test -race.

Middle+

  • Сначала определяет владельца состояния, потом выбирает примитив.
  • Предлагает отдельную горутину для сбора результата и объясняет закрытие канала.
  • Знает актуальную семантику переменных цикла и границу версии модуля.

Senior

  • Замечает, что горутина на каждое число подходит только для учебного примера.
  • Сравнивает мьютекс, CAS и одного владельца состояния по сложности и конкуренции за блокировку.
  • Для большого входа предлагает пул обработчиков с ограниченным размером и способ его настроить.
03Сборка сниппетаОписание и цена приходят из независимых сервисов. Их нужно получить параллельно, преобразовать и собрать в один результат.

Условие

  • Получить описание товара и форматировать его.
  • Получить цену в долларах и перевести ее в рубли.
  • Запустить независимые I/O операции параллельно и дождаться обеих.
  • Обработать ошибку, тайм-аут и решение продукта о частичном результате.

Куда раскручивают задачу

  • Параллельную композицию внешних зависимостей.
  • Передачу контекста и отмену соседней работы при ошибке.
  • Оборачивание ошибок с названием операции.
  • Разницу между техническим механизмом и продуктовым решением о частичном результате.

Что придется решить

  • Сразу завершать запрос с ошибкой или возвращать частичный сниппет решает продуктовый контракт.
  • Тайм-аут одной зависимости должен укладываться в общее допустимое время ответа.
  • Повторный запрос допустим только для подходящих ошибок и идемпотентных операций.

Разбор

При последовательном выполнении задержки двух независимых вызовов складываются. В решении из методички описание и цена считаются в двух горутинах, а WaitGroup не дает вернуть сниппет раньше времени.

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

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

Решение

snippet.go
package snippet

import "sync"

func BuildSnippet(itemID int) Snippet {
	var wg sync.WaitGroup
	wg.Add(2)

	var description string
	go func() {
		defer wg.Done()
		rawDescription := itemDescription(itemID)
		description = prettify(rawDescription)
	}()

	var priceRUB float64
	go func() {
		defer wg.Done()
		priceUSD := itemPrice(itemID)
		priceRUB = priceToRUB(priceUSD)
	}()

	wg.Wait()
	return Snippet{
		Price:       priceRUB,
		Description: description,
	}
}

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

  • Запустить две горутины без отмены и потерять одну ошибку.
  • Поставить отдельный большой тайм-аут на каждый вызов.
  • Повторять все запросы подряд и усилить аварию внешнего сервиса.
  • Считать, что контекст остановит зависимость, которая его игнорирует.

Middle

  • Запускает независимые вызовы параллельно и ждет оба.
  • Проверяет ошибки и не создает гонку данных.
  • Возвращает понятный полный результат.

Middle+

  • Проводит контекст в обе зависимости.
  • Отменяет соседнюю работу при первой ошибке.
  • Не оставляет отправителя заблокированным после раннего возврата.

Senior

  • Уточняет продуктовый контракт частичного результата.
  • Учитывает общее время ответа, предел повторных запросов и режим деградации.
  • Добавляет метрики по каждой зависимости и оборачивает ошибки с названием операции.
04Прогноз погоды при 10 000 RPSМедленная функция считает прогноз около секунды, а HTTP ручка должна выдерживать 10 000 запросов в секунду и учитывать city_id.

Условие

  • Сначала реализовать GET /weather без локаций поверх секундного расчета.
  • Не вызывать расчет на каждый запрос: держать последнее значение в памяти и обновлять его по таймеру.
  • Затем добавить city_id и обсудить кэш по городам, протухание, прогрев и одновременный промах.
  • Разобрать HTTP-ответ и завершение фонового обновления.
Исходная заготовка
package main

func aiWeatherForecast() int {
    time.Sleep(time.Second)
    return rand.Intn(70) - 30
}

func main() {
    http.HandleFunc("/weather", func(w http.ResponseWriter, r *http.Request) {
        fmt.Fprintf(w, "{\"temperature\":%d}\n", aiWeatherForecast())
    })
    if err := http.ListenAndServe(":3333", nil); err != nil {
        panic(err)
    }
}

Куда раскручивают задачу

  • Модель работы под высокой нагрузкой выходит за пределы map с мьютексом.
  • TTL кэша, лавину одинаковых запросов при промахе, объединение запросов по ключу и несколько реплик.
  • Отмену запроса, тайм-аут внешнего сервиса и корректное завершение работы.
  • Семантику HTTP, наблюдаемость и путь запроса через сеть.

Что придется решить

  • TTL зависит от допустимой устарелости прогноза, а не от красивого круглого числа.
  • Объединение запросов по ключу не должно позволять одному отмененному клиенту прервать загрузку для всех.
  • Если продукт допускает старый прогноз, сервис может вернуть его и обновить кэш в фоне.
  • Мьютекс или RWMutex выбирают по измеренной конкуренции за блокировку, а не по названию.

Разбор

Если каждый запрос запускает секундный расчет, незавершенная работа быстро копится. В исходном Go-решении прогноз считается заранее, хранится в памяти и обновляется раз в секунду.

RWMutex отделяет редкую запись нового прогноза от частых чтений HTTP-обработчика. В листинге используется Ticker: его проще остановить вместе с контекстом, чем создавать новый time.After на каждом проходе цикла.

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

Это локальный кэш в памяти одной реплики. Для нескольких реплик нужно решить, где хранить общий кэш, как разнести момент истечения TTL, как не допустить лавину между процессами и когда разрешено вернуть устаревшее значение.

Решение

service.go
package weather

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

type Cache struct {
	mu          sync.RWMutex
	temperature int
}

func (c *Cache) Update(forecast func() int) {
	value := forecast()
	c.mu.Lock()
	c.temperature = value
	c.mu.Unlock()
}

func (c *Cache) Temperature() int {
	c.mu.RLock()
	defer c.mu.RUnlock()
	return c.temperature
}

func (c *Cache) Run(ctx context.Context, interval time.Duration, forecast func() int) {
	c.Update(forecast)

	ticker := time.NewTicker(interval)
	defer ticker.Stop()
	for {
		select {
		case <-ctx.Done():
			return
		case <-ticker.C:
			c.Update(forecast)
		}
	}
}
handler.go
package weather

import (
	"fmt"
	"net/http"
)

func Handler(cache *Cache) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
		w.Header().Set("Content-Type", "application/json; charset=utf-8")
		fmt.Fprintf(w, "{\"temperature\":%d}\n", cache.Temperature())
	})
}

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

  • Положить значение в map без синхронизации.
  • Кэшировать без TTL и стратегии инвалидации.
  • Допустить отдельный вызов внешнего сервиса на каждый одновременный промах кэша.
  • Забыть о нескольких репликах и холодном старте.
  • Запустить сервер без корректного Shutdown и ожидания фоновой работы.

Middle

  • Предлагает map с TTL и защищает конкурентный доступ к кэшу.
  • Корректно парсит вход и возвращает HTTP ошибки.
  • Проводит контекст HTTP-запроса во внешний сервис.

Middle+

  • Объединяет одновременные запросы по одному city_id.
  • Разделяет отмену клиента и ограниченное время общей загрузки.
  • Ограничивает прогрев и корректно завершает сервис.

Senior

  • Разбирает холодный старт, массовое протухание и несколько реплик.
  • Решает, когда вернуть старые данные, как разнести TTL и где ограничить поток запросов с учетом SLO.
  • Планирует нагрузочный тест и метрики: долю попаданий в кэш, число незавершенных запросов, задержки, ошибки и загрузку ресурсов.
0510 000 сетевых операцийПоследовательная программа выполняет 10 000 имитированных I/O операций и увеличивает общий счетчик. Нужно ускорить ее без гонки и без бесконтрольной нагрузки.

Условие

  • Предсказать результат и длительность последовательного варианта.
  • Распараллелить операции и безопасно собрать count.
  • Объяснить, почему настоящий сетевой запрос нельзя просто запустить в 10 000 горутинах без лимита.
Исходная заготовка
package main

import (
    "fmt"
    "time"
)

const numRequests = 10000

var count int

func networkRequest() {
    time.Sleep(time.Millisecond)
    count++
}

func main() {
    for i := 0; i < numRequests; i++ {
        networkRequest()
    }
    fmt.Println(count)
}

Куда раскручивают задачу

  • Разницу между вычислениями на CPU и операциями ввода-вывода.
  • WaitGroup, атомарные операции или сбор результата одной горутиной.
  • Пул обработчиков, ограничение входящего потока и лимиты файловых дескрипторов.

Что придется решить

  • В учебном варианте достаточно WaitGroup и безопасного счетчика.
  • Для настоящих запросов число одновременных операций ограничивает пул обработчиков.
  • Контекст, сбор ошибок и статистика появляются после того, как меняется контракт networkRequest.

Разбор

Последовательный вариант печатает 10000 и тратит примерно сумму всех задержек. Если просто добавить горутины, придется дождаться их завершения и синхронизировать доступ к count.

Базовое решение следует подсказкам из методички: запускает горутину на каждый учебный запрос, ждет все через WaitGroup и считает завершения атомарно.

С настоящей сетью такой код нельзя переносить один в один. Десять тысяч одновременных запросов упрутся в сокеты, файловые дескрипторы, память и лимиты соседнего сервиса. Тут уже нужен пул обработчиков с измеренным лимитом.

Решение

networkbatch.go
package networkbatch

import (
	"sync"
	"sync/atomic"
)

func Run(total int, networkRequest func()) int64 {
	var count atomic.Int64
	var wg sync.WaitGroup
	wg.Add(total)

	for range total {
		go func() {
			defer wg.Done()
			networkRequest()
			count.Add(1)
		}()
	}

	wg.Wait()
	return count.Load()
}

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

  • Обещать фиксированные 1 или 2 мс без измерения окружения.
  • Запустить 10 000 реальных запросов одновременно.
  • Исправить ожидание, но оставить гонку данных при изменении count.
  • Подбирать число одновременных запросов по GOMAXPROCS.

Middle

  • Добавляет ожидание и безопасный счетчик.
  • Объясняет, почему I/O можно перекрывать.
  • Проверяет код детектором гонок.

Middle+

  • Сразу ставит пул обработчиков и проводит контекст.
  • Учитывает файловые дескрипторы, сокеты, пул соединений и лимиты внешнего сервиса.
  • Возвращает статистику и агрегированные ошибки.

Senior

  • Выбирает лимит по измерениям и SLO.
  • Обсуждает ограничение входящего потока, частоты запросов и лавину повторов.
  • Описывает, как менять лимит по метрикам загрузки системы.
06Параллельные URLНужно параллельно получить HTTP статусы сначала для двух, затем для произвольного числа адресов.

Условие

  • Выполнить два HTTP-запроса параллельно и получить статус каждого ответа.
  • Обобщить тот же код на список URL, не меняя базовую схему с WaitGroup.
  • Провести контекст в запрос и корректно закрыть тело ответа.
  • Объяснить keep-alive, Transport, завершение работы и ошибки удаленного сервера.

Куда раскручивают задачу

  • HTTP-запрос с контекстом и общий http.Client.
  • Чтение и закрытие тела HTTP-ответа.
  • Пул соединений и настройки Transport.
  • Корректное завершение работы, Circuit Breaker и диагностику Linux.

Что придется решить

  • Общий тайм-аут задает вызывающая сторона, а тайм-ауты клиента защищают отдельные фазы запроса.
  • MaxIdleConns и MaxIdleConnsPerHost настраивают под ожидаемое число параллельных запросов.
  • Circuit Breaker и повторные попытки требуют классификации ошибок и идемпотентности.

Разбор

Оригинальный пример запускает две горутины и ждет их через WaitGroup. В решении сохранена эта схема, только одинаковый код вынесен в функцию, а результаты записываются по индексам входного списка.

Один http.Client переиспользуется для всех запросов. Тело успешного ответа закрывается, а контекст доходит до net/http.

Ограничение параллелизма нужно, когда адресов уже сотни. Это следующий этап задачи; для двух URL пул обработчиков только мешает увидеть базовую механику.

Решение

parallelurls.go
package parallelurls

import (
	"context"
	"net/http"
	"sync"
)

type Result struct {
	URL    string
	Status string
	Err    error
}

func FetchStatuses(ctx context.Context, client *http.Client, urls []string) []Result {
	if client == nil {
		client = http.DefaultClient
	}

	results := make([]Result, len(urls))
	var wg sync.WaitGroup
	wg.Add(len(urls))
	for index, url := range urls {
		go func(index int, url string) {
			defer wg.Done()
			results[index] = fetch(ctx, client, url)
		}(index, url)
	}
	wg.Wait()
	return results
}

func fetch(ctx context.Context, client *http.Client, url string) Result {
	result := Result{URL: url}
	request, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
	if err != nil {
		result.Err = err
		return result
	}

	response, err := client.Do(request)
	if err != nil {
		result.Err = err
		return result
	}
	defer response.Body.Close()

	result.Status = response.Status
	return result
}

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

  • Делать запросы последовательно.
  • Вызывать WaitGroup.Add внутри горутины.
  • Создавать новый http.Client или Transport на каждый запрос.
  • Не закрывать тело ответа.
  • Не ограничивать параллелизм при сотнях URL.

Middle

  • Запускает запросы параллельно и проверяет ошибки.
  • Ждет завершения и закрывает тело ответа.
  • Передает контекст в HTTP-запрос.

Middle+

  • Поддерживает любое число URL с лимитом параллелизма.
  • Переиспользует http.Client и Transport.
  • Сохраняет порядок, обрабатывает отмену и частичный сбой.

Senior

  • Настраивает пул соединений по нагрузке и лимитам внешнего сервиса.
  • Разбирает DNS, TCP, TLS, keep-alive и сценарии отказа.
  • Связывает повторы, Circuit Breaker и метрики с допустимым временем ответа.
07Вызов с таймаутомФункция может работать неопределенно долго. Обертка должна вернуть результат или ошибку по тайм-ауту и измерить длительность.

Условие

  • Не менять тело unpredictableFunc в первом варианте.
  • Вернуть управление через заданный тайм-аут.
  • Не оставить горутину навсегда заблокированной на отправке результата.
  • Отдельно показать предпочтительный API, где сама операция принимает контекст.
Исходная заготовка
package main

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

func unpredictableFunc() int64 {
    delay := rand.Int63n(5000)
    time.Sleep(time.Duration(delay) * time.Millisecond)
    return delay
}

func predictableFunc() int64 {
    return unpredictableFunc()
}

func main() {
    fmt.Println("started")
    fmt.Println(predictableFunc())
}

Куда раскручивают задачу

  • Горутину, канал, select и таймер.
  • Разницу между ограничением ожидания и остановкой работы.
  • Буферизированный канал результата и утечку горутины.
  • Владельца CancelFunc и кооперативную отмену.

Что придется решить

  • Срок выполнения может задать вызывающая сторона. Внутренний тайм-аут нужен только как дополнительная защита контракта.
  • CancelFunc вызывается на каждом пути, чтобы освободить ресурсы таймера.
  • Метрика измеряет и успешный ответ, и тайм-аут, но не должна выдавать возврат обертки за фактическое завершение неконтекстной работы.

Разбор

Если исходную функцию нельзя изменить, обертка ограничит только время ожидания вызывающей стороны. После тайм-аута unpredictableFunc продолжит работать. Так устроен контракт, select здесь ни при чем.

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

Предпочтительный вариант принимает func(context.Context). Тогда операция может остановить таймер, сетевой запрос или собственный цикл по ctx.Done. Отмена все равно остается кооперативной.

Решение

timeout.go
package timeout

import (
	"context"
	"log"
	"time"
)

const defaultTimeout = time.Second

func PredictableFunc(ctx context.Context) (int64, error) {
	started := time.Now()
	defer func() {
		log.Printf("время выполнения: %s", time.Since(started))
	}()

	var cancel context.CancelFunc
	if _, hasDeadline := ctx.Deadline(); !hasDeadline {
		ctx, cancel = context.WithTimeout(ctx, defaultTimeout)
		defer cancel()
	}

	// Буфер не даст горутине зависнуть, если тайм-аут сработает раньше.
	resultCh := make(chan int64, 1)
	go func() {
		resultCh <- unpredictableFunc()
	}()

	select {
	case result := <-resultCh:
		return result, nil
	case <-ctx.Done():
		// Тайм-аут прекращает ожидание, но не останавливает unpredictableFunc.
		return 0, ctx.Err()
	}
}

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

  • Считать, что тайм-аут автоматически остановил функцию.
  • Использовать небуферизированный канал результата.
  • Создать context.WithTimeout и забыть вызвать cancel.
  • Вызвать time.Since(time.Now()) внутри аргументов defer и получить почти ноль.
  • Полагаться на порядок select, когда готовы несколько веток.

Middle

  • Запускает функцию в горутине и выбирает между результатом и тайм-аутом.
  • Возвращает ошибку и измеряет длительность.
  • Понимает базовое поведение select.

Middle+

  • Ставит буфер на результат и не оставляет отправителя заблокированным.
  • Прямо говорит, что неконтекстная функция продолжает жить.
  • Предлагает контракт с context.Context и правильно вызывает cancel.

Senior

  • Разделяет срок запроса, тайм-аут зависимости и общее допустимое время ответа.
  • Оценивает, как под нагрузкой копится работа после возврата функции.
  • Если зависимость нельзя отменить, предлагает изоляцию в отдельном процессе, очередь или жесткий лимит параллелизма.

Теория

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

01Горутины и потоки ОС

Что такое горутина и как она связана с потоками операционной системы?

Короткий ответ

Горутины планирует рантайм Go. Начальный стек горутины мал и растет по мере необходимости, а множество горутин выполняется на меньшем числе потоков ОС.

GOMAXPROCS ограничивает число логических процессоров P, которые одновременно исполняют код Go. Общее число потоков процесса может быть больше.

Куда углубляют

  • G хранит состояние горутины, M соответствует потоку ОС, P дает M право выполнять код Go и держит локальную очередь готовых горутин.
  • Планировщик использует локальные и общую очереди, перехватывает работу у соседних P, приостанавливает и вытесняет горутины.
  • Блокирующий системный вызов может удержать M. Рантайм отделяет от него P, чтобы другие горутины продолжили работу.
  • Утечку ищут по профилю горутин, дампу стеков, числу горутин и тестам жизненного цикла.

На что обратить внимание

  • Не обещать точный размер стека или стоимость переключения без привязки к версии Go.
  • Не путать GOMAXPROCS с числом потоков.
  • Назвать, кто отвечает за завершение горутины и как до нее доходит отмена.
02Рантайм Go

За что отвечает рантайм и где заканчиваются его возможности?

Короткий ответ

Рантайм планирует горутины, управляет их стеками, памятью и сборкой мусора, обслуживает таймеры и netpoll, реализует каналы, select, panic, recover и defer.

Ограничения ОС никуда не исчезают. Файловые дескрипторы, TCP-соединения, виртуальная память и потоки остаются реальными ресурсами.

Куда углубляют

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

На что обратить внимание

  • Не сводить рантайм к сборщику мусора.
  • Не ждать, что рантайм сам ограничит лавину параллельных запросов к внешнему сервису.
03Netpoll и сетевые соединения

Почему Go может держать много соединений без отдельного потока на каждое?

Короткий ответ

Для поддерживаемых файловых дескрипторов рантайм использует неблокирующие сокеты и регистрирует готовность через epoll в Linux, kqueue в BSD и macOS, а в Windows через свой системный механизм.

Горутина приостанавливается, пока дескриптор не готов. Netpoll получает событие от ОС и возвращает горутину в очередь на выполнение.

Куда углубляют

  • select и poll при каждом вызове передают ядру набор дескрипторов. epoll хранит отслеживаемый набор в ядре и возвращает готовые события.
  • Режим по уровню сообщает о готовности, пока событие не обработано. Режим по фронту сообщает об изменении состояния и требует читать до EAGAIN.
  • Netpoll экономит потоки, но не отменяет буферы, лимиты файловых дескрипторов, память сокетов и ограничения внешнего сервиса.

На что обратить внимание

  • Не говорить, что 10 000 соединений бесплатны.
  • Не путать готовность дескриптора с завершением операции ввода-вывода.
  • Стандартная сетевая библиотека Go сама управляет режимом уведомлений. Прикладной код обычно не вызывает epoll напрямую.
04Каналы и select

Что гарантируют каналы и когда мьютекс проще?

Короткий ответ

Канал передает типизированные значения и синхронизирует горутины. Отправка в небуферизированный канал встречается с получателем, а в буферизированный завершается, пока в буфере есть место.

Канал удобен, когда нужно передать владение данными, раздать работу нескольким горутинам, собрать результаты или сообщить о завершении. Мьютекс проще для небольшого общего состояния с понятным инвариантом.

Куда углубляют

  • Отправка в закрытый канал и повторный close вызывают панику.
  • Чтение из закрытого и опустошенного канала возвращает нулевое значение и ok false.
  • Отправка и чтение через nil-канал блокируются навсегда.
  • Если в select готовы несколько веток case, рассчитывать на порядок выбора нельзя.
  • Ветка default делает операцию неблокирующей, но пустой цикл с select может сжигать CPU.

На что обратить внимание

  • Канал закрывает отправитель или координатор, который знает, что новых значений не будет.
  • Закрытие канала подходит как сигнал завершения, но не заменяет передачу произвольного сообщения всем слушателям.
  • Канал не лучше мьютекса сам по себе. Выбор зависит от того, как движутся данные и кто ими владеет.
05Синхронизация и видимость памяти

Зачем нужны mutex, atomic, WaitGroup и happens-before?

Короткий ответ

Синхронизация защищает данные от конкурентного доступа и задает отношение happens-before, то есть гарантирует порядок и видимость изменений между горутинами.

Мьютекс защищает составной инвариант. Атомарные операции подходят для простого независимого счетчика, флага или замены указателя. WaitGroup ждет известный набор работ.

Куда углубляют

  • RWMutex не обязательно быстрее Mutex. Результат зависит от конкуренции за блокировку и длины критической секции.
  • WaitGroup.Add выполняют до запуска горутины. WaitGroup нельзя копировать после начала использования.
  • sync.Once считает вызов выполненным, даже если функция завершилась паникой.
  • sync.Cond ждет изменения условия под блокировкой и проверяет условие в цикле.
  • Гонка данных означает конкретный конфликт при обращении к памяти. Состояние гонки шире, а взаимная блокировка означает, что программа больше не может продвинуться.

На что обратить внимание

  • Не защищать только запись, если параллельно идет чтение.
  • Не собирать составной инвариант из независимых атомарных операций без доказательства корректности.
  • Проверять конкурентный код детектором гонок, но не считать один зеленый запуск доказательством всех путей.
06Интерфейсы и типизированный nil

Почему интерфейс может быть не nil, хотя внутри лежит nil-указатель?

Короткий ответ

Значение интерфейса можно представить как пару: динамический тип и динамическое значение. Интерфейс равен nil, только когда обе части пусты.

Если присвоить nil *MyError переменной типа error, динамический тип сохранится и err != nil.

Куда углубляют

  • Тип удовлетворяет интерфейсу неявно. Набор методов значения и указателя различается.
  • Функция, возвращающая интерфейс, должна вернуть буквальный nil, а не типизированный nil-указатель.
  • Проверка типа с value, ok безопасна. Вариант без ok вызывает панику при несовпадении.
  • any служит псевдонимом для interface{}.

На что обратить внимание

  • Не сводить интерфейс к списку методов и не забывать его внутреннее представление.
  • Проверять типизированный nil на границах API и в реализациях error.
07Память, слайсы, map и сборщик мусора

Как Go решает, где хранить значение, и почему слайсы могут влиять друг на друга?

Короткий ответ

Анализ выхода за пределы функции определяет, останется ли значение на стеке или попадет в кучу. Решение принимает компилятор, одного синтаксического признака здесь нет.

Слайс содержит ссылку на общий массив, len и cap. Несколько слайсов могут делить один массив, поэтому изменение через один слайс бывает видно в другом.

Куда углубляют

  • append использует старый массив, пока хватает емкости, затем выделяет новый и копирует данные. Точный коэффициент роста относится к деталям реализации.
  • У map есть len, но нет публичного cap. Одновременное чтение и запись требуют синхронизации. Порядок обхода не определен.
  • Сборщик мусора Go работает параллельно с программой: начинает с корней, помечает достижимые объекты и использует барьер записи.
  • GOGC меняет соотношение нагрузки на CPU и размера кучи. GOMEMLIMIT задает мягкий предел памяти. Сборщик не освободит объект, пока на него ссылается кэш.

На что обратить внимание

  • Не говорить, что new всегда означает выделение в куче.
  • Не строить контракт на точном росте слайса.
  • Различать скорость выделения памяти и объем памяти, который остается в куче.
08Тестирование

Что дает go test и как проверять конкурентный HTTP код?

Короткий ответ

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

Практический тест часто делают табличным и проверяют наблюдаемое поведение API. Для HTTP используют httptest, а для времени лучше управляемые часы или явная синхронизация вместо долгих Sleep.

Куда углубляют

  • Небольшие интерфейсы на стороне потребителя позволяют подставить имитацию или заглушку.
  • Мок с ожиданиями полезен для проверки протокола вызовов, но слишком подробный мок повторяет внутреннее устройство кода.
  • go test -race находит гонки только на реально выполненных путях.
  • Перемешивание порядка и повторные запуски помогают обнаружить нестабильные тесты.

На что обратить внимание

  • Не подменять тестирование перечислением модульных, интеграционных и сквозных тестов.
  • Не использовать Sleep как основной способ синхронизации конкурентных тестов.
  • Назвать конкретную ошибку, которую должен поймать каждый тест.
09Профилирование

Как выбрать между pprof и trace и какой профиль нужен под симптом?

Короткий ответ

Сначала формулируют измеренный симптом: высокая загрузка CPU, задержка, частые выделения памяти, рост занятой кучи, конкуренция за блокировку или зависшие горутины.

pprof собирает профили CPU, кучи, выделений памяти, горутин, блокировок, мьютексов и создания потоков. trace показывает последовательность работы планировщика, системных вызовов, сборщика мусора и горутин.

Куда углубляют

  • Профиль heap inuse показывает занятую память, allocs накапливает места всех выделений.
  • Одного списка горячих функций мало. Нужны граф вызовов, исходные строки и повторное измерение после изменения.
  • Профили мьютексов и блокировок зависят от настроек выборки. trace тяжелее и собирает подробный поток событий.
  • В боевой системе профили включают на ограниченное время, а отладочные эндпоинты закрывают от внешнего доступа.

На что обратить внимание

  • Не запускать профилирование без репрезентативной нагрузки и конкретного вопроса.
  • Не путать горячий путь по CPU с задержкой из-за ожидания ввода-вывода.
  • Каждое изменение подтверждать повторным профилем или тестом производительности.

Что отличает уровни

Разница видна по тому, насколько далеко кандидат прослеживает последствия своего решения.

MiddleMiddle+Senior
Контракт

Уточняет вход и ожидаемый результат.

Спрашивает про ошибки, срок выполнения и частичный результат.

Связывает условия с нагрузкой, задержками и режимом деградации.

Код

Пишет корректное решение основного сценария.

Сам находит гонки данных, утечки горутин и незакрытые ресурсы.

Упрощает владение данными и учитывает системные лимиты.

Отказы

Корректно возвращает очевидную ошибку.

Проводит отмену до внешней операции и учитывает соседнюю работу.

Ограничивает входящий поток, число повторов и режим деградации.

Глубина

Знает, когда нужен канал, мьютекс или WaitGroup.

Связывает код с рантаймом Go, работой HTTP и видимостью изменений в памяти.

Отделяет гарантии языка, детали рантайма и поведение ОС.

Проверка

Пишет тесты и запускает детектор гонок.

Выбирает тест производительности или профиль под конкретный риск.

Готовит нагрузочный сценарий, метрики и критерий отката.

Middle

Ожидается рабочее решение исходной задачи. Кандидат понимает базовые примитивы, не допускает очевидную гонку после подсказки и объясняет главные строки своего кода. WaitGroup или контекст нужно поставить в правильное место и показать, когда работа завершится.

Middle+

Кандидат сам замечает ранний выход, зависшего отправителя, незакрытое тело HTTP-ответа и одновременный промах кэша. Он проводит контекст через цепочку вызовов, ограничивает параллелизм и объясняет выбор канала, мьютекса или другого примитива.

Senior

Кандидат рассматривает решение как часть системы: оценивает ресурсы и допустимую задержку, учитывает несколько реплик, поведение внешнего сервиса и режим деградации. Сильный ответ часто приводит к более простому коду с понятными границами и способом проверки.

Как устроен лайвкодинг

Интервьюер дает задачу, смотрит на первую версию и постепенно меняет условия.

  1. 01

    Уточнить задачу

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

  2. 02

    Написать простую версию

    Сначала основной сценарий. Сложную архитектуру заранее придумывать не нужно.

  3. 03

    Проверить края

    Гонки данных, ранний возврат, отмена, незакрытые ресурсы и лимит параллелизма.

  4. 04

    Принять новое условие

    Например, сотни URL, 10 000 RPS, зависший внешний сервис или несколько реплик.

  5. 05

    Сказать, как проверить

    Тест, детектор гонок, профиль или нагрузочный сценарий под конкретный риск.

Что важно делать вслух

До кода стоит проговорить неизвестные условия. Можно ли вернуть частичный результат? Сколько времени допустимо ждать? Что именно означает 10 000 RPS в этой задаче? Молчаливо выбирать удобные значения опасно: интервьюер может проверять как раз это место.

После первой версии нужно пройти по жизненному циклу работы: кто запустил горутину, кто ее остановит, что останется после возврата функции, где закрывается тело ответа и сколько запросов выполняется одновременно. Новое условие не требует переписывать все с нуля.

Где чаще всего проваливается ответ

Одни и те же ошибки повторяются в разных задачах. Заучивать семь листингов бессмысленно.

Остановились на основном сценарии

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

Тайм-аут приняли за остановку

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

Параллельность оставили без лимита

Горутины легкие, но соединения, файловые дескрипторы, буферы и внешний сервис имеют предел. Число одновременных операций нужно ограничить и обосновать.

Контекст добавили только в сигнатуру

Пока контекст не дошел до HTTP-запроса, внешнего сервиса или цикла обработки, отмена существует только на бумаге.

Кэш решили как map плюс mutex

Без TTL, объединения одинаковых запросов, прогрева и стратегии для нескольких реплик один промах кэша вызывает лавину обращений к внешнему сервису.

Назвали инструмент без диагноза

pprof, trace и тест производительности выбирают под симптом. Высокая загрузка CPU, удерживаемая память и задержка на блокировке требуют разных проверок.

Как готовиться семь дней

Каждый день нужно что-то сделать руками: написать код, объяснить решение вслух, запустить тест или снять профиль. Одного чтения документации мало.

День 1Проверить текущий уровеньПонять, где ломается решение: в синтаксисе, логике, работе с ресурсами или объяснении.

Что сделать

  • Взять три задачи: указатели, конкурентность и сетевой ввод-вывод.
  • На каждую дать пятиминутный ответ вслух без готового решения.
  • Записать, что получилось без подсказки и на каком уточнении ответ рассыпался.

На выходе

Три слабых места и понятный критерий повторной проверки для каждого.

День 2Указатели, интерфейсы и памятьНаучиться точно говорить, что копируется и какая память остается общей.

Что сделать

  • Решить задачу с указателями и разделить изменение объекта и замену указателя.
  • Разобрать типизированный nil, наборы методов и устройство интерфейса.
  • Нарисовать общий массив для двух слайсов до и после append.

На выходе

Вы точно называете, что скопировано, какая память общая и кто ее меняет.

День 3Конкурентный код без гонокСначала сформулировать инвариант, потом выбирать канал, мьютекс или атомарную операцию.

Что сделать

  • Решить задачу с максимальным четным числом через мьютекс и через одну горутину, которая собирает результат.
  • Объяснить WaitGroup.Add, закрытие канала и условие завершения каждой горутины.
  • Запустить тесты, go test -race и go vet.

На выходе

Два корректных решения и объяснение, почему в них нет гонки данных и утечки горутин.

День 4Контекст, тайм-аут и завершениеРазличать возврат по тайм-ауту и реальную остановку работы.

Что сделать

  • Разобрать задачи с вызовом по тайм-ауту и сборкой сниппета.
  • Для каждой горутины назвать, кто ее запустил, кто отменяет и когда она завершится.
  • Смоделировать ошибку одной зависимости, тайм-аут и отмену вызывающей стороны.

На выходе

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

День 5HTTP и лимиты ресурсовНе заканчивать ответ фразой запущу горутину на каждый URL.

Что сделать

  • Решить задачи с параллельными URL и 10 000 сетевых операций через ограниченный пул обработчиков.
  • Проверить закрытие тела ответа, общий http.Client, Transport и передачу контекста.
  • Обосновать лимит параллелизма через задержку, файловые дескрипторы, пул соединений и квоты внешнего сервиса.

На выходе

В решении есть лимит, тайм-аут, очистка ресурсов и понятное поведение при частичном сбое.

День 6Нагрузка, рантайм и диагностикаСвязать прикладной код с планировщиком, netpoll, кэшем и измерениями.

Что сделать

  • Разобрать прогноз погоды: TTL, объединение одинаковых запросов, прогрев, инвалидацию и Close.
  • Проговорить модель M, P, G, netpoll, epoll и ограничения ОС.
  • Выбрать профиль для высокой загрузки CPU, роста занятой кучи, блокировок и утечки горутин.

На выходе

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

День 7Пробное собеседованиеСобрать знания в связное решение задачи вслух.

Что сделать

  • 5 минут: уточнить условия, входные данные, ошибки и ограничения.
  • 15 минут: написать простое компилируемое решение.
  • 10 минут: проверить гонки данных, отмену, лимиты и частичный сбой.
  • 5 минут: назвать тесты, проверку гонок, нужный профиль и следующий шаг для боевой системы.

На выходе

Три темы на следующую неделю и повторная проверка по тем же задачам.

Больше материалов о подготовке к работе в бигтехе я даю на менторстве.

Если хочешь вкатиться в Go на 320–450 тысяч, напиши мне в личку.

Разберём твой опыт и сложности, расскажу про программу и формат работы.

Написать в личку

Самые полезные посты
из Telegram

Как понять, что готов к собесу по Go

Базовый минимум без позора на интервью: язык, память, горутины, context, БД, HTTP и умение нормально объяснять свои решения.

14.2k43310

Почему резюме выглядит как школьный дневник

Рекрутер сканирует резюме первые 15 секунд. Нужны роль, стек, результат и внятный опыт, а не список обязанностей без смысла.

9.8k44207

Как я стал senior-разработчиком в бигтехе

Переходы между командами, офферы в WB и T-Bank, рост дохода и то, как решения по карьере складываются в нормальную стратегию.

3.9k13122

Математика конверсий в поиске работы

Отклик, HR, техничка, финалка, оффер. Чтобы получить результат, нужно управлять всей воронкой, а не ждать идеальную вакансию.

4.2k71122

Планировщик Go: что нужно знать на собесе

GMP-модель, очереди, syscalls, starvation и вопросы, на которых чаще всего сыпятся мидлы и синьоры на технических интервью.

2.5k62153

Разбор слитых задачек из OZON

Какие темы чаще всего встречаются: не просто Go, а смесь алгоритмов, операционки, БД, конкурентности и умения думать вслух.

7.9k52110

Как не проебать испытательный срок

Первые недели решают больше, чем кажется: как фиксировать ожидания, не теряться в хаосе и быстро стать понятным для команды.

5.2k68115

Как бы я учил Go в 2026

Что учить, в каком порядке, где люди месяцами буксуют и как собрать подготовку так, чтобы она вела к собеседованиям, а не к вечной теории.

2.1k45111

Как HR сейчас вычисляют накрутчиков

Внутренние методички, проверки опыта, поведенческие маркеры и способы, которыми компании отсеивают кандидатов с нарисованным стажем.

2.7k10333

Первые недели решают всё

Что делать сразу после выхода: зафиксировать ожидания, найти зоны риска, показывать прогресс и не ждать, пока проблемы станут видимыми всем.

4.3k6099

Как я сократил рабочий день до 2-3 часов

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

2.5k6239

Рынок умер, вакансий нет?

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

9.2k3827

Почему «я готовлюсь» = я стою на месте

Про вечную подготовку, перфекционизм, прокрастинацию и момент, где нужно уже выходить в рынок, а не бесконечно допиливать пет-проект.

3.4k4128