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

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

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

Сегодня мы разберём реальное собеседование по Go, в ходе которого кандидат Женя выполнял код-ревью, решал задачи на многопоточность (горутины, каналы, WaitGroup, Worker Pool) и отвечал на вопросы по безопасности, контекстам и внутреннему устройству языка. Интервью прошло в формате живого вебинара с ментором Димой, который по итогам дал подробную обратную связь, отметив как сильные стороны, так и зоны роста кандидата.

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

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

Ответ собеседника: Правильный. Начинал карьеру с тестировщика на Python, проработал 7 лет, затем работал в разных сферах, включая интернет-провайдера. Сейчас работаю в крупном бигтехе. Изучаю Go примерно полтора-два года.

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

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

Для более сильного ответа на позиции Go-разработчика рекомендуется дополнительно акцентировать:

  • Конкретные проекты на Go — какие сервисы разрабатывались, какую архитектуру использовали, какие проблемы решались.
  • Глубину знания Go — понимание модели конкурентности (горутины, каналы, контексты), работа с памятью, профилирование, опыт с фреймворками (gin, echo, grpc).
  • Масштаб систем — количество запросов, объём данных, требования к доступности и латентности.
  • Конкретные метрики успеха — например, «снизил latency P99 на 40%», «масштабировал сервис до 10k RPS».

Стоит отметить, что переход из Python в Go — распространённый и логичный путь, поскольку Go часто выбирают для высоконагруженных систем, где Python проигрывает в производительности. Важно подчеркнуть мотивацию перехода и практический опыт применения Go в продакшене.

Вопрос 2. Почему компания решила переписать Legacy PHP-код на Go и в чём Go выигрывает у PHP?

Таймкод: 00:05:45

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

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

Переход с PHP на Go — это архитектурное решение, которое обычно обусловлено комплексом технических и бизнес-факторов. Вот ключевые причины, по которым Go предпочтительнее PHP для серверных приложений.

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

Go — компилируемый язык со статической типизацией. Бинарный файл выполняется напрямую на процессоре, без промежуточного слоя интерпретатора. PHP работает через интерпретатор (Zend Engine), и каждый запрос в классической модели (mod_php, php-fpm) запускает новый процесс, который инициализирует всё заново. Это критично при высоких нагрузках.

# Простой HTTP-сервер на Go — минимальный overhead
package main

import (
"fmt"
"net/http"
)

func handler(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "Hello, World!")
}

func main() {
http.HandleFunc("/", handler)
http.ListenAndServe(":8080", nil)
}

Этот сервер обрабатывает тысячи запросов в секунду практически без накладных расходов. Для аналогичного сервера на PHP потребуется php-fpm пул, nginx как reverse proxy, и каждый запрос будет нести накладные расходы на инициализацию.

Модель конкурентности

Встроенная поддержка горутин и каналов — это фундаментальное преимущество Go. Горутины — это легковесные потоки, управляемые рантаймом Go, а не ОС. Один процесс Go может запускать сотни тысяч горутин с минимальным потреблением памяти (стек каждой горутины начинается с ~2 КБ и растёт динамически).

func processRequests(requests []Request) []Response {
results := make([]Response, len(requests))
var wg sync.WaitGroup

for i, req := range requests {
wg.Add(1)
go func(idx int, r Request) {
defer wg.Done()
results[idx] = handleRequest(r) // параллельная обработка
}(i, req)
}

wg.Wait()
return results
}

В PHP нет нативной многопоточности. Для параллельной обработки приходится использовать расширения (pthreads, которое давно не поддерживается), внешние очереди (RabbitMQ, Redis), или запускать отдельные процессы через pcntl_fork. Это значительно усложняет архитектуру.

Статическая типизация и компиляция

Go ловит множество ошибок на этапе компиляции. Несовместимость типов, неиспользуемые переменные, отсутствующие импорты — всё это выявляется до запуска в продакшен. PHP с динамической типизацией пропускает такие ошибки в runtime, что увеличивает количество багов в production.

Развёртывание

Go компилируется в один статический бинарный файл без внешних зависимостей. Деплой сводится к копированию одного файла. PHP-приложение требует настроенного веб-сервера, php-fpm, расширений, зависимостей через Composer — всё это увеличивает поверхность отказа и усложняет CI/CD.

Экосистема для микросервисов

Go изначально проектировался для облачных распределённых систем. В стандартной библиотеке есть HTTP/2, gRPC, JSON, работа с криптографией. Инструменты вроде Docker, Kubernetes, Prometheus, Terraform написаны на Go, что говорит о его зрелости для инфраструктурных задач.

Когда PHP всё же уместен

PHP остаётся отличным выбором для CMS-проектов (WordPress, Drupal), небольших веб-приложений, прототипов и задач, где важна скорость разработки и не критична производительность. Современный PHP 8+ с JIT-компилятором значительно приблизился по скорости к Go, но по модели конкурентности и деплою разница остаётся существенной.

Вопрос 3. Проведите code review предоставленного кода: найдите баги, проблемы и перепишите код правильно.

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

Ответ собеседника: Неполный. Кандидат нашёл ряд проблем: захардкоженные строки ошибок нужно вынести в константы, неинформативное название функции name, некорректный подсчёт длины строки для Unicode (len считает байты, а не символы), функция возвращает interface{} вместо конкретного типа, SQL-инъекция (прямая передача SQL-строки в репозиторий), мапа объявлена без make (будет паника), отсутствие транзакции при обновлении в БД, неправильный HTTP-статус 404 вместо 401 при неверном пароле, сравнение паролей простым сравнением строк без хэширования, отсутствие middleware для проверки авторизации. При переписывании кода не смог полностью завершить рефакторинг.

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

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

Полный список проблем в исходном коде

1. Мапа объявлена без инициализации. Объявление var m map[string]string создаёт nil-мапу. Любая попытка записи в неё вызовет панику assignment to entry in nil map. Исправление: m := make(map[string]string) или литерал.

2. SQL-инъекция. Прямая подстановка пользовательского ввода в SQL-строку через конкатенацию или Sprintf — это критическая уязвимость. Злоумышленник может выполнить произвольный SQL-запрос.

3. Хранение и сравнение паролей в открытом виде. Пароли никогда не должны храниться или сравниваться в виде plain text. Необходимо использовать bcrypt, scrypt или argon2 для хеширования.

4. Некорректный HTTP-статус. При неверном пароле возвращается 404 (Not Found) вместо 401 (Unauthorized). Это утечка информации — по статусу 404 атакующий понимает, что пользователь не найден, и может перебирать существующие логины.

5. Функция возвращает interface{} вместо конкретного типа. Это убивает типобезопасность и вынуждает вызывающего код делать type assertion.

6. len() считает байты, а не символы. Для Unicode-строк (кириллица, эмодзи, CJK) len(str) вернёт количество байт, а не рун. Нужно использовать utf8.RuneCountInString(str) или len([]rune(str)).

7. Отсутствие транзакции. Если операция обновления в БД состоит из нескольких шагов, нужна транзакция для атомарности.

8. Отсутствие контекста запроса. Без context.Context нельзя управлять таймаутами и отменой операций с БД.

9. Неинформативное имя функции. Имя name ничего не говорит о назначении функции.

10. Захардкоженные строки ошибок. Строки ошибок нужно выносить в константы для единообразия логирования и тестирования.

Исправленный код

package handler

import (
"context"
"encoding/json"
"errors"
"net/http"
"time"
"unicode/utf8"

"golang.org/x/crypto/bcrypt"
)

// Константы ошибок — единообразие логирования и тестов
var (
ErrUserNotFound = errors.New("user not found")
ErrInvalidPassword = errors.New("invalid password")
ErrValidationFailed = errors.New("validation failed")
ErrInternalServer = errors.New("internal server error")
)

// Структурированный ответ вместо interface{}
type UserResponse struct {
ID int64 `json:"id"`
Login string `json:"login"`
}

// Запрос на обновление пароля
type UpdatePasswordRequest struct {
Login string `json:"login"`
OldPassword string `json:"old_password"`
NewPassword string `json:"new_password"`
}

type UserService interface {
GetUserByLogin(ctx context.Context, login string) (*User, error)
UpdatePassword(ctx context.Context, userID int64, hashedPassword string) error
}

type Handler struct {
userService UserService
}

func NewHandler(us UserService) *Handler {
return &Handler{userService: us}
}

// UpdatePassword обновляет пароль пользователя
func (h *Handler) UpdatePassword(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()

var req UpdatePasswordRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, ErrValidationFailed)
return
}

// Валидация длины через руны, а не байты
if utf8.RuneCountInString(req.NewPassword) < 8 {
writeError(w, http.StatusBadRequest, errors.New("password must be at least 8 characters"))
return
}

// Параметризованный запрос через репозиторий — защита от SQL-инъекций
user, err := h.userService.GetUserByLogin(ctx, req.Login)
if err != nil {
// Возвращаем одинаковый статус и для «не найден», и для «неверный пароль»
// чтобы не утечь информацию о существовании логина
writeError(w, http.StatusUnauthorized, ErrInvalidPassword)
return
}

// Сравнение через bcrypt — пароли хранятся в хешированном виде
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(req.OldPassword)); err != nil {
writeError(w, http.StatusUnauthorized, ErrInvalidPassword)
return
}

