Ошибки в Go: error wrapping, sentinel errors и %w
Разбираем обработку ошибок в Go с нуля: интерфейс error, sentinel errors, кастомные типы, fmt.Errorf с %w, errors.Is / errors.As / errors.Unwrap — и что спрашивают на собеседовании.
- 1. Интерфейс error
- Отсутствие ошибки — nil
- Собственный тип, реализующий error
- 2. Sentinel errors
- Прямое сравнение и почему его недостаточно
- Антипаттерн: сравнение по строке
- 3. Кастомные типы ошибок
- Поддержка цепочки через Unwrap
- 4. Оборачивание ошибок: fmt.Errorf и %w
- Анатомия оборачивания
- Несколько уровней оборачивания
- %v vs %w: правило выбора
- Антипаттерн: двойное оборачивание без контекста
- 5. errors.Is — проверка идентичности
- Кастомный Is
- Важный нюанс: только %w сохраняет цепочку
- 6. errors.As — извлечение типа
- errors.As vs type assertion
- Кастомный As
- 7. errors.Unwrap и поддержка деревьев ошибок
- Go 1.20: errors.Join и деревья ошибок
- Собственная реализация Unwrap() []error
- 8. Паттерны обработки ошибок
- Добавляй контекст при каждом подъёме
- Граница пакета: скрывай детали
- Единственная проверка: не проверяй на каждом уровне
- Сравнение с pkg/errors
- 9. Типичные вопросы на собеседовании
- 10. Итоги
Ошибки в Go — не исключения. Это обычные значения, которые функция возвращает наравне с результатом. Это принципиальный выбор дизайна языка: никаких скрытых путей выполнения, никаких try/catch, которые можно случайно пропустить. Либо ты явно обработал ошибку, либо передал её выше — и это видно прямо в коде.
Такой подход кажется многословным до тех пор, пока не начинаешь работать в production. В Go невозможно случайно «потерять» ошибку в стеке вызовов — компилятор заставит её либо использовать, либо явно проигнорировать через _. Это делает код предсказуемым и читаемым: каждый if err != nil — явная точка принятия решения.
С Go 1.13 добавили механизм оборачивания ошибок: %w в fmt.Errorf, errors.Is, errors.As, errors.Unwrap. Именно эта часть регулярно всплывает на собеседованиях — и именно её нужно понимать глубоко, а не только знать синтаксис. В этой статье разберём всё: от устройства интерфейса error до практических паттернов добавления контекста на каждом уровне стека. Если хочешь сначала освежить общую картину языка — посмотри 50 вопросов junior–middle Go-разработчика, а после этой статьи имеет смысл перейти к context в Go.
Тренируй обработку ошибок с AI-интервьюером
Реальные вопросы по error handling, wrapping и паттернам Go — с разбором ответов в реальном времени.
1. Интерфейс error
В Go error — это обычный интерфейс, определённый в стандартной библиотеке:
type error interface {
Error() string
}
Один метод, одна строка. Любой тип, реализующий Error() string, автоматически удовлетворяет интерфейсу error. Это важно: ошибка — это не специальный объект языка, а просто значение интерфейсного типа. Соответственно, её можно хранить в переменной, передавать как аргумент, возвращать из функции и класть в структуру — ровно как любое другое значение.
Самый простой способ создать ошибку — errors.New:
import "errors"
var err error = errors.New("что-то пошло не так")
fmt.Println(err) // что-то пошло не так
errors.New возвращает указатель на внутреннюю структуру errorString. Это значит, что каждый вызов errors.New создаёт новое значение, отличное от всех остальных — даже если текст совпадает:
err1 := errors.New("not found")
err2 := errors.New("not found")
fmt.Println(err1 == err2) // false — разные указатели
Это поведение критически важно для понимания sentinel errors — к нему мы вернёмся в следующем разделе.
Отсутствие ошибки — nil
Когда ошибки нет, функция возвращает nil. Это идиоматично для Go:
func divide(a, b float64) (float64, error) {
if b == 0 {
return 0, errors.New("деление на ноль")
}
return a / b, nil
}
result, err := divide(10, 2)
if err != nil {
log.Fatal(err)
}
fmt.Println(result) // 5
Проверка if err != nil — самый частый паттерн в Go-коде. Кажется, что это шум, но за этим стоит явность: ты точно знаешь, что функция может вернуть ошибку и что ты её обработал.
Собственный тип, реализующий error
Для передачи структурированного контекста достаточно реализовать один метод:
type NotFoundError struct {
Resource string
ID int
}
func (e *NotFoundError) Error() string {
return fmt.Sprintf("%s с id=%d не найден", e.Resource, e.ID)
}
func getUser(id int) (*User, error) {
// ...
return nil, &NotFoundError{Resource: "пользователь", ID: id}
}
Кастомный тип позволяет хранить машиночитаемые данные: не просто строку, а конкретные поля. Вызывающий код сможет их извлечь — как именно, разберём в разделе об errors.As.
2. Sentinel errors
Sentinel error — это переменная типа error, объявленная на уровне пакета и предназначенная для сравнения:
package store
var ErrNotFound = errors.New("не найдено")
var ErrPermissionDenied = errors.New("нет доступа")
Идея в том, что вызывающий код получает один и тот же указатель на одно и то же значение — и может надёжно сравнивать с ним ошибки. Название «sentinel» (страж) отражает смысл: это фиксированное значение, которое охраняет определённое семантическое значение.
Стандартная библиотека полна sentinel errors. Вот несколько важных:
io.EOF // нормальное завершение потока чтения
os.ErrNotExist // файл или директория не существует
sql.ErrNoRows // запрос не вернул ни одной строки
context.Canceled // контекст был явно отменён
context.DeadlineExceeded // истёк дедлайн контекста
Их объединяет одно: это сигнал, несущий конкретный смысл, который вызывающий код должен уметь распознать и обработать.
Прямое сравнение и почему его недостаточно
До Go 1.13 сравнение через == было стандартным:
_, err := io.ReadAll(r)
if err == io.EOF {
// конец данных, всё нормально
}
Это работает, пока ошибка не оборачивается. Как только появляется обёртка, прямое сравнение ломается:
wrapped := fmt.Errorf("читаю файл: %w", io.EOF)
fmt.Println(wrapped == io.EOF) // false — это уже другой объект
Именно поэтому с Go 1.13 рекомендуется использовать errors.Is — он умеет раскрутить цепочку обёрток и проверить каждое звено:
fmt.Println(errors.Is(wrapped, io.EOF)) // true
Антипаттерн: сравнение по строке
Иногда в старом коде встречается такой подход:
if err.Error() == "not found" {
// ...
}
Это антипаттерн по нескольким причинам: строка может измениться при рефакторинге, не работает с оборачиванием, не масштабируется на несколько пакетов. Всегда используй sentinel errors или кастомные типы.
3. Кастомные типы ошибок
Sentinel error хранит только сообщение. Когда нужен контекст — использую кастомный тип:
type ValidationError struct {
Field string
Message string
}
func (e *ValidationError) Error() string {
return fmt.Sprintf("поле %q: %s", e.Field, e.Message)
}
Вызывающий код может не только проверить факт ошибки, но и получить конкретные поля:
func validateAge(age int) error {
if age < 0 {
return &ValidationError{Field: "age", Message: "не может быть отрицательным"}
}
if age > 150 {
return &ValidationError{Field: "age", Message: "не может быть больше 150"}
}
return nil
}
err := validateAge(-5)
var valErr *ValidationError
if errors.As(err, &valErr) {
fmt.Printf("Ошибка валидации поля: %s\n", valErr.Field)
}
errors.As умеет находить нужный тип даже если ошибка обёрнута — подробнее в разделе 6. Здесь важно другое: кастомный тип даёт машиночитаемый контекст, который строка никогда не даст.
Поддержка цепочки через Unwrap
Чтобы кастомная ошибка участвовала в цепочке unwrapping, добавь поле и метод:
type DBError struct {
Op string
Err error // вложенная ошибка
}
func (e *DBError) Error() string {
return fmt.Sprintf("db %s: %v", e.Op, e.Err)
}
func (e *DBError) Unwrap() error {
return e.Err
}
Теперь errors.Is и errors.As смогут пройти сквозь DBError и добраться до вложенной ошибки:
err := &DBError{Op: "query", Err: sql.ErrNoRows}
fmt.Println(errors.Is(err, sql.ErrNoRows)) // true
Go-вопросы в Telegram
Ежедневные разборы горутин, каналов и паттернов.
4. Оборачивание ошибок: fmt.Errorf и %w
До Go 1.13 при добавлении контекста использовали %v:
return fmt.Errorf("открываю конфиг: %v", err)
Это добавляет строку с описанием, но теряет оригинальную ошибку как значение. Проверить через errors.Is или извлечь через errors.As уже нельзя.
Go 1.13 добавил %w:
return fmt.Errorf("открываю конфиг: %w", err)
Разница принципиальная: %w не просто вставляет строку — он создаёт новую ошибку типа *fmt.wrapError, которая содержит оригинальную ошибку и реализует Unwrap() error. Цепочка сохраняется, и errors.Is/errors.As могут её пройти.
Анатомия оборачивания
Что происходит внутри fmt.Errorf("открываю конфиг: %w", err):
// Примерно так реализован *fmt.wrapError
type wrapError struct {
msg string
err error
}
func (e *wrapError) Error() string { return e.msg }
func (e *wrapError) Unwrap() error { return e.err }
Когда errors.Is получает обёрнутую ошибку, он вызывает Unwrap() до тех пор, пока не найдёт совпадение или не дойдёт до nil.
Несколько уровней оборачивания
Типичный сценарий — ошибка поднимается через несколько слоёв:
// слой хранилища
func (r *UserRepo) Find(id int) (*User, error) {
row := r.db.QueryRow("SELECT * FROM users WHERE id = $1", id)
if err := row.Scan(&u); err == sql.ErrNoRows {
return nil, fmt.Errorf("find user %d: %w", id, store.ErrNotFound)
}
// ...
}
// слой сервиса
func (s *UserService) GetProfile(id int) (*Profile, error) {
user, err := s.repo.Find(id)
if err != nil {
return nil, fmt.Errorf("get profile: %w", err)
}
// ...
}
// слой хендлера
profile, err := svc.GetProfile(42)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
http.Error(w, "not found", http.StatusNotFound)
return
}
http.Error(w, "internal error", http.StatusInternalServerError)
}
Хендлер получает ошибку вида "get profile: find user 42: не найдено" — строку можно логировать. При этом errors.Is(err, store.ErrNotFound) вернёт true, потому что цепочка сохранена.
%v vs %w: правило выбора
%w — когда вызывающий код должен иметь возможность проверить вложенную ошибку через errors.Is/As. Это правило для внутренних вызовов внутри одного сервиса.
%v — когда ошибка является деталью реализации и не должна «протекать» наружу. Типичный случай — граница пакета или API: не хочешь, чтобы вызывающий код завязывался на внутренние типы ошибок.
// Граница пакета: скрываем детали реализации
func (s *AuthService) Login(login, pass string) (*Token, error) {
token, err := s.db.FindToken(login, pass)
if err != nil {
// %v — вызывающий код не должен знать о деталях БД
return nil, fmt.Errorf("аутентификация: %v", err)
}
return token, nil
}
Антипаттерн: двойное оборачивание без контекста
// Плохо: добавляем обёртку, но никакого нового смысла нет
return fmt.Errorf("ошибка: %w", err)
Оборачивание имеет смысл только тогда, когда добавляет информацию: какая операция выполнялась, с какими параметрами, на каком уровне. Если добавить нечего — просто возвращай err напрямую.
5. errors.Is — проверка идентичности
errors.Is(err, target) проверяет, есть ли в цепочке обёрток ошибка, равная target:
func Is(err, target error) bool
Алгоритм простой: сравниваем err == target. Если не совпало — вызываем err.Unwrap() и повторяем. Если Unwrap() вернул nil — возвращаем false.
err := fmt.Errorf("слой A: %w",
fmt.Errorf("слой B: %w",
fmt.Errorf("слой C: %w", io.EOF)))
fmt.Println(errors.Is(err, io.EOF)) // true — найдено на 3-м уровне
Кастомный Is
Иногда стандартного сравнения через == недостаточно. Например, если хочешь сравнивать ошибки по коду, а не по указателю:
type AppError struct {
Code int
Message string
}
func (e *AppError) Error() string {
return fmt.Sprintf("[%d] %s", e.Code, e.Message)
}
func (e *AppError) Is(target error) bool {
t, ok := target.(*AppError)
if !ok {
return false
}
return e.Code == t.Code
}
var ErrForbidden = &AppError{Code: 403, Message: "нет доступа"}
err := fmt.Errorf("обработка запроса: %w", &AppError{Code: 403, Message: "токен истёк"})
fmt.Println(errors.Is(err, ErrForbidden)) // true — коды совпадают
errors.Is проверяет, реализует ли ошибка метод Is(error) bool, и если да — делегирует сравнение ему.
Важный нюанс: только %w сохраняет цепочку
withW := fmt.Errorf("обёртка: %w", io.EOF)
withV := fmt.Errorf("обёртка: %v", io.EOF)
fmt.Println(errors.Is(withW, io.EOF)) // true
fmt.Println(errors.Is(withV, io.EOF)) // false — цепочка потеряна
После %v оригинальная ошибка становится просто частью строки. errors.Is не может её найти — потому что технически её уже нет как значения.
6. errors.As — извлечение типа
Там где errors.Is проверяет конкретное значение, errors.As проверяет тип. Сигнатура:
func As(err error, target any) bool
target должен быть указателем на тип ошибки (или на интерфейс). Если в цепочке найдётся ошибка нужного типа — она будет записана в target и функция вернёт true:
_, err := os.Open("/несуществующий/файл")
wrappedErr := fmt.Errorf("загружаю конфиг: %w", err)
var pathErr *os.PathError
if errors.As(wrappedErr, &pathErr) {
fmt.Println("операция:", pathErr.Op) // open
fmt.Println("путь:", pathErr.Path) // /несуществующий/файл
fmt.Println("ошибка ОС:", pathErr.Err) // no such file or directory
}
Без errors.As пришлось бы делать type assertion напрямую на оборачивающей ошибке — что не сработало бы, ведь внешний тип — *fmt.wrapError.
errors.As vs type assertion
// Type assertion — работает только если err именно *os.PathError
pathErr, ok := err.(*os.PathError)
// errors.As — работает и через любое количество обёрток
var pathErr *os.PathError
ok := errors.As(err, &pathErr)
Type assertion можно использовать, только если точно знаешь, что обёрток нет. В остальных случаях — errors.As.
Кастомный As
Аналогично Is, можно реализовать метод As(target any) bool для нестандартного поведения:
type MultiError struct {
Errors []error
}
func (m *MultiError) Error() string {
msgs := make([]string, len(m.Errors))
for i, e := range m.Errors {
msgs[i] = e.Error()
}
return strings.Join(msgs, "; ")
}
func (m *MultiError) As(target any) bool {
// Пробуем привести к *MultiError
if t, ok := target.(**MultiError); ok {
*t = m
return true
}
// Пробуем найти нужный тип в каждой из вложенных ошибок
for _, err := range m.Errors {
if errors.As(err, target) {
return true
}
}
return false
}
Go для собеседований: 50 реальных вопросов
Горутины, каналы, ошибки, интерфейсы — с разборами и примерами кода.
7. errors.Unwrap и поддержка деревьев ошибок
errors.Unwrap(err) — низкоуровневая функция, которую errors.Is и errors.As используют внутри:
wrapped := fmt.Errorf("обёртка: %w", io.EOF)
inner := errors.Unwrap(wrapped)
fmt.Println(inner == io.EOF) // true
fmt.Println(errors.Unwrap(inner)) // <nil> — io.EOF не оборачивает ничего
Напрямую errors.Unwrap редко нужен — обычно достаточно Is и As. Но понимать, что происходит под капотом, важно.
Go 1.20: errors.Join и деревья ошибок
До Go 1.20 одна ошибка могла содержать только одну вложенную. В Go 1.20 добавили errors.Join и поддержку Unwrap() []error:
err1 := errors.New("ошибка сети")
err2 := errors.New("ошибка диска")
combined := errors.Join(err1, err2)
fmt.Println(combined) // ошибка сети\nошибка диска
Под капотом errors.Join создаёт ошибку с методом Unwrap() []error, который возвращает срез. errors.Is и errors.As умеют обходить такие деревья рекурсивно:
fmt.Println(errors.Is(combined, err1)) // true
fmt.Println(errors.Is(combined, err2)) // true
Это полезно, когда несколько горутин возвращают ошибки и нужно их объединить в одну:
var mu sync.Mutex
var errs []error
var wg sync.WaitGroup
for _, task := range tasks {
wg.Add(1)
go func(t Task) {
defer wg.Done()
if err := t.Run(); err != nil {
mu.Lock()
errs = append(errs, err)
mu.Unlock()
}
}(task)
}
wg.Wait()
return errors.Join(errs...)
Собственная реализация Unwrap() []error
type MultiValidationError struct {
Errors []error
}
func (e *MultiValidationError) Error() string {
msgs := make([]string, len(e.Errors))
for i, err := range e.Errors {
msgs[i] = err.Error()
}
return "ошибки валидации: " + strings.Join(msgs, ", ")
}
func (e *MultiValidationError) Unwrap() []error {
return e.Errors
}
8. Паттерны обработки ошибок
Механизмы разобрали. Теперь — практика: как использовать их правильно в реальном коде.
Добавляй контекст при каждом подъёме
Ошибка должна нести в себе путь к причине. Каждый слой добавляет что-то своё:
// Хранилище
return fmt.Errorf("userRepo.Find(id=%d): %w", id, err)
// Сервис
return fmt.Errorf("UserService.GetProfile: %w", err)
// Хендлер
return fmt.Errorf("GET /users/%d: %w", id, err)
В итоге лог будет выглядеть так:
GET /users/42: UserService.GetProfile: userRepo.Find(id=42): не найдено
Это читаемый стектрейс без единого стека вызовов. Дебажить такой лог гораздо проще, чем безымянную "не найдено" без контекста.
Граница пакета: скрывай детали
Когда ошибка пересекает границу публичного API — используй %v вместо %w:
// Публичный метод пакета
func (c *Client) Fetch(url string) ([]byte, error) {
body, err := c.doRequest(url)
if err != nil {
// Не раскрываем внутренний тип *requestError наружу
return nil, fmt.Errorf("fetch %s: %v", url, err)
}
return body, nil
}
Это называется «капсуляция ошибок». Вызывающий код видит сообщение, но не может через errors.As добраться до внутреннего типа. Если нужно дать доступ к структурированным данным — делай это через явный sentinel или кастомный публичный тип.
Единственная проверка: не проверяй на каждом уровне
Частая ошибка — логировать ошибку на каждом уровне стека:
// Плохо: ошибка логируется трижды
func (r *Repo) Find(id int) (*User, error) {
u, err := r.db.Get(id)
if err != nil {
log.Println(err) // первый лог
return nil, fmt.Errorf("repo: %w", err)
}
return u, nil
}
func (s *Service) Get(id int) (*User, error) {
u, err := s.repo.Find(id)
if err != nil {
log.Println(err) // второй лог
return nil, fmt.Errorf("service: %w", err)
}
return u, nil
}
func handler(w http.ResponseWriter, r *http.Request) {
u, err := svc.Get(42)
if err != nil {
log.Println(err) // третий лог
http.Error(w, "internal error", 500)
}
}
В логах появятся три одинаковые записи. Правильная стратегия: либо логируй, либо возвращай наверх — не делай оба. Логировать стоит один раз, на том уровне, где ошибку окончательно обработали (обычно это самый верхний слой — хендлер или main):
// Хорошо: repo и service только оборачивают и возвращают
// хендлер логирует один раз и завершает обработку
func handler(w http.ResponseWriter, r *http.Request) {
u, err := svc.Get(42)
if err != nil {
log.Printf("GET /users/42: %v", err) // единственный лог
http.Error(w, "internal error", 500)
return
}
// ...
}
Сравнение с pkg/errors
До Go 1.13 популярной альтернативой был пакет github.com/pkg/errors. Он давал errors.Wrap(err, "msg") и стектрейс через %+v. С появлением встроенного wrapping через %w необходимость в pkg/errors существенно снизилась — стандартная библиотека теперь покрывает большинство сценариев. В новых проектах предпочтительнее стандартный подход.
9. Типичные вопросы на собеседовании
«Что такое sentinel error и когда его применять?»
Sentinel error — переменная типа error на уровне пакета, которая идентифицирует конкретный вид ошибки. Применять стоит тогда, когда вызывающий код должен уметь различить конкретные исходы и среагировать по-разному: например, вернуть 404 на ErrNotFound и 409 на ErrConflict. Не стоит делать sentinel из каждой ошибки — только из тех, на которые планируется реагировать.
«Зачем нужен %w, чем отличается от %v?»
%w оборачивает оригинальную ошибку: создаёт новое значение, содержащее исходную через Unwrap(). Это позволяет errors.Is/As добраться до оригинала. %v просто включает строковое представление ошибки в текст нового сообщения — оригинальная ошибка как значение теряется.
«Как работает errors.Is под капотом?»
Сравнивает err == target. Если нет — вызывает err.Unwrap() и повторяет. Если ошибка реализует метод Is(error) bool — делегирует сравнение ему. Это рекурсивный обход цепочки до совпадения или nil.
«Как errors.As отличается от type assertion?»
Type assertion работает только на самом верхнем типе: если ошибка обёрнута, assertion на внутренний тип упадёт с ok = false. errors.As проходит всю цепочку через Unwrap() и находит нужный тип на любой глубине.
«Как реализовать кастомный тип ошибки с поддержкой unwrapping?»
Добавь поле Err error и метод Unwrap() error { return e.Err }. Тогда errors.Is/As смогут пройти сквозь твой тип и добраться до вложенного. Без Unwrap() цепочка обрывается на твоём типе.
«Когда НЕ нужно оборачивать ошибку?»
Когда оборачивание не добавляет контекста: fmt.Errorf("ошибка: %w", err) бессмысленно. Также на границе пакета, когда не хочешь раскрывать внутренние типы ошибок вызывающему коду. И когда ошибка уже несёт достаточно информации — лишние обёртки загрязняют сообщение.
«Что произойдёт с errors.Is, если использовали %v вместо %w?»
errors.Is вернёт false. После %v оригинальная ошибка становится частью строки, а не частью цепочки. Новая ошибка не реализует Unwrap(), поэтому раскручивать нечего.
«Что такое errors.Join и когда он нужен?»
errors.Join объединяет несколько ошибок в одну, создавая ошибку с методом Unwrap() []error. Нужен когда несколько независимых операций (например, несколько горутин) могут завершиться с ошибками, и хочется вернуть их все разом. errors.Is/As умеют обходить такое дерево.
«Как логировать ошибки без дублирования?»
Правило одного лога: либо логируй и обрабатывай, либо оборачивай и возвращай наверх. Логировать стоит один раз на самом верхнем уровне — там, где ошибка окончательно обрабатывается (хендлер, main). Все нижние слои только добавляют контекст через fmt.Errorf("%w").
10. Итоги
Обработка ошибок в Go выстроена вокруг нескольких простых, но мощных примитивов. Интерфейс error даёт единую точку входа, sentinel errors — именованные константы для ветвления, кастомные типы — структурированный контекст. Механизм wrapping через %w, появившийся в Go 1.13, склеивает всё воедино: можно добавлять контекст на каждом уровне и при этом не терять доступ к исходной ошибке.
Ключевые выводы:
errors.Is проверяет идентичность значения в цепочке, errors.As — тип. Оба умеют рекурсивно обходить цепочку Unwrap(). %w сохраняет цепочку, %v её обрывает. На границах пакета — %v, внутри слоёв — %w. Логируй ошибку один раз на верхнем уровне, не дублируй по стеку.
Для более глубокого погружения в Go — смотри другие материалы кластера: 50 вопросов junior–middle Go-разработчика, context в Go: отмена, дедлайны и best practices, горутины в Go: concurrency на собеседовании.
Тренируй Go-интервью в реальном времени
Lexicon проводит голосовые mock-интервью по Go: ошибки, горутины, контексты, каналы. Получи развёрнутый фидбэк после каждой сессии.
Автор
Lexicon Team
Читайте также
backend
Go memory model простыми словами: happens-before, гонки и sync
Разбираем модель памяти Go: happens-before, data races, гарантии каналов, Mutex, Once и атомики — с примерами кода и реальными вопросами с интервью.
backend
Context в Go: отмена, дедлайны и best practices
Полный разбор context.Context в Go: WithCancel, WithTimeout, WithDeadline, WithValue, дерево контекстов, propagation отмены, best practices и антипаттерны — с примерами и вопросами с интервью.
backend
Goroutines в Go: как работают и что спрашивают на интервью
Глубокий разбор горутин в Go: планировщик M:N, стек, каналы, sync-примитивы, паттерны конкурентности, утечки горутин и context — с примерами кода и реальными вопросами с интервью.