// Хеширование нового пароля
newHash, err := bcrypt.GenerateFromPassword([]byte(req.NewPassword), bcrypt.DefaultCost)
if err != nil {
writeError(w, http.StatusInternalServerError, ErrInternalServer)
return
}

// Обновление в рамках транзакции (реализация в сервисном слое)
if err := h.userService.UpdatePassword(ctx, user.ID, string(newHash)); err != nil {
writeError(w, http.StatusInternalServerError, ErrInternalServer)
return
}

resp := UserResponse{
ID: user.ID,
Login: user.Login,
}

w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(resp)
}

func writeError(w http.ResponseWriter, status int, err error) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(map[string]string{"error": err.Error()})
}

Сервисный слой с транзакцией

package service

import (
"context"
"database/sql"
"fmt"
)

type UserRepository interface {
FindByLogin(ctx context.Context, tx *sql.Tx, login string) (*User, error)
UpdatePassword(ctx context.Context, tx *sql.Tx, userID int64, hash string) error
}

type userService struct {
db *sql.DB
repo UserRepository
}

func (s *userService) UpdatePassword(ctx context.Context, userID int64, hashedPassword string) error {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("begin transaction: %w", err)
}
defer tx.Rollback()

if err := s.repo.UpdatePassword(ctx, tx, userID, hashedPassword); err != nil {
return fmt.Errorf("update password: %w", err)
}

return tx.Commit()
}

Ключевые принципы, которые продемонстрированы в исправленном коде:

  • Использование context.Context с таймаутом для всех операций с БД
  • Параметризованные запросы через интерфейс репозитория — никаких SQL-инъекций
  • bcrypt для хеширования и сравнения паролей
  • Одинаковый HTTP-статус 401 для «не найден» и «неверный пароль» — защита от перебора логинов
  • Транзакция при обновлении БД
  • Структурированные типы вместо interface{}
  • utf8.RuneCountInString для корректной работы с Unicode
  • Вынесенные константы ошибок
  • Разделение на слои: handler → service → repository

Вопрос 4. Как безопасно сравнивать пароли и какие алгоритмы хэширования паролей существуют?

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

Ответ собеседника: Неправильный. Кандидат предположил, что пароль можно раскодировать для сравнения. Когда уточнили, что пароли хранятся в зашифрованном виде и их нельзя расшифровать, признал, что не знает конкретных алгоритмов хэширования (bcrypt, scrypt, argon2).

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

Фундаментальный принцип: хеширование, а не шифрование

Пароли хранятся в виде хешей, а не зашифрованных данных. Хеширование — это односторонняя функция: из хеша невозможно восстановить исходные данные. Именно поэтому пароль «нельзя расшифровать» — это не ограничение, а фундаментальное свойство криптографических хеш-функций.

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

Почему нельзя использовать обычные хеш-функции (MD5, SHA-256)

Стандартные хеш-функции вроде MD5 или SHA-256 спроектированы для скорости. Это делает их уязвимыми к:

  • Brute-force атакам — современные GPU вычисляют миллиарды SHA-256 хешей в секунду
  • Rainbow table атакам — заранее рассчитанные таблицы хешей для распространённых паролей
  • Атакам по словарю — перебор популярных паролей с их хешами

Специализированные алгоритмы хеширования паролей

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

bcrypt — самый распространённый и проверенный временем алгоритм. Основан на шифре Blowfish. Имеет параметр cost (work factor), который определяет количество раундов хеширования. Каждое увеличение cost вдвое замедляет вычисление. bcrypt автоматически генерирует случайную соль и включает её в результат.

import "golang.org/x/crypto/bcrypt"

// Хеширование пароля
func HashPassword(password string) (string, error) {
// cost=10 — значение по умолчанию, рекомендуется 12+ для production
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return "", err
}
return string(hash), nil
}

// Сравнение пароля с хешем
func VerifyPassword(hashedPassword, inputPassword string) bool {
err := bcrypt.CompareHashAndPassword(
[]byte(hashedPassword),
[]byte(inputPassword),
)
return err == nil
}

Результат bcrypt выглядит так: $2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy. Здесь $2a$ — версия алгоритма, 10 — cost, далее 22 символа соли и 31 символ хеша. Соль хранится прямо в строке хеша — не нужно отдельное поле в БД.

scrypt — алгоритм, разработанный для защиты от атак на специализированном оборудовании (ASIC, FPGA). Помимо CPU-сложности, требует значительного объёма памяти. Параметры: N (CPU/memory cost), r (block size), p (parallelization).

import "golang.org/x/crypto/scrypt"

func HashPasswordScrypt(password string) (string, error) {
salt := make([]byte, 32)
if _, err := io.ReadFull(rand.Reader, salt); err != nil {
return "", err
}

// N=32768, r=8, p=1 — рекомендуемые параметры
hash, err := scrypt.Key([]byte(password), salt, 32768, 8, 1, 32)
if err != nil {
return "", err
}

// Сохраняем соль и хеш вместе (base64-кодирование)
return base64.StdEncoding.EncodeToString(salt) + "$" +
base64.StdEncoding.EncodeToString(hash), nil
}

Argon2 — победитель Password Hashing Competition 2015 года, считается самым современным алгоритмом. Имеет три варианта:

  • Argon2d — максимально устойчив к GPU-атакам, но уязвим к side-channel атакам
  • Argon2i — устойчив к side-channel атакам
  • Argon2id — гибрид, рекомендуется для большинства случаев
import "golang.org/x/crypto/argon2"

func HashPasswordArgon2(password string) (string, error) {
salt := make([]byte, 16)
if _, err := io.ReadFull(rand.Reader, salt); err != nil {
return "", err
}

// Argon2id: time=1, memory=64MB, threads=4, keyLen=32
hash := argon2.IDKey([]byte(password), salt, 1, 64*1024, 4, 32)

// Кодирование в стандартный формат
b64Salt := base64.RawStdEncoding.EncodeToString(salt)
b64Hash := base64.RawStdEncoding.EncodeToString(hash)

return fmt.Sprintf("$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s",
argon2.Version, 64*1024, 1, 4, b64Salt, b64Hash), nil
}

Сравнение алгоритмов

ПараметрbcryptscryptArgon2id
Максимальная длина пароля72 байтаНе ограниченаНе ограничена
Устойчивость к GPUСредняяВысокаяВысокая
Устойчивость к ASICНизкаяВысокаяВысокая
Настройка памятиНетДаДа
Рекомендация OWASPУстарелХорошоЛучший выбор

OWASP рекомендует Argon2id как первый выбор, bcrypt — как приемлемую альтернативу для систем, где Argon2id недоступен.

Важные правила при работе с паролями

  • Никогда не храните пароли в открытом виде
  • Никогда не используйте MD5 или SHA без соли для паролей
  • Всегда генерируйте уникальную случайную соль для каждого пароля
  • Не изобретайте собственные алгоритмы хеширования
  • Используйте constant-time сравнение — библиотеки bcrypt, argon2 уже реализуют его внутри, что защищает от timing-атак
  • Регулярно увеличивайте параметр cost при росте вычислительных мощностей

Вопрос 5. Какие параметры куки существуют и что означают Secure и HttpOnly?

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

Ответ собеседника: Неполный. Кандидат сначала неверно предположил, что Secure означает видимость куки на клиенте. После подсказки принял правильное объяснение: Secure означает, что кука передаётся только по HTTPS, а HttpOnly запрещает доступ к куке из JavaScript. Самостоятельно точные определения дать не смог.

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

Куки (HTTP cookies) — это небольшие фрагменты данных, которые сервер отправляет браузеру, а браузер автоматически прикрепляет к каждому последующему запросу к этому же домену. Помимо пары ключ-значение, куки имеют ряд атрибутов, определяющих их поведение и безопасность.

Основные атрибуты куки

Secure — указывает браузеру отправлять куку только по зашифрованному HTTPS-соединению. Если сайт доступен по HTTP, браузер не включит эту куку в запрос. Это защищает от перехвата куки при атаке «человек посередине» (MITM).

HttpOnly — запрещает доступ к куке из JavaScript через document.cookie. Куку можно читать и записывать только на сервере через HTTP-заголовки. Это критически важная защита от XSS-атак (Cross-Site Scripting): даже если злоумышленник внедрит вредоносный скрипт на страницу, он не сможет украсть сессионную куку.

SameSite — контролирует, отправляется ли кука при кросс-доменных запросах. Три значения:

  • Strict — кука не отправляется при переходе с другого сайта вообще. Пользователь, перешедший по ссылке из поисковика, не будет аутентифицирован.
  • Lax — кука отправляется только при навигации верхнего уровня (переход по ссылке), но не при загрузке ресурсов (изображения, iframe, AJAX). Значение по умолчанию в современных браузерах.
  • None — кука отправляется всегда, но обязательно требует атрибут Secure.

Защищает от CSRF-атак (Cross-Site Request Forgery).

Domain — определяет, к какому домену относится кука. Если явно не указан, кука привязана к текущему домену (без поддоменов). Если указан .example.com, кука доступна всем поддоменам.

Path — ограничивает путь, для которого кука действительна. Например, Path=/admin означает, что кука отправляется только для запросов к /admin/*.

Expires / Max-Age — определяют время жизни куки. Expires задаёт конкретную дату, Max-Age — количество секунд от текущего момента. Если оба атрибута отсутствуют, кука является сессионной и удаляется при закрытии браузера.

Пример установки куки в Go

func setAuthCookie(w http.ResponseWriter, token string) {
http.SetCookie(w, &http.Cookie{
Name: "session_token",
Value: token,
Path: "/",
Domain: ".example.com",
MaxAge: 86400 * 7, // 7 дней
HttpOnly: true, // недоступна из JavaScript
Secure: true, // только по HTTPS
SameSite: http.SameSiteLaxMode,
})
}

Пример Set-Cookie заголовка

Set-Cookie: session_token=abc123; Path=/; Domain=.example.com; MaxAge=604800; HttpOnly; Secure; SameSite=Lax

Практические рекомендации по безопасности

  • Всегда используйте HttpOnly для сессионных куки — это базовая защита от XSS
  • Всегда используйте Secure — не передавайте чувствительные куки по HTTP
  • Используйте SameSite=Lax или Strict для защиты от CSRF
  • Не храните чувствительные данные (пароли, номера карт) в куках — храните только идентификатор сессии на сервере
  • Устанавливайте минимально необходимые Domain и Path
  • Используйте короткий Max-Age для сессионных куки и реализуйте механизм обновления токенов

Типичные атаки, от которых защищают атрибуты куки

АтакаАтрибут защиты
XSS (кража сессии)HttpOnly
MITM (перехват трафика)Secure
CSRF (подделка запроса)SameSession
Fixation (фиксация сессии)Регенерация ID после логина

Вопрос 6. Что такое middleware в HTTP-обработке и как его реализовать в стандартном net/http без фреймворков?

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

Ответ собеседника: Неполный. Кандидат описал middleware как обёртку функции, которая что-то делает до или после запроса. Подтвердил, что это функция, принимающая и возвращающая handler. Однако не смог предложить конкретную реализацию паттерна middleware в стандартном net/http.

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

Что такое middleware

Middleware (промежуточное ПО) — это функция-обёртка, которая перехватывает HTTP-запрос до или после того, как он достигнет основного обработчика. В Go middleware основан на том, что http.Handler — это интерфейс с одним методом ServeHTTP(w http.ResponseWriter, r *http.Request), и любая функция, которая принимает и возвращает http.Handler, является middleware.

Базовый паттерн

// Middleware — это функция, которая принимает Handler и возвращает Handler
type Middleware func(http.Handler) http.Handler

// LoggingMiddleware — логирует каждый входящий запрос
func LoggingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()

// Вызываем следующий обработчик в цепочке
next.ServeHTTP(w, r)

log.Printf("%s %s %v", r.Method, r.URL.Path, time.Since(start))
})
}

Паттерн ResponseWriter-обёртка для перехвата статуса

Стандартный http.ResponseWriter не даёт узнать, какой статус код был записан. Для этого нужна обёртка:

type responseWriter struct {
http.ResponseWriter
statusCode int
bytesWritten int
}

func (rw *responseWriter) WriteHeader(code int) {
rw.statusCode = code
rw.ResponseWriter.WriteHeader(code)
}

func (rw *responseWriter) Write(b []byte) (int, error) {
n, err := rw.ResponseWriter.Write(b)
rw.bytesWritten += n
return n, err
}

// LoggingMiddleware с расширенным логированием
func LoggingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()

wrapped := &responseWriter{
ResponseWriter: w,
statusCode: 200, // значение по умолчанию
}

next.ServeHTTP(wrapped, r)

log.Printf(
"%s %s %d %d bytes %v",
r.Method,
r.URL.Path,
wrapped.statusCode,
wrapped.bytesWritten,
time.Since(start),
)
})
}

Middleware для аутентификации

func AuthMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
token := r.Header.Get("Authorization")
if token == "" {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}

// Верификация токена и извлечение userID
userID, err := validateToken(token)
if err != nil {
http.Error(w, "invalid token", http.StatusUnauthorized)
return
}

// Добавляем userID в контекст для передачи в обработчик
ctx := context.WithValue(r.Context(), "userID", userID)
next.ServeHTTP(w, r.WithContext(ctx))
})
}

Middleware для rate limiting

func RateLimitMiddleware(requestsPerSecond int) Middleware {
limiter := rate.NewLimiter(rate.Limit(requestsPerSecond), requestsPerSecond*2)

return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !limiter.Allow() {
http.Error(w, "rate limit exceeded", http.StatusTooManyRequests)
return
}
next.ServeHTTP(w, r)
})
}
}

Middleware для восстановления после паник

func RecoveryMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if err := recover(); err != nil {
log.Printf("panic recovered: %v\n%s", err, debug.Stack())
http.Error(w, "internal server error", http.StatusInternalServerError)
}
}()
next.ServeHTTP(w, r)
})
}

Композиция middleware — цепочка вызовов

// Chain применяет middleware в порядке: первый middleware — самый внешний
func Chain(handler http.Handler, middlewares ...Middleware) http.Handler {
for i := len(middlewares) - 1; i >= 0; i-- {
handler = middlewares[i](handler)
}
return handler
}

// Использование
func main() {
mux := http.NewServeMux()
mux.HandleFunc("/api/data", dataHandler)

// Порядок: Recovery → Logging → Auth → RateLimit → Handler
handler := Chain(
mux,
RecoveryMiddleware,
LoggingMiddleware,
AuthMiddleware,
RateLimitMiddleware(100),
)

log.Fatal(http.ListenAndServe(":8080", handler))
}

Middleware с параметрами через замыкание

// CORS middleware с настройками
func CORSMiddleware(allowedOrigins []string) Middleware {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
origin := r.Header.Get("Origin")

for _, allowed := range allowedOrigins {
if origin == allowed {
w.Header().Set("Access-Control-Allow-Origin", origin)
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
break
}
}

if r.Method == "OPTIONS" {
w.WriteHeader(http.StatusOK)
return
}

next.ServeHTTP(w, r)
})
}
}

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

  • Middleware должен вызывать next.ServeHTTP(w, r), чтобы передать управление следующему обработчику в цепочке
  • Если middleware не вызывает next, цепочка прерывается — это нормально для middleware авторизации или rate limiting
  • Порядок применения middleware важен: recovery должен быть первым, чтобы перехватить паники в любом нижележащем слое
  • Используйте context.Context для передачи данных между middleware и обработчиками
  • Для перехвата статуса ответа используйте обёртку ResponseWriter

Вопрос 7. Что выведет программа с горутиной и бесконечным циклом?

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

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

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

Кандидат дал в целом верный ответ. Однако формулировка «кооперативная многозадачность» не совсем точна для Go — стоит разобрать это подробнее, так как на собеседованиях часто углубляются в детали.

Модель планирования Go — M:N (гибридная)

Go использует модель M:N scheduling: M горутин распределяются по N системным потокам ОС (по умолчанию N = GOMAXPROCS, обычно равно числу ядер CPU). Планировщик Go — это кооперативный с элементами вытеснения.

До Go 1.14 планировщик был полностью кооперативным: горутина отдавала управление только в определённых точках — при вызове каналов, syscall, выделении памяти (function call boundary). Бесконечный цикл без таких точек мог заблокировать весь поток, а если таких горутин было столько, сколько ядер — вся программа зависала.

Начиная с Go 1.14, планировщик поддерживает вытеснение по сигналу (signal-based preemption). Горутина, выполняющаяся дольше 10 мкс, может быть вытеснена. Однако цикл без вызовов функций всё ещё может вызвать проблемы.

Пример проблемного кода

func main() {
go func() {
for {
// Бесконечный цикл без вызовов функций
// До Go 1.14 мог заблокировать поток
}
}()

fmt.Println("main завершилась")
// Если main завершится — все горутины убиваются
}

Когда программа завершится, а когда зависнет

Если основная горутина (main) завершается, программа завершается полностью, независимо от того, что делают другие горутины. Это важно: Go не ждёт завершения горутин.

Если же main не завершается (например, ждёт из канала), а есть горутина с чистым бесконечным циклом без вызовов функций — на многопроцессорной системе (GOMAXPROCS > 1) это не заблокирует программу, потому что другие потоки продолжат работать. Но на GOMAXPROCS=1 до Go 1.14 это привело бы к deadlock.

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

go func() {
for {
// Явная передача управления планировщику
runtime.Gosched()

// Или любая операция, являющаяся точкой переключения:
// - операции с каналами
// - time.Sleep
// - syscall
// - вызов функции (с Go 1.14 — не гарантирует вытеснение)
}
}()

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

  • Бесконечный цикл в горутине — не проблема, если main завершается быстрее
  • Бесконечный цикл без точек кооперативного переключения может заблокировать поток на системах с одним ядром или при GOMAXPROCS=1
  • Для long-running горутин всегда добавляйте механизм выхода: канал отмены, context.Context, done канал
  • Используйте runtime.Gosched() в CPU-bound циклах, чтобы быть вежливым к другим горутинам
// Правильный паттерн для long-running горутины
func worker(ctx context.Context, ch <-chan Task) {
for {
select {
case <-ctx.Done():
return // корректное завершение
case task := <-ch:
process(task)
}
}
}

Вопрос 8. Как работает планировщик Go? Кто отвечает за переключение контекста между горутинами и что происходит при блокирующем системном вызове?

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

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

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

Кандидат дал корректный ответ. Ниже приведено более глубокое объяснение архитектуры планировщика, которое ожидается на позициях выше среднего уровня.

Архитектура M:N планировщика

Go использует модель M:N scheduling, где:

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

Каждый P имеет локальную очередь горутин (runqueue) и привязан к одному системному потоку M. P — это контекст выполнения, необходимый для запуска горутин. Без P горутина не может выполняться.

┌─────────────────────────────────────────────┐
│ Global Run Queue │
│ [G1] [G2] [G3] [G4] [G5] │
└─────────────────────────────────────────────┘
│ │ │
┌────▼────┐ ┌────▼────┐ ┌────▼────┐
│ P0 │ │ P1 │ │ P2 │
│ [G][G] │ │ [G][G] │ │ [G][G] │
└────┬────┘ └────┬────┘ └────┬────┘
│ │ │
┌────▼────┐ ┌────▼────┐ ┌────▼────┐
│ M0 │ │ M1 │ │ M2 │
│ OS │ │ OS │ │ OS │
│ Thread │ │ Thread │ │ Thread │
└─────────┘ └─────────┘ └─────────┘

Точки переключения контекста

Планировщик Go переключает горутины в следующих точках:

  • Операции с каналами (send/recv) — горутина блокируется, если канал пуст или полон
  • time.Sleep — горутина паркуется на таймере
  • syscall — при блокирующих вызовах
  • runtime.Gosched() — явная передача управления
  • Вытеснение по таймеру (с Go 1.14) — горутина, выполняющаяся дольше 10 мкс, может быть вытеснена по сигналу SIGURG
  • Вызов функций — при проверке stack boundary (stack growth)

Что происходит при блокирующем syscall

Это ключевой момент, отличающий Go от чисто кооперативных систем:

  1. Горутина G вызывает блокирующий syscall (например, чтение файла, сетевой вызов)
  2. Системный поток M, на котором выполнялась G, блокируется в ядре ОС
  3. Планировщик Go обнаруживает это (через нотификацию от netpoller или при входе в syscall)
  4. P (процессор) отсоединяется от заблокированного M
  5. P присоединяется к другому существующему M или создаётся новый системный поток M
  6. Другие горутины из очереди P продолжают выполнение на новом M
  7. Когда syscall завершается, горутина G помещается в глобальную очередь и ждёт своего P
// Пример: сетевой вызов — не блокирует поток полностью
// Go использует epoll/kqueue/IOCP через netpoller
func handler(w http.ResponseWriter, r *http.Request) {
// Этот сетевой вызов НЕ блокирует системный поток
// Go переводит горутину в состояние ожидания и использует
// неблокирующий I/O через netpoller
resp, err := http.Get("https://api.example.com/data")
// ...
}

Work stealing

Когда у P заканчиваются горутины в локальной очереди, он не простаивает. Планировщик применяет стратегию work stealing:

  1. Сначала проверяется глобальная очередь
  2. Затем «крадёт» половину горутин у случайного другого P
  3. Если и там пусто — поток M засыпает до появления работы

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

GOMAXPROCS

// По умолчанию равно числу логических ядер CPU
// Можно изменить для CPU-bound или I/O-bound задач
runtime.GOMAXPROCS(runtime.NumCPU())

// Для CPU-bound задач: GOMAXPROCS = NumCPU
// Для I/O-bound задач (много сетевых вызовов): можно увеличить

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

  • Горутины дешёвые: начальный стек ~2 КБ, тысячи горутин — это нормально
  • Блокирующие syscall не убивают производительность, потому что Go создаёт новые потоки
  • Чисто CPU-bound код без вызовов функций может мешать планировщику — используйте runtime.Gosched()
  • Количество P (GOMAXPROCS) определяет степень параллелизма для CPU-bound задач

Вопрос 9. В каком порядке выведутся данные при использовании WaitGroup и горутин с time.Sleep(2 секунды)?

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

Ответ собеседния: Правильный. Кандидат предположил, что сначала выполнится горутина без WaitGroup (Print 1), так как она запустится первой из-за задержки в 2 секунды в другой горутине. В целом правильно понял порядок выполнения.

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

Кандидат дал верное качественное объяснение. Ниже приведён полный разбор с кодом, чтобы дать точный ответ на этот классический вопрос собеседования.

Типичный код из вопроса

package main

import (
"fmt"
"sync"
"time"
)

func main() {
var wg sync.WaitGroup

wg.Add(1)
go func() {
defer wg.Done()
time.Sleep(2 * time.Second)
fmt.Println("Print 2 (с WaitGroup)")
}()

wg.Wait() // основная горутина блокируется здесь

go func() {
fmt.Println("Print 1 (без WaitGroup)")
}()

time.Sleep(100 * time.Millisecond) // чтобы дать время последней горутине
}

Порядок выполнения

Вывод будет:

Print 2 (с WaitGroup)
Print 1 (без WaitGroup)

Почему именно так

  1. wg.Add(1) — счётчик WaitGroup устанавливается в 1
  2. Запускается горутина с time.Sleep(2 * time.Second) — она паркуется на 2 секунды
  3. wg.Wait()основная горутина блокируется и ждёт, пока счётчик станет нулём
  4. Через 2 секунды горутина просыпается, выводит «Print 2», вызывает wg.Done() — счётчик становится 0
  5. wg.Wait() разблокирует основную горутину
  6. Запускается вторая горутина без WaitGroup, которая выводит «Print 1»

Ключевой момент: wg.Wait() — это блокирующая операция. Основная горутина останавливается и ждёт. Без wg.Wait() порядок был бы непредсказуем, а main мог бы завершиться до выполнения горутин.

Распространённая ошибка: забыть wg.Wait()

func main() {
var wg sync.WaitGroup

wg.Add(1)
go func() {
defer wg.Done()
fmt.Println("эта строка может не успеть вывестись")
}()

// main завершается сразу — горутина убивается
// wg.Wait() отсутствует!
}

Распространённая ошибка: забыть wg.Done()

func main() {
var wg sync.WaitGroup

wg.Add(1)
go func() {
// забыли wg.Done() — deadlock!
fmt.Println("done")
}()

wg.Wait() // будет ждать вечно
}

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

func processItems(items []Item) {
var wg sync.WaitGroup

for _, item := range items {
wg.Add(1)
go func(i Item) { // передаём параметр, чтобы избежать closure-бага
defer wg.Done()
process(i)
}(item)
}

wg.Wait() // ждём завершения всех горутин
}

Closure-баг с циклом

// НЕПРАВИЛЬНО — все горутины увидят последнее значение item
for _, item := range items {
wg.Add(1)
go func() {
defer wg.Done()
fmt.Println(item) // item — это переменная цикла, общая для всех горутин
}()
}

// ПРАВИЛЬНО — передаём item как параметр
for _, item := range items {
wg.Add(1)
go func(i Item) {
defer wg.Done()
fmt.Println(i) // своя копия для каждой горутины
}(item)
}

Когда использовать WaitGroup vs каналы

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

func processWithChannels(items []Item) []Result {
results := make(chan Result, len(items))

for _, item := range items {
go func(i Item) {
results <- processItem(i)
}(item)
}

var output []Result
for range items {
output = append(output, <-results)
}

return output
}

Вопрос 10. Что произойдёт при запуске 5000 горутин и какая версия Go используется?

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

Ответ собеседника: Правильный. Кандидат ответил, что выведутся числа от 1 до 5000 в разном порядке, и верно назвал версию Go 1.22.

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

Кандидат дал правильный ответ. Однако этот вопрос обычно сопровождается конкретным кодом, и важно понимать нюансы. Разберём типичный вариант этого вопроса.

Типичный код

package main

import "fmt"

func main() {
for i := 1; i <= 5000; i++ {
go func() {
fmt.Println(i)
}()
}
}

Что произойдёт

Этот код содержит классическую closure-ловушку. Все 5000 горутин захватывают одну и ту же переменную i по ссылке, а не по значению. К моменту, когда горутины начнут выполняться, цикл уже завершится и i будет равно 5001. В результате:

  • Большинство горутин выведут 5001
  • Порядок вывода непредсказуем
  • Некоторые числа могут вообще не вывестись из-за гонки данных (data race)
  • Программа завершится до того, как все горутины успеют вывести результат

Исправленный вариант

func main() {
for i := 1; i <= 5000; i++ {
go func(n int) { // передаём значение как параметр
fmt.Println(n)
}(i)
}

// Даём время горутинам выполниться
time.Sleep(time.Second)
}

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

Важная деталь: main не ждёт горутин

В обоих вариантах main завершается сразу после запуска горутин. Программа убивает все горутины при выходе из main. Для корректного ожидания нужен WaitGroup:

func main() {
var wg sync.WaitGroup

for i := 1; i <= 5000; i++ {
wg.Add(1)
go func(n int) {
defer wg.Done()
fmt.Println(n)
}(i)
}

wg.Wait() // ждём завершения всех 5000 горутин
}

5000 горутин — это много?

Нет, 5000 горутин — это абсолютно нормальное количество для Go. Каждая горутина начинается со стека ~2 КБ, так что 5000 горутин займут примерно 10 МБ памяти. Go способен запускать сотни тысяч и даже миллионы горутин на одном сервере.

Версия Go и особенности циклов

Начиная с Go 1.22 (выпущен в феврале 2024), поведение переменных цикла изменилось. В Go 1.22+ каждая итерация цикла for создаёт новую переменную, а не переиспользует ту же. Это значит, что в Go 1.22:

for i := 1; i <= 5000; i++ {
go func() {
fmt.Println(i) // В Go 1.22: каждая горутина видит своё значение i
}()
}

Этот код корректен в Go 1.22+ и выведет числа от 1 до 5000 в произвольном порядке. Однако передача параметра явно остаётся лучшей практикой — код становится понятнее и не зависит от версии языка.

Проверка на race condition

go run -race main.go

Флаг -race включает race detector, который обнаруживает гонки данных в runtime. Это обязательный инструмент при разработке конкурентного кода.

Вопрос 11. Как обеспечить потокобезопасность при работе с общим ресурсом из разных горутин? Что такое атомики и когда их использовать?

Таймкод: 00:44:22

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

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

Проблема гонки данных

Когда несколько горутин одновременно читают и пишут в общую переменную без синхронизации, возникает гонка данных (data race). Операция counter++ на уровне процессора состоит из трёх шагов: чтение, инкремент, запись. Если две горутины выполняют эти шаги параллельно, результат будет некорректным.

// НЕПРАВИЛЬНО — гонка данных
func main() {
var counter int
var wg sync.WaitGroup

for i := 0; i < 1000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
counter++ // data race!
}()
}

wg.Wait()
fmt.Println(counter) // результат < 1000, каждый раз разный
}

Решение 1: sync.Mutex

Мьютекс обеспечивает эксклюзивный доступ к критической секции. Только одна горутина может удерживать мьютекс в каждый момент времени.

// ПРАВИЛЬНО — через мьютекс
func main() {
var counter int
var mu sync.Mutex
var wg sync.WaitGroup

for i := 0; i < 1000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
mu.Lock()
counter++
mu.Unlock()
}()
}

wg.Wait()
fmt.Println(counter) // всегда 1000
}

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

type SafeCounter struct {
mu sync.RWMutex
count int
}

func (c *SafeCounter) Inc() {
c.mu.Lock() // эксклюзивная блокировка для записи
defer c.mu.Unlock()
c.count++
}

func (c *SafeCounter) Value() int {
c.mu.RLock() // разделяемая блокировка для чтения
defer c.mu.RUnlock()
return c.count
}

Решение 2: sync/atomic — атомарные операции

Атомики — это операции, которые выполняются за один неделимый шаг на уровне процессора. Они используют специальные инструкции CPU (например, LOCK XADD на x86) и не требуют блокировки ОС. Это делает их значительно быстрее мьютексов для простых операций.

// ПРАВИЛЬНО — через атомики
func main() {
var counter int64 // атомики работают с конкретными типами
var wg sync.WaitGroup

for i := 0; i < 1000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
atomic.AddInt64(&counter, 1) // атомарный инкремент
}()
}

wg.Wait()
fmt.Println(atomic.LoadInt64(&counter)) // всегда 1000
}

Основные функции пакета sync/atomic

var val int64

// Атомарные операции с int64
atomic.AddInt64(&val, 1) // val += 1
atomic.AddInt64(&val, -1) // val -= 1
atomic.LoadInt64(&val) // атомарное чтение
atomic.StoreInt64(&val, 42) // атомарная запись
atomic.SwapInt64(&val, 100) // атомарная замена, возвращает старое значение

// Compare-and-swap (CAS) — основа lock-free алгоритмов
old := atomic.LoadInt64(&val)
if atomic.CompareAndSwapInt64(&val, old, old+1) {
// Успешно: val был равен old, теперь old+1
} else {
// Неуспешно: другая горутина изменила val
}

// Аналогичные функции для int32, uint32, uint64, uintptr
atomic.AddUint32(&val32, 1)
atomic.CompareAndSwapPointer(&ptr, oldPtr, newPtr)

Решение 3: Каналы — идиоматический подход Go

Go пропагандирует принцип: «Не общайтесь через разделяемую память, а разделяйте память через коммуникацию». Каналы — встроенный механизм синхронизации.

// ПРАВИЛЬНО — через канал (worker pool паттерн)
func main() {
counter := make(chan int, 1)
counter <- 0 // начальное значение

var wg sync.WaitGroup

for i := 0; i < 1000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
val := <-counter // получаем текущее значение
counter <- val + 1 // отправляем обратно
}()
}

wg.Wait()
fmt.Println(<-counter) // 1000
}

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

СценарийРекомендация
Простой счётчик (int)sync/atomic — максимальная производительность
Сложная структура данныхsync.Mutex — гибкость
Много читателей, мало писателейsync.RWMutex
Передача данных между горутинамиКаналы
Lock-free структуры данныхsync/atomic + CAS
Однократная инициализацияsync.Once

sync.Once — для однократной инициализации

var (
db *sql.DB
once sync.Once
)

func GetDB() *sql.DB {
once.Do(func() {
db = initDB() // выполнится ровно один раз, даже из 1000 горутин
})
return db
}

sync.Map — потокобезопасная мапа

Для случаев, когда горутины работают с разными ключами (редко пересекаются), sync.Map может быть быстрее, чем map + Mutex:

var m sync.Map

// Запись
m.Store("key", "value")

// Чтение
if val, ok := m.Load("key"); ok {
fmt.Println(val.(string))
}

// Удаление
m.Delete("key")

// Итерация
m.Range(func(key, value interface{}) bool {
fmt.Println(key, value)
return true // false — остановить итерацию
})

Бенчмарк: атомик vs мьютекс

func BenchmarkAtomic(b *testing.B) {
var counter int64
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
atomic.AddInt64(&counter, 1)
}
})
}

func BenchmarkMutex(b *testing.B) {
var counter int64
var mu sync.Mutex
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
mu.Lock()
counter++
mu.Unlock()
}
})
}

Результат: атомик обычно в 2-5 раз быстрее мьютекса для простых операций с int64. Однако для сложных структур данных разница не имеет значения — мьютекс единственный вариант.

Вопрос 12. Что такое канал в Go? Что будет при записи в небуферизированный канал без читателя? Как избежать дедлока? Что будет при записи в закрытый канал и при чтении из закрытого канала?

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

Ответ собеседника: Неполный. Кандидат описал канал как способ передачи данных между горутинами, упомянул буферизированные каналы. Верно ответил, что запись в небуферизированный канал без читателя вызовет дедлок. Предложил использовать буферизированный канал как решение. Не сразу разобрался — потребовались подсказки. Верно ответил, что запись в закрытый канал вызывает панику, а чтение из закрытого канала возвращает zero value и ok=false.

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

Кандидат в итоге дал правильные ответы на все подвопросы, но потребовались подсказки. Ниже систематизированный разбор.

Что такое канал

Канал (channel) — это типизированная труба для передачи данных между горутинами. Каналы обеспечивают синхронизацию: отправитель блокируется до тех пор, пока получатель не прочитает значение, и наоборот. Каналы реализуют модель CSP (Communicating Sequential Processes).

// Создание канала
ch := make(chan int) // небуферизированный
ch := make(chan int, 10) // буферизированный на 10 элементов

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

Типы каналов по направлению

// Двунаправленный (по умолчанию)
ch := make(chan int)

// Только для отправки
var send chan<- int = ch

// Только для получения
var recv <-chan int = ch

// Полезно в сигнатурах функций для явного указания намерений
func producer(ch chan<- int) {
ch <- 42
close(ch)
}

func consumer(ch <-chan int) {
for val := range ch {
fmt.Println(val)
}
}

Небуферизированный канал: запись без читателя = deadlock

Небуферизированный канал требует, чтобы отправитель и получатель были готовы одновременно. Это называется «rendezvous» — точка встречи.

func main() {
ch := make(chan int)
ch <- 42 // deadlock! нет читателя — основная горутина блокируется навсегда
fmt.Println("никогда не выполнится")
}

Go runtime обнаруживает, что все горутины заблокированы, и выбрасывает панику: fatal error: all goroutines are asleep - deadlock!

Как избежать дедлока

Способ 1 — запустить читателя в отдельной горутине:

func main() {
ch := make(chan int)

go func() {
val := <-ch
fmt.Println("получено:", val)
}()

ch <- 42 // теперь есть читатель — работает
time.Sleep(time.Millisecond)
}

Способ 2 — использовать буферизированный канал:

func main() {
ch := make(chan int, 1) // буфер на 1 элемент
ch <- 42 // не блокируется, потому что есть место в буфере
fmt.Println("работает")
}

Способ 3 — использовать select с default (non-blocking отправка):

func main() {
ch := make(chan int)

select {
case ch <- 42:
fmt.Println("отправлено")
default:
fmt.Println("нет получателя — пропускаем")
}
}

Способ 4 — использовать контекст с таймаутом:

func main() {
ch := make(chan int)

ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()

select {
case ch <- 42:
fmt.Println("отправлено")
case <-ctx.Done():
fmt.Println("таймаут — получатель не ответил")
}
}

Запись в закрытый канал — паника

func main() {
ch := make(chan int, 1)
close(ch)
ch <- 42 // panic: send on closed channel
}

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

Чтение из закрытого канала

func main() {
ch := make(chan int, 2)
ch <- 1
ch <- 2
close(ch)

// Сначала читаем все буферизованные значения
fmt.Println(<-ch) // 1
fmt.Println(<-ch) // 2

// После опустошения буфера — zero value без блокировки
fmt.Println(<-ch) // 0 (zero value для int)

// С проверкой закрытия
val, ok := <-ch
fmt.Println(val, ok) // 0 false
}

Ключевое свойство: чтение из закрытого канала не блокируется, а немедленно возвращает zero value. Именно поэтому проверка ok важна — чтобы отличить «в канале реально лежит 0» от «канал закрыт».

Паттерн range по каналу

func main() {
ch := make(chan int, 3)
ch <- 1
ch <- 2
ch <- 3
close(ch) // обязательно закрыть, иначе range будет ждать вечно

for val := range ch {
fmt.Println(val) // 1, 2, 3 — затем цикл завершится
}
}

Паттерн для безопасного закрытия канала

// Один отправитель — просто закрываем после отправки всех данных
func producer(ch chan<- int) {
for i := 0; i < 10; i++ {
ch <- i
}
close(ch) // отправитель всегда закрывает канал
}

// Несколько отправителей — используем WaitGroup
func multiProducer() <-chan int {
ch := make(chan int)
var wg sync.WaitGroup

for i := 0; i < 3; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
for j := 0; j < 5; j++ {
ch <- id*10 + j
}
}(i)
}

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

return ch
}

// Fan-out паттерн — несколько читателей
func fanOut(ch <-chan int, n int) []<-chan int {
channels := make([]<-chan int, n)
for i := 0; i < n; i++ {
channels[i] = processChannel(ch)
}
return channels
}

func processChannel(ch <-chan int) <-chan int {
out := make(chan int)
go func() {
defer close(out)
for val := range ch {
out <- val * 2
}
}()
return out
}

Правила работы с каналами

  • Никогда не закрывайте канал из получателя — это приведёт к панике, если другие отправители ещё пишут
  • Никогда не закрывайте канал дважды — паника
  • Никогда не пишите в закрытый канал — паника
  • Используйте val, ok := <-ch для проверки закрытия
  • Используйте for val := range ch для чтения до закрытия
  • Если канал nil — отправка и получение блокируются навсегда (это полезно в select)

Вопрос 13. Что такое select в Go? Как он работает? Что будет в примере с WaitGroup, закрытием канала и select?

Таймкод: 00:50:04

Ответ собеседника: Неполный. Кандидат верно описал select как аналог switch для каналов — позволяет выбрать один из готовых кейсов, при одновременной готовности нескольких — выбор случайный. Упомянул, что select блокирующий без default. В задаче с WaitGroup и закрытием канала не сразу понял проблему — потребовались подсказки. Верно ответил, что нужно сначала WaitGroup.Wait, потом закрывать канал, и что при чтении из закрытого канала (ok=false) нужно выходить из цикла.

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

Что такое select и как он работает

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

select {
case val := <-ch1:
fmt.Println("получено из ch1:", val)
case val := <-ch2:
fmt.Println("получено из ch2:", val)
case ch3 <- 42:
fmt.Println("отправлено в ch3")
case <-time.After(5 * time.Second):
fmt.Println("таймаут")
default:
fmt.Println("ни один канал не готов")
}

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

  • Без default — select блокируется до готовности хотя бы одного кейса
  • С default — select неблокирующий, выполняется default если ни один канал не готов
  • При одновременной готовности — случайный выбор среди готовых кейсов (не по порядку!)
  • nil канал — кейс с nil каналом игнорируется (никогда не срабатывает)

Паттерн с таймаутом

func fetchWithTimeout(url string) (string, error) {
resultCh := make(chan string, 1)

go func() {
resp, err := http.Get(url)
if err == nil {
resultCh <- resp.Status
}
}()

select {
case status := <-resultCh:
return status, nil
case <-time.After(3 * time.Second):
return "", errors.New("timeout")
}
}

Паттерн с отменой через контекст

func doWork(ctx context.Context) error {
for {
select {
case <-ctx.Done():
return ctx.Err() // корректная отмена
default:
// выполняем работу
if err := processChunk(); err != nil {
return err
}
}
}
}

Типичная задача с WaitGroup, закрытием канала и select

func main() {
ch := make(chan int)
var wg sync.WaitGroup

// Запускаем воркеров
for i := 0; i < 3; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
for j := 0; j < 5; j++ {
ch <- id*10 + j
}
}(i)
}

// Проблема: канал закрывается до завершения всех воркеров
go func() {
wg.Wait()
close(ch) // правильно: ждём всех, потом закрываем
}()

// Читаем из канала
for {
select {
case val, ok := <-ch:
if !ok {
fmt.Println("канал закрыт, выходим")
return
}
fmt.Println(val)
}
}
}

Частые ошибки

Ошибка 1: закрытие канала до завершения отправителей

// НЕПРАВИЛЬНО — panic: send on closed channel
go func() {
wg.Wait()
close(ch)
}()

close(ch) // закрыли раньше, чем воркеры закончили

Ошибка 2: бесконечный цикл без проверки ok

// НЕПРАВИЛЬНО — после закрытия канала будет бесконечно печатать 0
for {
select {
case val := <-ch: // без проверки ok
fmt.Println(val) // после закрытия: 0, 0, 0, 0...
}
}

Ошибка 3: утечка горутины

// НЕПРАВИЛЬНО — горутина будет висеть вечно
go func() {
for val := range ch {
process(val)
}
}()
// Если канал никогда не закроется — горутина утечёт

Паттерн graceful shutdown

func worker(ctx context.Context, jobs <-chan Job, results chan<- Result) {
for {
select {
case <-ctx.Done():
return // корректное завершение
case job, ok := <-jobs:
if !ok {
return // канал закрыт
}
results <- process(job)
}
}
}

Паттерн с nil каналом для отключения кейсов

var inputCh chan int = make(chan int)
var disabledCh chan int = nil // nil канал — кейс никогда не сработает

select {
case val := <-inputCh:
fmt.Println("input:", val)
case val := <-disabledCh: // этот кейс игнорируется
fmt.Println("disabled:", val)
}

Это полезно для динамического включения/выключения обработчиков в select без переписывания логики.

Паттерн Ticker для периодических задач

func periodicTask(ctx context.Context) {
ticker := time.NewTicker(5 * time.Second)
defer ticker.Stop()

for {
select {
case <-ctx.Done():


#### **Вопрос 14**. Напишите функцию Merge — слияние нескольких каналов в один. Как правильно закрывать выходной канал?

**Таймкод:** <YouTubeSeekTo id="rFkU-8GVnVI" time="01:00:35"/>

**Ответ собеседния:** **Неполный**. Кандидат начал реализовывать функцию: создал выходной канал, начал перебирать входные каналы и читать из них значения, записывая в выходной канал. Верно определил, что входные каналы закрываются не этой функцией, а выходной канал нужно закрыть. Предложил использовать WaitGroup для синхронизации. Однако реализация не была завершена из-за обрыва сессии.

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

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

**Базовая реализация Merge**

```go
func Merge(channels ...<-chan int) <-chan int {
out := make(chan int)
var wg sync.WaitGroup

// Для каждого входного канала запускаем горутину-читателя
for _, ch := range channels {
wg.Add(1)
go func(c <-chan int) {
defer wg.Done()
for val := range c {
out <- val
}
}(ch)
}

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

return out
}

Почему это работает корректно

  • Каждый входной канал читается в своей горутине через for val := range c — горутина завершится, когда входной канал будет закрыт
  • wg.Wait() блокируется до тех пор, пока все читатели не завершатся (то есть все входные каналы не будут закрыты и прочитаны)
  • Только после этого закрывается выходной канал — гарантия, что никакие данные не будут потеряны
  • Входные каналы не закрываются внутри Merge — это ответственность отправителей

Использование

func main() {
ch1 := make(chan int, 3)
ch2 := make(chan int, 3)
ch3 := make(chan int, 3)

// Заполняем каналы в отдельных горутинах
go func() {
defer close(ch1)
for i := 1; i <= 3; i++ {
ch1 <- i
}
}()

go func() {
defer close(ch2)
for i := 10; i <= 30; i += 10 {
ch2 <- i
}
}()

go func() {
defer close(ch3)
for i := 100; i <= 300; i += 100 {
ch3 <- i
}
}()

// Сливаем и читаем
for val := range Merge(ch1, ch2, ch3) {
fmt.Println(val) // 1, 2, 3, 10, 20, 30, 100, 200, 300 в произвольном порядке
}
}

Обобщённая версия с дженериками (Go 1.18+)

func Merge[T any](channels ...<-chan T) <-chan T {
out := make(chan T)
var wg sync.WaitGroup

for _, ch := range channels {
wg.Add(1)
go func(c <-chan T) {
defer wg.Done()
for val := range c {
out <- val
}
}(ch)
}

go func() {
wg.Wait()
close(out)
}()

return out
}

Версия с контекстом для отмены

func MergeWithContext[T any](ctx context.Context, channels ...<-chan T) <-chan T {
out := make(chan T)
var wg sync.WaitGroup

for _, ch := range channels {
wg.Add(1)
go func(c <-chan T) {
defer wg.Done()
for {
select {
case <-ctx.Done():
return // отмена — прекращаем чтение
case val, ok := <-c:
if !ok {
return // канал закрыт
}
select {
case out <- val:
case <-ctx.Done():
return
}
}
}
}(ch)
}

go func() {
wg.Wait()
close(out)
}()

return out
}

Версия с буферизацией для повышения производительности

func MergeBuffered[T any](bufSize int, channels ...<-chan T) <-chan T {
out := make(chan T, bufSize)
var wg sync.WaitGroup

for _, ch := range channels {
wg.Add(1)
go func(c <-chan T) {
defer wg.Done()
for val := range c {
out <- val
}
}(ch)
}

go func() {
wg.Wait()
close(out)
}()

return out
}

Частые ошибки при реализации Merge

Ошибка 1: закрытие выходного канала без ожидания

// НЕПРАВИЛЬНО — данные могут быть потеряны
func MergeBroken(channels ...<-chan int) <-chan int {
out := make(chan int)

for _, ch := range channels {
go func(c <-chan int) {
for val := range c {
out <- val
}
}(ch)
}

close(out) // закрыли сразу — горутины ещё пишут!
return out
}

Ошибка 2: попытка закрыть входные каналы

// НЕПРАВИЛЬНО — panic: close of receive-only channel
func MergeBroken2(channels ...<-chan int) <-chan int {
out := make(chan int)

for _, ch := range channels {
go func(c <-chan int) {
defer close(c) // panic! канал только для чтения
for val := range c {
out <- val
}
}(ch)
}

return out
}

Ошибка 3: утечка горутины при отсутствии закрытия входных каналов

// НЕПРАВИЛЬНО — если входной канал не закрывается, горутина висит вечно
func MergeBroken3(channels ...<-chan int) <-chan int {
out := make(chan int)

for _, ch := range channels {
go func(c <-chan int) {
for { // бесконечный цикл без range — не завершится при закрытии канала
val := <-ch
out <- val
}
}(ch)
}

return out
}

Паттерны fan-in и fan-out

Merge — это реализация паттерна fan-in (сведение нескольких источников в один). Противоположный паттерн — fan-out (распределение одного источника на нескольких обработчиков):

// Fan-out: один канал → несколько обработчиков
func FanOut[T any](in <-chan T, n int) []<-chan T {
channels := make([]<-chan T, n)

for i := 0; i < n; i++ {
channels[i] = processChannel(in)
}

return channels
}

func processChannel[T any](in <-chan T) <-chan T {
out := make(chan T)
go func() {
defer close(out)
for val := range in {
out <- transform(val)
}
}()
return out
}

// Fan-in + Fan-out вместе
func Pipeline(in <-chan int, workers int) <-chan int {
// Fan-out: распределяем по воркерам
channels := FanOut(in, workers)
// Fan-in: собираем результаты
return Merge(channels...)
}

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

func TestMerge(t *testing.T) {
ch1 := make(chan int, 3)
ch2 := make(chan int, 3)

ch1 <- 1; ch1 <- 2; ch1 <- 3; close(ch1)
ch2 <- 10; ch2 <- 20; ch2 <- 30; close(ch2)

var results []int
for val := range Merge(ch1, ch2) {
results = append(results, val)
}

sort.Ints(results)
expected := []int{1, 2, 3, 10, 20, 30}

if !reflect.DeepEqual(results, expected) {
t.Errorf("expected %v, got %v", expected, results)
}
}

Ключевые принципы, которые проверяет этот вопрос

  • Понимание жизненного цикла канала: кто открывает, кто закрывает
  • Умение использовать WaitGroup для синхронизации завершения горутин
  • Понимание, что for val := range ch завершится при закрытии канала
  • Осознание того, что выходной канал должен быть закрыт после завершения всех входных
  • Корректная передача канала как параметра горутины (избегание closure-бага)

Вопрос 15. Как устроен канал изнутри в Go? Как реализован буферизированный канал?

Таймкод: 01:10:05

Ответ собеседника: Неполный. Кандидат предположил, что канал — это структура с полями: буфер, флаг открытости/закрытости (bool или int), тип записываемых значений. Также упомянул, что буферизированный канал реализован как кольцевой буфер. В целом правильное понимание, хотя и неполное.

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

Кандидат верно назвал основные компоненты. Ниже подробный разбор внутреннего устройства канала на основе исходного кода Go runtime (src/runtime/chan.go).

Структура hchan

Канал в runtime представлен структурой hchan. Вот ключевые поля (упрощённо):

type hchan struct {
qcount uint // текущее количество элементов в буфере
dataqsiz uint // размер буфера (0 для небуферизированных)
buf unsafe.Pointer // указатель на кольцевой буфер
elemsize uint16 // размер одного элемента
closed uint32 // флаг закрытия (0 = открыт, 1 = закрыт)
elemtype *_type // тип элементов

sendx uint // индекс для следующей записи в буфер
recvx uint // индекс для следующего чтения из буфера

recvq waitq // очередь ожидающих читателей (sudog)
sendq waitq // очередь ожидающих отправителей (sudog)

lock mutex // мьютекс для защиты всех полей
}

Кольцевой буфер (ring buffer)

Буферизированный канал использует кольцевой буфер фиксированного размера. Два индекса — sendx (куда писать) и recvx (откуда читать) — циклически перемещаются по буферу.

Буфер размером 5, qcount=3:

[10] [20] [30] [ ] [ ]
^ ^
recvx sendx

После записи 40:
[10] [20] [30] [40] [ ]
^ ^
recvx sendx

После чтения (получаем 10):
[ ] [20] [30] [40] [ ]
^ ^
recvx sendx
// Упрощённая логика отправки в буфер
func chanSend(ch *hchan, ep unsafe.Pointer) {
lock(&ch.lock)

// Если есть ожидающий читатель — отдаём ему напрямую
if !ch.recvq.empty() {
sg := ch.recvq.dequeue()
recv(ch, sg, ep)
unlock(&ch.lock)
return
}

// Если буфер не полон — пишем в буфер
if ch.qcount < ch.dataqsiz {
// Копируем данные в буфер по индексу sendx
typedmemmove(ch.elemtype, chanbuf(ch, ch.sendx), ep)
ch.sendx++
if ch.sendx == ch.dataqsiz {
ch.sendx = 0 // заворачиваем в начало
}
ch.qcount++
unlock(&ch.lock)
return
}

// Буфер полон — блокируем отправителя
// (добавляем sudog в sendq и паркуем горутину)
}

Очереди ожидания (sudog)

Когда горутина не может выполнить операцию с каналом немедленно, она оборачивается в структуру sudog и помещается в соответствующую очередь:

type sudog struct {
g *goroutine // указатель на горутину

next *sudog // следующий в очереди
prev *sudog // предыдущий в очереди

elem unsafe.Pointer // указатель на данные для передачи

// ...
}

type waitq struct {
first *sudog // первый в очереди
last *sudog // последний в очереди
}

Небуферизированный канал

Для небуферизированного канала dataqsiz = 0 и buf = nil. Данные передаются напрямую от отправителя к получателю без промежуточного хранения:

// Упрощённая логика отправки в небуферизированный канал
func chanSendDirect(ch *hchan, ep unsafe.Pointer) {
lock(&ch.lock)

// Если есть ожидающий читатель — копируем данные напрямую
if sg := ch.recvq.dequeue(); sg != nil {
// Копируем данные из отправителя в память получателя
typedmemmove(ch.elemtype, sg.elem, ep)
// Будим горутину-получатель
goready(sg.g)
unlock(&ch.lock)
return
}

// Нет читателя — паркуем отправителя
// (добавляем sudog в sendq, горутина засыпает)
sg := acquireSudog()
sg.elem = ep
sg.g = getg()
ch.sendq.enqueue(sg)
goparkunlock(&ch.lock) // горутина засыпает
}

Механизм передачи данных

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

ch := make(chan []int)
data := []int{1, 2, 3}
go func() {
ch <- data // копируется слайс-заголовок (ptr, len, cap), но не underlying array
}()

Lock-free операции

Операции с каналом защищены мьютексом (lock mutex в hchan). Каждая операция send/recv/close захватывает этот мьютекс. Это значит, что канал — не lock-free структура данных, но мьютекс удерживается очень короткое время (только для изменения состояния канала и копирования данных).

Визуализация полного цикла

Отправитель вызывает ch <- val:

├─ Захват мьютекса
├─ Канал закрыт? → panic
├─ Есть ожидающий читатель (recvq)?
│ ├─ ДА → копируем данные читателю, будим его, отпускаем мьютекс
│ └─ НЕТ → проверяем буфер
├─ Буфер не полон?
│ ├─ ДА → пишем в буфер, отпускаем мьютекс
│ └─ НЕТ → добавляем sudog в sendq, паркуем горутину
└─ (горутина спит, пока её не разбудит читатель)

Получатель вызывает val := <-ch:

├─ Захват мьютекса
├─ Канал закрыт и буфер пуст? → возвращаем zero value
├─ Есть ожидающий отправитель (sendq)?
│ ├─ ДА → забираем данные у отправителя, будим его, отпускаем мьютекс
│ └─ НЕТ → проверяем буфер
├─ Буфер не пуст?
│ ├─ ДА → читаем из буфера, отпускаем мьютекс
│ └─ НЕТ → добавляем sudog в recvq, паркуем горутину
└─ (горутина спит, пока её не разбудит отправитель)

Почему канал безопасен для конкурентного доступа

Весь доступ к полям hchan защищён мьютексом. Это единая точка синхронизации, которая гарантирует атомарность всех операций. Горутины не могут одновременно модифицировать состояние канала — они выстраиваются в очередь на мьютексе.

Выделение памяти

Канал выделяется в куче через makechan:

// Упрощённо
func makechan(t *chantype, size int64) *hchan {
var c *hchan

if size == 0 {
// Небуферизированный — только структура hchan
c = (*hchan)mallocgc(sizeof(hchan), nil, true)
} else {
// Буферизированный — hchan + кольцевой буфер
c = (*hchan)mallocgc(sizeof(hchan), nil, true)
c.buf = mallocgc(elem.size * size, nil, true)
c.dataqsiz = uint(size)
}

return c
}

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

Операции с каналом дороже, чем атомарные операции с памятью, потому что включают захват мьютекса и потенциальное переключение горутин. Для простых счётчиков sync/atomic будет быстрее. Каналы стоит использовать, когда нужна передача данных или сложная координация между горутинами, а не просто инкремент счётчика.

Вопрос 16. Реализуйте Worker Pool с методами Start и Stop, который принимает канал задач и выводит их на экран с заданным количеством воркеров. Как правильно обработать шатдаун с использованием контекста?

Таймкод: 01:11:58

Ответ собеседника: Неполный. Кандидат начал реализацию: создал структуру пула с каналом задач и количеством воркеров, начал писать метод Start с запуском горутин-воркеров, использовал WaitGroup для синхронизации. Предложил использовать select для чтения из канала. При обсуждении шатдауна кандидат верно предложил использовать контекст (context) для отмены, упомянул ctx.Done(). Однако реализация не была завершена из-за обрыва сессии.

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

Кандидат верно определил все ключевые компоненты: структура с каналом и числом воркеров, WaitGroup, select, context для шатдауна. Ниже полная реализация.

Базовая реализация Worker Pool

package workerpool

import (
"context"
"fmt"
"sync"
)

// Task — единица работы
type Task struct {
ID int
Data string
}

// Pool — пул воркеров
type Pool struct {
numWorkers int
tasks <-chan Task
ctx context.Context
cancel context.CancelFunc
wg sync.WaitGroup
}

// New создаёт новый пул воркеров
func New(numWorkers int, tasks <-chan Task, parentCtx context.Context) *Pool {
ctx, cancel := context.WithCancel(parentCtx)
return &Pool{
numWorkers: numWorkers,
tasks: tasks,
ctx: ctx,
cancel: cancel,
}
}

// Start запускает воркеров
func (p *Pool) Start() {
for i := 0; i < p.numWorkers; i++ {
p.wg.Add(1)
go p.worker(i)
}
}

// worker — горутина, обрабатывающая задачи
func (p *Pool) worker(id int) {
defer p.wg.Done()

for {
select {
case <-p.ctx.Done():
fmt.Printf("worker %d: завершаюсь по отмене контекста\n", id)
return
case task, ok := <-p.tasks:
if !ok {
fmt.Printf("worker %d: канал задач закрыт, завершаюсь\n", id)
return
}
p.processTask(id, task)
}
}
}

// processTask обрабатывает одну задачу
func (p *Pool) processTask(workerID int, task Task) {
fmt.Printf("worker %d: обрабатываю задачу %d: %s\n", workerID, task.ID, task.Data)
}

// Stop корректно завершает работу пула
func (p *Pool) Stop() {
p.cancel() // сигнализируем воркерам остановиться
p.wg.Wait() // ждём завершения всех воркеров
}

Использование

func main() {
tasks := make(chan Task, 100)

pool := New(5, tasks, context.Background())
pool.Start()

// Отправляем задачи
go func() {
for i := 1; i <= 20; i++ {
tasks <- Task{ID: i, Data: fmt.Sprintf("task-%d", i)}
}
close(tasks) // закрываем канал задач — воркеры завершатся
}()

// Ждём завершения
pool.Stop()
fmt.Println("пул остановлен")
}

Расширенная версия с таймаутом и graceful shutdown

package workerpool

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

type Task struct {
ID int
Data string
}

type Result struct {
TaskID int
WorkerID int
Output string
Err error
}

type Pool struct {
numWorkers int
tasks <-chan Task
results chan<- Result
ctx context.Context
cancel context.CancelFunc
wg sync.WaitGroup
shutdownTimeout time.Duration
}

func New(numWorkers int, tasks <-chan Task, results chan<- Result, parentCtx context.Context) *Pool {
ctx, cancel := context.WithCancel(parentCtx)
return &Pool{
numWorkers: numWorkers,
tasks: tasks,
results: results,
ctx: ctx,
cancel: cancel,
shutdownTimeout: 30 * time.Second,
}
}

func (p *Pool) Start() {
for i := 0; i < p.numWorkers; i++ {
p.wg.Add(1)
go p.worker(i)
}
}

func (p *Pool) worker(id int) {
defer p.wg.Done()

for {
select {
case <-p.ctx.Done():
return
case task, ok := <-p.tasks:
if !ok {
return
}
result := p.processTask(id, task)
select {
case p.results <- result:
case <-p.ctx.Done():
return
}
}
}
}

func (p *Pool) processTask(workerID int, task Task) Result {
// Имитация работы
time.Sleep(10 * time.Millisecond)
return Result{
TaskID: task.ID,
WorkerID: workerID,
Output: fmt.Sprintf("processed: %s", task.Data),
}
}

// Stop с таймаутом — принудительное завершение
func (p *Pool) Stop() error {
p.cancel()

done := make(chan struct{})
go func() {
p.wg.Wait()
close(done)
}()

select {
case <-done:
return nil // все воркеры завершились
case <-time.After(p.shutdownTimeout):
return fmt.Errorf("shutdown timeout exceeded")
}
}

Версия с буферизированным каналом задач и возможностью добавления задач

type Pool struct {
numWorkers int
tasks chan Task
ctx context.Context
cancel context.CancelFunc
wg sync.WaitGroup
}

func New(numWorkers, taskQueueSize int, parentCtx context.Context) *Pool {
ctx, cancel := context.WithCancel(parentCtx)
return &Pool{
numWorkers: numWorkers,
tasks: make(chan Task, taskQueueSize),
ctx: ctx,
cancel: cancel,
}
}

func (p *Pool) Start() {
for i := 0; i < p.numWorkers; i++ {
p.wg.Add(1)
go p.worker(i)
}
}

// Submit добавляет задачу в очередь (неблокирующий при наличии места)
func (p *Pool) Submit(task Task) error {
select {
case <-p.ctx.Done():
return fmt.Errorf("pool is shutting down")
case p.tasks <- task:
return nil
}
}

// SubmitBlocking добавляет задачу с ожиданием (блокирующий)
func (p *Pool) SubmitBlocking(ctx context.Context, task Task) error {
select {
case <-p.ctx.Done():
return fmt.Errorf("pool is shutting down")
case <-ctx.Done():
return ctx.Err()
case p.tasks <- task:
return nil
}
}

// Stop корректно завершает работу
func (p *Pool) Stop() {
close(p.tasks) // закрываем канал задач — воркеры прочитают остатки и завершатся
p.wg.Wait() // ждём завершения всех воркеров
}

// ForceStop немедленная остановка
func (p *Pool) ForceStop() {
p.cancel() // отменяем контекст — воркеры завершатся сразу
close(p.tasks)
p.wg.Wait()
}

Интеграция с OS signals для graceful shutdown

func main() {
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()

pool := New(10, 1000, ctx)
pool.Start()

// HTTP-сервер или другой источник задач
go func() {
for {
select {
case <-ctx.Done():
return
default:
task := fetchTask()
if err := pool.SubmitBlocking(ctx, task); err != nil {
return
}
}
}
}()

// Ждём сигнала завершения
<-ctx.Done()
fmt.Println("получен сигнал завершения, начинаю graceful shutdown")

pool.Stop()
fmt.Println("сервис остановлен")
}

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

func TestPool(t *testing.T) {
tasks := make(chan Task, 10)
results := make(chan Result, 10)

pool := New(3, tasks, results, context.Background())
pool.Start()

// Отправляем задачи
for i := 1; i <= 10; i++ {
tasks <- Task{ID: i, Data: fmt.Sprintf("task-%d", i)}
}
close(tasks)

// Собираем результаты
var collected []Result
for i := 0; i < 10; i++ {
results := <-results
collected = append(collected, results)
}

pool.Stop()

if len(collected) != 10 {
t.Errorf("expected 10 results, got %d", len(collected))
}
}

Ключевые принципы корректного шатдауна

  • Сначала закрываем источник задач (канал), чтобы воркеры прочитали оставшиеся задачи и завершились сами
  • Context cancellation — для немедленной остановки, если воркер заблокирован на длительной операции
  • WaitGroup — чтобы main горутина дождалась завершения всех воркеров
  • Таймаут на shutdown — защита от зависания, если какой-то воркер не завершается
  • Не забываем закрывать канал результатов — после завершения всех воркеров
  • Обработка OS signals — для production-сервисов, чтобы корректно завершаться по Ctrl+C или SIGTERM от orchestrator