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

Go-собес: Уровень Middle, но... Слабые места Go-разработчика, которые видит Senior из Яндекса

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

Сегодня мы разберём прямой эфир с мок-собеседованием на позицию Go-разработчика, в котором интервьюер Влад из Яндекса проверял знания кандидата Андрея по ключевым темам языка: ООП в Go, слайсы и массивы, строки и UTF-8, мапы, каналы, контекст, примитивы синхронизации и лайвкодинг. Собеседование прошло в дружелюбной атмосфере с наводящими подсказками и разборами нюансов, а по итогам Андрей получил оценку уровня «мидл-минус» с рекомендацией углубить знания внутреннего устройства структур данных и примитивов языка.

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

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

Ответ собеседника: Правильный. Занимается коммерческой разработкой около четырёх лет. Работал в продуктовых и инфраструктурных командах в сферах электронной коммерции, системной разработки транспортных систем и организации командировок. В компании Скала работал в команде мониторинга кластера серверов, разработал систему сбора, хранения информации о кластере, сетевой инфраструктуре и графовой базе данных с автоматической загрузкой бинарников на сервера. Разработал архитектурный план, собрал MVP, дистрибутив с приложением и графовой БД, автоматизировал развёртывание и вывел информацию о топологии системы в UI для инженеров поддержки. До этого работал в компании Софтелематика, занимался системой контроля дорожных фондов, внедрил систему ролей на Go для доступа к модулям федеральных и региональных дорожных служб, настроил выгрузку в Excel таблиц дорожных работ, оптимизировал и распараллелил запросы к БД.

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

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

Для подготовки к подобному вопросу рекомендуется придерживаться следующей структуры:

1. Общая вводная (кто вы и сколько опыта)

  • Назвать свою специализацию (например, Go-разработчик, системный инженер, бэкенд-разработчик)
  • Указать общий стаж коммерческой разработки
  • Упомянуть основные домены, в которых работали (e-commerce, fintech, инфраструктура, транспорт и т.д.)

2. Ключевые проекты и достижения (2–3 штуки) Для каждого проекта желательно описать:

  • Контекст: в какой компании, в каком домене, какой команде
  • Задача: какую бизнес- или техническую проблему решали
  • Что именно сделали: какие компоненты разработали, какие технологии использовали
  • Результат: MVP, автоматизация, оптимизация, масштабирование, внедрение в продакшн

3. Технический стек

  • Языки (Go как основной, плюс дополнительные если есть)
  • Базы данных (PostgreSQL, MySQL, Neo4j, Redis, ClickHouse и т.д.)
  • Брокеры сообщений (Kafka, RabbitMQ)
  • Инфраструктура (Docker, Kubernetes, CI/CD)
  • Мониторинг (Prometheus, Grafana)

4. Мягкие навыки и роль в команде

  • Участие в проектировании архитектуры
  • Взаимодействие с другими командами (DevOps, QA, аналитики)
  • Наставничество, code review, документация

Пример улучшенного ответа (шаблон):

«Я Go-разработчик с четыродами коммерческого опыта. Работал в продуктовых и инфраструктурных командах в сферах e-commerce, транспортных систем и корпоративных сервисов.

В последнем проекте в компании Скала я разрабатывал систему мониторинга серверного кластера. Задача — собрать информацию о топологии инфраструктуры, серверах, сетевых связях и автоматизировать деплой приложений. Я спроектировал архитектуру системы, реализовал сбор метрик, хранение в графовой БД Neo4j, автоматическую загрузку бинарников на целевые сервера и визуализацию топологии в веб-интерфейсе для инженеров поддержки. В результате время диагностики инцидентов сократилось на 40%.

Ранее в компании Софтелематика я работал над системой управления дорожными фондами. Внедрил ролевую модель доступа на Go для разграничения прав между федеральными и региональными службами, реализовал выгрузку отчётов в Excel, оптимизировал тяжёлые SQL-запросы и распараллелил обработку данных, что ускорило формирование отчётов в 3 раза.

Владею Go, PostgreSQL, Redis, Docker, Kafka. Имею опыт проектирования микросервисной архитектуры, написания unit- и интеграционных тестов, участия в code review и менторинга младших разработчиков.»

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

Вопрос 2. Как в Go реализуются принципы ООП: инкапсуляция, наследование, полиморфизм?

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

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

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

Go не является классическим объектно-ориентированным языком, но поддерживает все три принципа ООП через свои механизмы. Ответ собеседника верен в целом, но требует дополнения, особенно в части полиморфизма.

Инкапсуляция

В Go инкапсуляция реализуется через систему видимости на уровне пакетов, а не классов:

  • Имена, начинающиеся с заглавной буквы (MyFunc, MyStruct), экспортируются и доступны из других пакетов
  • Имена, начинающиеся со строчной буквы (myFunc, myStruct), приватны и видны только внутри текущего пакета

Это распространяется на структуры, поля структур, методы, константы, переменные и функции.

package user

// Экспортируемая структура
type User struct {
ID int // экспортируемое поле
name string // приватное поле (доступно только внутри пакета user)
}

// Экспортируемый метод
func (u *User) GetName() string {
return u.name
}

// Приватная функция
func validateName(name string) bool {
return len(name) > 0

Важно понимать: в Go нет модификаторов private, protected, public. Единица инкапсуляции — пакет, а не структура. Два файла в одном пакете имеют доступ к приватным членам друг друга.

Полиморфизм

В Go полиморфизм реализуется через интерфейсы, а не через дженерики. Это ключевое уточнение к ответу собеседника.

Интерфейсы в Go реализуются неявно (duck typing): тип автоматически удовлетворяет интерфейсу, если реализует все его методы. Это позволяет писать полиморфный код без явного указания зависимостей.

package main

import "fmt"

// Интерфейс определяет контракт
type Printer interface {
Print() string
}

// Различные типы реализуют интерфейс неявно
type Document struct {
Content string
}

func (d Document) Print() string {
return fmt.Sprintf("Document: %s", d.Content)
}

type Image struct {
URL string
}

func (i Image) Print() string {
return fmt.Sprintf("Image: %s", i.URL)
}

// Полиморфная функция — работает с любым типом, реализующим Printer
func Render(p Printer) {
fmt.Println(p.Print())
}

func main() {
doc := Document{Content: "Hello World"}
img := Image{URL: "https://example.com/photo.png"}

Render(doc) // Document: Hello World
Render(img) // Image: https://example.com/photo.png
}

Интерфейсы в Go могут быть вложенными (composition of interfaces):

type Reader interface {
Read(p []byte) (n int, err error)
}

type Writer interface {
Write(p []byte) (n int, err error)
}

// ReadWriter объединяет два интерфейса
type ReadWriter interface {
Reader
Writer
}

Что касается дженериков (добавлены в Go 1.18), они решают другую задачу — параметрический полиморфизм (type-safe работу с разными типами), но не заменяют интерфейсы как основной механизм полиморфизма в Go. Дженерики полезны для контейнеров, утилитарных функций и алгоритмов.

// Дженерик-функция — параметрический полиморфизм
func Min[T constraints.Ordered](a, b T) T {
if a < b {
return a
}
return b
}

Наследование (композиция)

В Go нет классического наследования. Вместо этого используется композиция через встраивание (embedding):

package main

import "fmt"

// Базовая структура
type Animal struct {
Name string
}

func (a Animal) Speak() string {
return "..."
}

// Встраивание — "наследование" поведения
type Dog struct {
Animal // встраиваем Animal
Breed string
}

// Переопределение метода
func (d Dog) Speak() string {
return "Woof!"
}

func main() {
dog := Dog{
Animal: Animal{Name: "Rex"},
Breed: "Labrador",
}

fmt.Println(dog.Name) // прямой доступ к полю Animal
fmt.Println(dog.Speak()) // Woof! — переопределённый метод
fmt.Println(dog.Animal.Speak()) // ... — метод "родителя"
}

Встраивание интерфейсов работает аналогично:

type Logger interface {
Log(msg string)
}

type ErrorLogger interface {
Logger
LogError(err error)
}

Важные нюансы композиции:

  • При встраивании структуры получаем доступ к её методам и полям, но это не наследование в классическом смысле
  • Нет приведения типов "вверх" (upcasting): Dog не является Animal в системе типов Go
  • Если нужен полиморфизм — используем интерфейсы
func MakeSound(a Animal) {
fmt.Println(a.Speak())
}

// Это не скомпилируется:
// MakeSound(dog) // ошибка — Dog не является Animal

// Правильно — через интерфейс
type Speaker interface {
Speak() string
}

func MakeSound(s Speaker) {
fmt.Println(s.Speak())
}

// Теперь работает:
MakeSound(dog) // Woof!

Резюме:

Принцип ООПРеализация в Go
ИнкапсуляцияРегистр имён на уровне пакета
ПолиморфизмИнтерфейсы (неявная реализация)
НаследованиеКомпозиция через встраивание (embedding)

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

Вопрос 3. Как в Go реализуются принципы ООП: инкапсуляция, наследование, полиморфизм?

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

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

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

Данный вопрос уже был подробно разобран ранее (Вопрос 2). Ответ собеседника идентичен предыдущему и содержит ту же ключевую неточность.

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

Рекомендация: на интервью стоит чётко разделять эти два понятия — интерфейсный полиморфизм (основной в Go) и параметрический полиморфизм (дженерики).

Вопрос 4. В чём разница между слайсом и массивом в Go?

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

Ответ собеседника: Неполный. Слайс — надстройка над массивом. Массив — участок в памяти, тип массива состоит из количества элементов и их типа. Массивы не могут расширяться или сужаться. На практике чаще используют слайсы. Не смог назвать удобное свойство массивов (сравнение через ==).

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

Массив (Array)

Массив в Go — это фиксированная последовательность элементов одного типа. Длина массива является частью его типа.

var a [5]int // массив из 5 целых чисел
var b [3]string // массив из 3 строк

// [5]int и [3]int — это РАЗНЫЕ типы
// a = b // ошибка компиляции

Ключевые свойства массивов:

  • Фиксированная длина, задаётся на этапе компиляции
  • Значимый тип (value type) — при присваивании копируется полностью
  • Сравнивается оператором == (если элементы сравнимы)
  • Передаётся в функцию копированием
a := [3]int{1, 2, 3}
b := a // полная копия
b[0] = 100 // a не изменится

fmt.Println(a == b) // false — массивы можно сравнивать

Слайс (Slice)

Слайс — это динамическая обёртка над массивом. Это ссылочный тип, который содержит указатель на underlying array, длину и ёмкость.

var s []int // nil слайс, len=0, cap=0
s2 := []int{1, 2, 3} // слайс с тремя элементами
s3 := make([]int, 5) // слайс длины 5, заполненный нулями

Внутренняя структура слайса (упрощённо):

type slice struct {
array unsafe.Pointer // указатель на массив
len int // текущая длина
cap int // ёмкость (максимальная длина без реаллокации)
}

Ключевые различия

ХарактеристикаМассивСлайс
ДлинаФиксирована, часть типаДинамическая
Тип данныхЗначимый (value)Ссылочный (reference)
Сравнение ==ДаНет (только с nil)
Копирование при присваиванииПолная копияКопия заголовка (shared underlying array)
Передача в функциюКопируется целикомПередаётся заголовок (24 байта)

Практические последствия ссылочной природы слайсов

func modify(s []int) {
s[0] = 100 // изменит оригинал!
s = append(s, 4) // НЕ изменит оригинал снаружи (при append с реаллокацией)
}

original := []int{1, 2, 3}
modify(original)
fmt.Println(original) // [100, 2, 3]

Реаллокация при append

s := make([]int, 0, 2) // len=0, cap=2
s = append(s, 1)
s = append(s, 2)
s = append(s, 3) // cap превышена — происходит реаллокация (обычно cap удваивается)

fmt.Println(cap(s)) // 4 (или больше, зависит от реализации)

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

Массивы полезны в специфических случаях:

  • Ключи в map (слайсы нельзя использовать как ключи)
  • Когда нужна гарантия фиксированного размера (криптография, сетевые протоколы)
  • Когда важно избежать аллокаций в куче
  • Когда нужно сравнивать коллекции через ==
type Key [32]byte

func main() {
m := make(map[Key]string)
k1 := Key{1, 2, 3}
m[k1] = "value"

k2 := Key{1, 2, 3}
fmt.Println(k1 == k2) // true — массивы сравнимы
fmt.Println(m[k2]) // "value"
}

Подводные камни

// 1. Разделяемый underlying array
original := []int{1, 2, 3, 4, 5}
slice := original[1:3] // [2, 3]
slice[0] = 100
fmt.Println(original) // [1, 100, 3, 4, 5] — оригинал изменился!

// 2. Утечка памяти при reslice большого массива
big := make([]int, 1_000_000)
small := big[:2] // small всё ещё ссылается на big массив
// Для копирования используйте copy или append
smallCopy := append([]int{}, big[:2]...)

В повседневной разработке используются слайсы в 95% случаев. Массивы применяются редко, но понимание их устройства критически важно для понимания работы слайсов и избежания багов с разделяемым состоянием.

Вопрос 5. Какие типы в Go можно сравнивать между собой?

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

Ответ собеседника: Неполный. Можно сравнивать типы, удовлетворяющие интерфейсу comparable: int, float, string. Структуры тоже можно сравнивать, но только если все поля структуры имеют сравнимый тип (нельзя сравнивать структуры с полями типа канал или функция).

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

Comparable типы

В Go операторы == и != работают с типами, которые реализуют встроенное ограничение comparable. Полный список:

1. Примитивные типы (всегда сравнимы)

  • int, int8, int16, int32, int64
  • uint, uint8, uint16, uint32, uint64, uintptr
  • float32, float64
  • complex64, complex128
  • bool
  • string
  • byte (alias for uint8), rune (alias for int32)
fmt.Println(42 == 42) // true
fmt.Println("hello" == "hello") // true
fmt.Println(3.14 == 2.71) // false

2. Указатели (pointers) Сравниваются по адресу в памяти, а не по значению.

x, y := 42, 42
p1, p2 := &x, &y
fmt.Println(p1 == p2) // false — разные адреса

p3 := &x
fmt.Println(p1 == p3) // true — один и тот же адрес

3. Каналы (channels) Сравниваются по идентичности (созданы одним make или оба nil).

ch1 := make(chan int)
ch2 := make(chan int)
ch3 := ch1

fmt.Println(ch1 == ch2) // false — разные каналы
fmt.Println(ch1 == ch3) // true — один и тот же канал

4. Интерфейсы (interfaces) Сравниваются по динамическому типу и значению. Важный нюанс: если динамический тип несравним, произойдёт panic в runtime.

var i1, i2 interface{} = 42, 42
fmt.Println(i1 == i2) // true

var i3, i4 interface{} = []int{1}, []int{1}
// fmt.Println(i3 == i4) // panic: comparing uncomparable type []int

5. Массивы (arrays) Сравниваются поэлементно, если тип элемента сравним.

a := [3]int{1, 2, 3}
b := [3]int{1, 2, 3}
fmt.Println(a == b) // true

6. Структуры (structs) Сравниваются поэлементно, если все поля сравнимы.

type Point struct {
X, Y int
}

p1 := Point{1, 2}
p2 := Point{1, 2}
fmt.Println(p1 == p2) // true

Несравнимые типы

ТипПричина
[]T (слайс)Ссылочный тип, нет семантики сравнения по умолчанию
map[K]VСсылочный тип
func(...)Невозможно определить эквивалентность функций
Структура с несравнимыми полямиКаскадное ограничение
type BadStruct struct {
Data []int
}

// a := BadStruct{[]int{1}}
// b := BadStruct{[]int{1}}
// fmt.Println(a == b) // ошибка компиляции: struct containing []int cannot be compared

Сравнение через reflect.DeepEqual

Для несравнимых типов можно использовать reflect.DeepEqual, но это медленно и не рекомендуется для production кода.

s1 := []int{1, 2, 3}
s2 := []int{1, 2, 3}
fmt.Println(reflect.DeepEqual(s1, s2)) // true

Сравнение через slices пакета (Go 1.21+)

import "slices"

s1 := []int{1, 2, 3}
s2 := []int{1, 2, 3}
fmt.Println(slices.Equal(s1, s2)) // true

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

  • Для map ключей используйте только comparable типы
  • Для сравнения слайсов используйте slices.Equal (Go 1.21+) или ручную реализацию
  • Избегайте reflect.DeepEqual в горячих путях — это медленно
  • При сравнении интерфейсов помните о возможном panic с несравнимыми динамическими типами
// Безопасное сравнение интерфейсов
func safeEqual(a, b interface{}) bool {
defer func() {
if r := recover(); r != nil {
// обработка panic для несравнимых типов
}
}()
return a == b
}

Вопрос 6. Что выведет данный код со слайсами? Объясните, что происходит под капотом при передаче слайсов в функцию и вызове append.

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

Ответ собеседния: Неполный. Описал поведение append при переполнении capacity и без, но не учёл, что длина слайса в main остаётся неизменной, так как структура слайса передаётся по значению.

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

Устройство слайса под капотом

Слайс — это структура из трёх полей (24 байта на 64-bit системе):

type slice struct {
ptr *byte // указатель на underlying array
len int // текущая длина
cap int // ёмкость
}

При передаче слайса в функцию эта структура копируется по значению, но указатель ptr ссылается на тот же underlying array.

Что происходит при различных операциях

Изменение элементов по индексу — видно в вызывающем коде, потому что оба слайса ссылаются на один underlying array:

func modify(s []int) {
s[0] = 100 // изменяет underlying array
}

original := []int{1, 2, 3}
modify(original)
fmt.Println(original) // [100, 2, 3]

Append без реаллокации — добавляет элемент в underlying array, но длина обновляется только в локальной копии структуры слайса:

func appendNoRealloc(s []int) {
s = append(s, 99) // cap не превышена, но len обновляется только в локальной копии
fmt.Println("inside:", s) // [1, 2, 3, 99]
}

original := make([]int, 3, 5)
original[0], original[1], original[2] = 1, 2, 3
appendNoRealloc(original)
fmt.Println("outside:", original) // [1, 2, 3] — len всё ещё 3!

Append с реаллокацией — создаётся новый underlying array, все изменения локальны:

func appendWithRealloc(s []int) {
s = append(s, 99) // cap превышена, новый массив
s[0] = 100 // изменяет НОВЫЙ массив
}

original := []int{1, 2, 3} // len=3, cap=3
appendWithRealloc(original)
fmt.Println(original) // [1, 2, 3] — ничего не изменилось

Разбор типичного задания на интервью

package main

import "fmt"

func main() {
// Слайс 1: len=3, cap=3
sl1 := []int{1, 2, 3}
// Слайс 2: len=1, cap=3
sl2 := sl1[:1]
// Слайс 3: len=2, cap=2 (от sl1[1:3])
sl3 := sl1[1:3]

fmt.Println("Before:")
fmt.Println("sl1:", sl1, "len:", len(sl1), "cap:", cap(sl1)) // [1,2,3] len=3 cap=3
fmt.Println("sl2:", sl2, "len:", len(sl2), "cap:", cap(sl2)) // [1] len=1 cap=3
fmt.Println("sl3:", sl3, "len:", len(sl3), "cap:", cap(sl3)) // [2,3] len=2 cap=2

modify(sl1)
modify(sl2)
modify(sl3)

fmt.Println("After:")
fmt.Println("sl1:", sl1) // [1, 100, 3]
fmt.Println("sl2:", sl2) // [1] — len=1, не видит добавленный элемент
fmt.Println("sl3:", sl3) // [2, 3] — реаллокация, оригинал не затронут
}

func modify(s []int) {
s = append(s, 100)
}

Пошаговое объяснение

sl1 = [1, 2, 3], len=3, cap=3:

  • append(sl1, 100) — cap превышена, происходит реаллокация
  • Новый массив создаётся, sl1 в main всё ещё ссылается на старый
  • Однако s[0] не менялся, поэтому sl1 не изменился

sl2 = [1], len=1, cap=3:

  • append(sl2, 100) — cap НЕ превышена (1+1 ≤ 3)
  • Элемент 100 записан в underlying array на позицию 1
  • Но len обновился только в локальной копии внутри modify
  • sl2 в main всё ещё имеет len=1, поэтому fmt.Println(sl2) выведет [1]
  • Физически underlying array содержит [1, 100, 3], но sl2 это не видит

sl3 = [2, 3], len=2, cap=2:

  • append(sl3, 100) — cap превышена, реаллокация
  • Создаётся новый массив, sl3 в main не затронут

Как правильно изменять слайс из функции

Вариант 1: Возвращать новый слайс

func appendAndReturn(s []int, val int) []int {
return append(s, val)
}

original := []int{1, 2, 3}
original = appendAndReturn(original, 100)
fmt.Println(original) // [1, 2, 3, 100]

Вариант 2: Использовать указатель на слайс

func appendViaPtr(s *[]int, val int) {
*s = append(*s, val)
}

original := []int{1, 2, 3}
appendViaPtr(&original, 100)
fmt.Println(original) // [1, 2, 3, 100]

Вариант 3: Изменять элементы по индексу (без append)

func setFirst(s []int, val int) {
if len(s) > 0 {
s[0] = val // видно в вызывающем коде
}
}

original := []int{1, 2, 3}
setFirst(original, 100)
fmt.Println(original) // [100, 2, 3]

Ключевые правила для запоминания

  1. Слайс передаётся в функцию копированием заголовка (ptr, len, cap)
  2. Изменение элементов по индексу видно в вызывающем коде
  3. append обновляет len только в локальной копии, если не возвращать результат
  4. При реаллокации создаётся новый underlying array, связи с оригиналом теряются
  5. Без реаллокации append пишет в общий underlying array, но вызывающий код не видит новый len

Вопрос 7. Расскажите про строки в Go: что такое руна и байт, как работает перебор строки через range с UTF-8 символами.

Таймкод: 00:26:40

Ответ собеседника: Неполный. Строка — структура, похожая на слайс для чтения, внутренний массив байтов в UTF-8. len возвращает байты, не символы. utf8.RuneCountInString — количество символов. Руна — алиас int32, байт — uint8. range перебирает по рунам с пропуском байтов. Не точно знал размер UTF-8 для различных символов.

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

Внутреннее устройство строки

Строка в Go — это структура из двух полей (16 байт на 64-bit системе):

type string struct {
data *byte // указатель на массив байтов
len int // длина в байтах
}

Строки в Go неизменяемы (immutable). Любая операция, которая модифицирует строку, создаёт новую.

Байт и руна

byte = uint8 // 1 байт, значения 0-255
rune = int32 // 4 байта, представляет Unicode code point (0-0x10FFFF)

UTF-8 кодировка

Go использует UTF-8 для хранения строк. UTF-8 — это переменная длина кодировки:

Диапазон code pointКоличество байтПримеры
U+0000 — U+007F1 байтASCII: A, B, 1, @
U+0080 — U+07FF2 байтаКириллица: А, Б, В
U+0800 — U+FFFF3 байтаКитайские иероглифы, японские
U+10000 — U+10FFFF4 байтаЭмодзи: 😀, 🚀
package main

import (
"fmt"
"unicode/utf8"
)

func main() {
s := "Hello, 世界! 🚀"

// len возвращает количество БАЙТ
fmt.Println("len:", len(s)) // 17 (не 10!)

// Количество символов (рун)
fmt.Println("runes:", utf8.RuneCountInString(s)) // 10
}

Байты vs Руны в памяти

String: "AБ😀"

Байты (hex): 41 D0 91 F0 9F 98 80
─┬─ ─┬─── ─┬─────────
│ │ │
│ │ └── 4 байта (эмодзи U+1F600)
│ └───────── 2 байта (кириллица U+0411)
└────────────── 1 байт (ASCII U+0041)

Руны: [65] [1045] [128512]
A Б 😀

Перебор строки

Через range — по рунам (правильно для UTF-8)

s := "Hello, 世界!"

for i, r := range s {
fmt.Printf("index=%d, rune=%c, type=%T\n", i, r, r)
}

Вывод:

index=0, rune=H, type=int32
index=1, rune=e, type=int32
index=2, rune=l, type=int32
index=3, rune=l, type=int32
index=4, rune=o, type=int32
index=5, rune=,, type=int32
index=6, rune= , type=int32
index=7, rune=世, type=int32 ← индекс прыгнул на 3 (UTF-8 занимает 3 байта)
index=10, rune=界, type=int32 ← ещё +3
index=13, rune=!, type=int32

Через индекс — по байтам (часто ошибка)

s := "Hello, 世界!"

for i := 0; i < len(s); i++ {
fmt.Printf("index=%d, byte=%c\n", i, s[i])
}

Вывод:

index=0, byte=H
index=1, byte=e
...
index=7, byte=ä ← мусор! Это первый байт многобайтового символа
index=8, byte=¹ ← мусор!
index=9, byte= ← мусор!
index=10, byte=ç ← мусор!
...

Преобразования между типами

// Строка → слайс байтов
s := "Hello"
b := []byte(s) // [72 101 108 108 111]

// Строка → слайс рун
r := []rune(s) // [72 101 108 108 111]

// Слайс байтов → строка
s2 := string(b) // "Hello"

// Слайс рун → строка
s3 := string(r) // "Hello"

// Осторожно: преобразование []byte создаёт копию!

Подводные камни

1. Длина строки ≠ количество символов

s := "Привет"
fmt.Println(len(s)) // 12 (байт), не 6!
fmt.Println(utf8.RuneCountInString(s)) // 6 (символов)

2. Индексация строки возвращает байт, не руну

s := "Привет"
fmt.Printf("%c\n", s[0]) // Ð — мусор! Первый байт символа П
fmt.Printf("%c\n", s[1]) // — мусор! Второй байт символа П

3. Конкатенация строк создаёт новую строку

s := "Hello"
s2 := s + " World" // новая аллокация

Для множественных конкатенаций используйте strings.Builder:

var builder strings.Builder
for i := 0; i < 100; i++ {
builder.WriteString("Hello ")
}
result := builder.String()

4. Сравнение строк

// Лексикографическое сравнение (побайтовое)
fmt.Println("abc" < "abd") // true
fmt.Println("abc" == "abc") // true

// Для Unicode-нормализации используйте golang.org/x/text/unicode/norm

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

  • Всегда используйте range для перебора символов UTF-8 строк
  • Используйте utf8.RuneCountInString для подсчёта символов
  • Для работы с отдельными символами конвертируйте в []rune
  • Помните, что len(s) возвращает байты, а не символы
  • Избегайте прямой индексации s[i] для не-ASCII строк

Вопрос 8. Расскажите про мапы в Go: внутреннее устройство, алгоритмическая сложность, эвакуация данных, load factor, бакеты, метаданные. Что будет при записи в неинициализированную мапу? Что такое sync.Map и когда используется?

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

Ответ собеседника: Неполный. Рассказал про hash map, бакеты, O(1) сложность, рост вдвое, load factor ~6.5, эвакуацию данных, невозможность брать указатель, top hash в метаданных, make с подсказкой, панику при записи в nil map, sync.Map для read-heavy сценариев. Не смог подробно рассказать про метаданные бакетов и второй сценарий sync.Map.

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

Внутреннее устройство мапы

Мапа в Go — это указатель на структуру hmap:

type hmap struct {
count int // количество элементов
flags uint8 // флаги состояния (growing, iterating и т.д.)
B uint8 // log2 количества бакетов (2^B бакетов)
noverflow uint16 // количество overflow-бакетов
hash0 uint32 // seed для хеш-функции (рандомизация)
buckets *bmap // указатель на массив бакетов
oldbuckets *bmap // указатель на старые бакеты (при эвакуации)
nevacuate uintptr // прогресс эвакуации
extra *maextra // дополнительные данные (overflow pointers)
}

Структура бакета (bmap)

Каждый бакет содержит 8 слотов для пар ключ-значение плюс метаданные:

type bmap struct {
tophash [8]uint8 // старшие 8 бит хеша для каждого слота
// Далее в памяти идут:
// keys [8]keyType // ключи (хранятся отдельно для оптимизации)
// values [8]valueType // значения (хранятся отдельно)
// overflow *bmap // указатель на overflow-бакет (если есть)
}

Tophash — ключевая оптимизация

tophash хранит старшие 8 бит хеша ключа. Это позволяет быстро отбросить несовпадающие ключи без дорогого сравнения:

// Упрощённая логика поиска
func mapaccess(t *maptype, h *hmap, key unsafe.Pointer) unsafe.Pointer {
hash := t.hasher(key, h.hash0)
bucket := hash & ((1 << h.B) - 1) // маска для выбора бакета
top := tophash(hash) // старшие 8 бит

for i := 0; i < 8; i++ {
if b.tophash[i] != top {
continue // быстрый пропуск — не совпал tophash
}
// Только здесь сравниваем ключи
if t.keys.equal(key, b.keys[i]) {
return b.values[i]
}
}
// Проверяем overflow-бакет
...
}

Алгоритмическая сложность

ОперацияСредний случайХудший случай
ПоискO(1)O(n) — при коллизиях
ВставкаO(1)O(n) — при коллизиях
УдалениеO(1)O(n) — при коллизиях

Худший случай наступает, когда все ключи попадают в один бакет (цепочка overflow-бакетов).

Load factor и рост мапы

Load factor — среднее количество элементов на бакет. В Go порог ≈ 6.5.

Load factor = count / (2^B * 8)

При превышении порога происходит рост:

  • Выделяется новый массив бакетов в 2 раза больше
  • h.B увеличивается на 1
  • Данные переносятся инкрементально (не сразу)

Эвакуация данных (incremental evacuation)

Эвакуация происходит постепенно

Вопрос 9. Что такое Race Condition и какие средства борьбы с ним существуют в Go? Как работают мьютексы под капотом?

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

Ответ собеседника: Неполный. Race Condition — гонка данных при одновременной записи в общий ресурс. Средства борьбы: каналы, WaitGroup, ErrGroup, Mutex, RWMutex, atomic, Once, Cond. Не знает устройство мьютекса под капотом (предположил семафор или каналы).

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

Race Condition (Состояние гонки)

Race Condition возникает, когда две или более горутины одновременно обращаются к общему ресурсу, и хотя бы одна из них выполняет запись, без синхронизации.

// Классический пример race condition
var counter int

func main() {
for i := 0; i < 1000; i++ {
go func() {
counter++ // RACE: чтение-модификация-запись не атомарны
}}()
}
time.Sleep(time.Second)
fmt.Println(counter) // Результат < 1000, каждый раз разный
}

counter++ — это три операции: прочитать, увеличить, записать. Между ними может вмешаться другая горутина.

Средства борьбы с Race Condition

1. sync.Mutex — взаимное исключение

var (
counter int
mu sync.Mutex
)

func increment() {
mu.Lock()
defer mu.Unlock()
counter++
}

2. sync.RWMutex — 读写 мьютекс

var (
data map[string]string
mu sync.RWMutex
)

func read(key string) string {
mu.RLock() // блокирует писателей, не блокирует читателей
defer mu.RUnlock()
return data[key]
}

func write(key, value string) {
mu.Lock() // блокирует всех
defer mu.Unlock()
data[key] = value
}

RWMutex эффективен, когда чтений значительно больше, чем записей.

3. sync/atomic — атомарные операции

var counter int64

func increment() {
atomic.AddInt64(&counter, 1)
}

func getValue() int64 {
return atomic.LoadInt64(&counter)
}

Атомики используют инструкции процессора (CAS, XADD) без блокировок. Быстрее мьютексов для простых типов.

4. Каналы — каноничный подход Go

// Через каналы можно реализовать любую синхронизацию
type Counter struct {
ch chan func()
}

func NewCounter() *Counter {
c := &Counter{ch: make(chan func(), 1)}
go func() {
var value int
for f := range c.ch {
f(&value)
}
}()
return c
}

func (c *Counter) Increment() {
done := make(chan struct{})
c.ch <- func(v *int) {
*v++
close(done)
}
<-done
}

5. sync.WaitGroup — ожидание завершения горутин

var wg sync.WaitGroup

for i := 0; i < 1000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
// работа
}}()
}
wg.Wait() // блокирует, пока все горутины не вызовут Done()

6. sync.Once — однократное выполнение

var (
once sync.Once
instance *Singleton
)

func GetInstance() *Singleton {
once.Do(func() {
instance = &Singleton{} // выполнится ровно один раз
})
return instance
}

7. sync.Cond — условные переменные

var (
mu sync.Mutex
cond = sync.NewCond(&mu)
ready bool
)

// Горутина-потребитель
func consumer() {
mu.Lock()
for !ready {
cond.Wait() // освобождает мьютекс и ждёт сигнала
}
// ready == true
mu.Unlock()
}

// Горутина-продюсер
func producer() {
mu.Lock()
ready = true
cond.Signal() // разбудить одну ожидающую горутину
// cond.Broadcast() — разбудить все
mu.Unlock()
}

Устройство мьютекса под капотом

sync.Mutex в Go реализован через атомарные операции процессора, а не через семафоры ОС или каналы.

type Mutex struct {
state int32 // битовая маска: locked, woken, starving, waiters count
sema uint32 // семафор для блокировки горутин (используется только при конкуренции)
}

Биты поля state:

  • Бит 0: mutexLocked — мьютекс захвачен
  • Бит 1: mutexWoken — кто-то пробуждён
  • Бит 2: mutexStarving — режим starvation (защита от голодания)
  • Биты 3+: mutexWaiterShift — счётчик ожидающих горутин

Алгоритм Lock():

func (m *Mutex) Lock() {
// Быстрый путь: если мьютекс свободен, захватываем через CAS
if atomic.CompareAndSwapInt32(&m.state, 0, mutexLocked) {
return // захватили мгновенно
}
// Медленный путь: конкуренция
m.lockSlow()
}

func (m *Mutex) lockSlow() {
var waitStartTime int64
starving := false
awoke := false
iter := 0

for {
// Проверяем, можно ли захватить
old := m.state
new := old | mutexLocked
if old&mutexLocked == 0 {
// Мьютекс свободен — пытаемся захватить через CAS
if atomic.CompareAndSwapInt32(&m.state, old, new) {
return
}
continue
}

// Мьютекс занят — увеличиваем счётчик ожидающих
if !starving {
new = old + 1<<mutexWaiterShift
}

// Если мы были пробуждены или мьютекс в starving mode — снимаем woken флаг
if awoke {
new &^= mutexWoken
}

if atomic.CompareAndSwapInt32(&m.state, old, new) {
// Успешно встали в очередь — ждём на семафоре
runtime_SemacquireMutex(&m.sema, starving, 1)
...
}
}
}

Ключевые особенности реализации:

  1. Spin на первом этапе — несколько попыток CAS без блокировки (до 4 итераций на многоядерных системах)
  2. FIFO очередь — горутины выстраиваются в очередь, а не конкурируют хаотично
  3. Starvation mode — если горутина ждёт более 1 мс, мьютекс переходит в режим starvation: новый владелец передаётся напрямую следующему в очереди
  4. Sema только при конкуренции — семафор ОС используется только когда есть конкуренция, в отличие от наивной реализации

Алгоритм Unlock():

func (m *Mutex) Unlock() {
// Быстрый путь: нет ожидающих
new := atomic.AddInt32(&m.state, -mutexLocked)
if new == 0 {
return // никто не ждёт
}
// Медленный путь: нужно разбудить кого-то
m.unlockSlow(new)
}

Сравнение подходов

ПодходКогда использоватьНакладные расходы
sync.MutexОбщий случай, защита критической секцииНизкие (атомарки + семафор при конкуренции)
sync.RWMutexМного чтений, мало записейСредние
atomicПростые типы (int, pointer)Минимальные (одна инструкция CPU)
КаналыПередача владения данными, pipelineВыше (аллокации, scheduling)
sync.MapRead-heavy, разные горутины пишут по разным ключамСредние

Race Detector

Go имеет встроенный детектор гонок:

go run -race main.go
go test -race ./...

Он добавляет инструментацию ко всем обращениям к памяти и обнаруживает гонки в runtime. Накладные расходы: 5-10x замедление, 5-10x потребление памяти. Используется только в тестах и CI, не в продакшне.

Вопрос 10. Что такое каналы в Go? В чём разница между буферизированными и небуферизированными каналами? Как устроен канал под капотом? Что происходит при чтении/записи из nil-канала и закрытого канала?

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

Ответ собеседника: Неполный. Каналы — средство обмена между горутинами, буферизированные и небуферизированные. Под капотом — hchan с буфером, мьютексом, sendx/recvx. Ошибся: сказал что небуферизированный канал содержит один элемент. Чтение из nil-канала блокирует навсегда, запись в nil-канал блокирует. Чтение из закрытого — zero value, запись — паника.

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

Каналы — это типизированные очереди для коммуникации между горутинами

Каналы реализуют принцип CSP (Communicating Sequential Processes): «Don't communicate by sharing memory; share memory by communicating».

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

// Однонаправленные каналы (для типобезопасности в API)
func producer(ch chan<- int) { // только запись
ch <- 42
}

func consumer(ch <-chan int) { // только чтение
val := <-ch
}

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

ХарактеристикаНебуферизированныйБуферизированный
Созданиеmake(chan T)make(chan T, n)
Ёмкость0n
ЗаписьБлокируется до готового читателяБлокируется только при полном буфере
ЧтениеБлокируется до готового писателяБлокируется только при пустом буфере
СемантикаСинхронная передача (handshake)Асинхронная передача с очередью
// Небуферизированный: запись блокируется до чтения
ch := make(chan int)
go func() { ch <- 42 }() // заблокируется, пока кто-то не прочитает
val := <-ch

// Буферизированный: запись не блокируется, пока буфер не полон
ch := make(chan int, 2)
ch <- 1 // не блокируется
ch <- 2 // не блокируется
ch <- 3 // ЗАБЛОКИРУЕТСЯ — буфер полон

Внутреннее устройство канала (hchan)

type hchan struct {
qcount uint // количество элементов в буфере
dataqsiz uint // размер буера (ёмкость)
buf unsafe.Pointer // указатель на кольцевой буфер
sendx uint // индекс для следующей записи
recvx uint // индекс для следующего чтения
recvq waitq // очередь ожидающих читателей (sudog список)
sendq waitq // очередь ожидающих писателей (sudog список)
lock mutex // мьютекс для защиты структуры
}

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

type sudog struct {
g *goroutine // горутина
elem unsafe.Pointer // указатель на данные (для записи) или место назначения (для чтения)
next *sudog
prev *sudog
...
}

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

Буфер ёмкостью 4, qcount=2, recvx=1, sendx=3:

Index: [0] [1] [2] [3]
Data: [A] [B] [C] [ ]
^ ^
recvx sendx

Чтение: из index 1 → recvx становится 2
Запись: в index 3 → sendx становится 0 (wrap around)

Алгоритм записи (ch <- val)

func chansend(c *hchan, ep unsafe.Pointer) {
// 1. Если есть ожидающий читатель — отдаём ему напрямую (bypass buffer)
if sg := c.recvq.dequeue(); sg != nil {
send(c, sg, ep, ...) // прямая передача читателю
return
}

// 2. Если буфер не полон — пишем в буфер
if c.qcount < c.dataqsiz {
qp := chanbuf(c, c.sendx)
typedmemmove(c.elemtype, qp, ep) // копируем данные
c.sendx++
if c.sendx == c.dataqsiz {
c.sendx = 0 // wrap around
}
c.qcount++
return
}

// 3. Буфер полен и нет читателей — блокируемся
// Текущая горутина помещается в sendq и паркуется
mysg := acquireSudog()
mysg.elem = ep
c.sendq.enqueue(mysg)
gopark(...)
}

Алгоритм чтения (val := <-ch)

func chanrecv(c *hchan, ep unsafe.Pointer) {
// 1. Если есть ожидающий писатель — читаем напрямую
if sg := c.sendq.dequeue(); sg != nil {
recv(c, sg, ep, ...)
return
}

// 2. Если буфер не пуст — читаем из буфера
if c.qcount > 0 {
qp := chanbuf(c, c.recvx)
typedmemmove(c.elemtype, ep, qp)
c.recvx++
if c.recvx == c.dataqsiz {
c.recvx = 0
}
c.qcount--
return
}

// 3. Буфер пуст и нет писателей — блокируемся
mysg := acquireSudog()
c.recvq.enqueue(mysg)
gopark(...)
}

Поведение nil-канала и закрытого канала

// Nil канал
var ch chan int // nil

// Запись в nil-канал: блокирует горутину НАВСЕГДА
// ch <- 42 // deadlock (если это единственная горутина)

// Чтение из nil-канала: блокирует горутину НАВСЕГДА
// val := <-ch // deadlock

// Это свойство используется для динамического отключения case в select
select {
case v := <-ch: // если ch == nil, этот case никогда не сработает
fmt.Println(v)
default:
fmt.Println("channel is nil")
}
// Закрытый канал
ch := make(chan int, 2)
ch <- 1
ch <- 2
close(ch)

// Чтение из закрытого канала:
val, ok := <-ch // 1, true — ещё есть данные
val, ok := <-ch // 2, true — ещё есть данные
val, ok := <-ch // 0, false — буфер пуст, канал закрыт

// Запись в закрытый канал: ПАНИКА
// ch <- 3 // panic: send on closed channel

// Двойное закрытие: ПАНИКА
// close(ch) // panic: close of closed channel

// Закрытие nil-канала: ПАНИКА
// var ch chan int
// close(ch) // panic: close of nil channel

Безопасное закрытие канала

Только писатель должен закрывать канал. Если писателей несколько — используйте sync.WaitGroup:

func fanOut(input <-chan int, n int) []<-chan int {
outputs := make([]chan int, n)
for i := range outputs {
outputs[i] = make(chan int)
}

go func() {
defer func() {
for _, ch := range outputs {
close(ch) // закрываем все output-каналы
}
}()

for val := range input {
for _, ch := range outputs {
ch <- val
}
}
}()

result := make([]<-chan int, n)
for i, ch := range outputs {
result[i] = ch
}
return result
}

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

// 1. Done-канал для отмены
func worker(done <-chan struct{}) {
for {
select {
case <-done:
return // получили сигнал отмены
default:
// работа
}
}
}

// 2. Таймер через канал
select {
case val := <-ch:
fmt.Println("received:", val)
case <-time.After(5 * time.Second):
fmt.Println("timeout")
}

// 3. Pipeline
func generate(nums ...int) <-chan int {
out := make(chan int)
go func() {
for _, n := range nums {
out <- n
}
close(out)
}()
return out
}

func square(in <-chan int) <-chan int {
out := make(chan int)
go func() {
for n := range in {
out <- n * n
}
close(out)
}()
return out
}

// Использование
c := generate(2, 3, 4, 5)
out := square(square(c))
for n := range out {
fmt.Println(n) // 16, 81, 256, 625
}

Ключевые правила

  1. Каналы — ссылочный тип, передаются по ссылке (не копируются)
  2. Закрывать канал должен только писатель
  3. Чтение из закрытого канала возвращает zero value и ok=false
  4. Запись в закрытый канал — panic
  5. Nil-канал блокирует навсегда (используется в select для отключения case)
  6. Каналы реализуют FIFO-семантику

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

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

Ответ собеседника: Неполный. Select выбирает один case для выполнения. Контекст — для метаданных запроса и каскадного завершения. Хранение больших данных в контексте — антипаттерн. Каскадное завершение через закрытие канала ctx.Done(). Не смог объяснить механизм бродкаста через закрытие канала.

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

Как работает select

select — это конструкция для мультиплексирования операций с каналами. Блокируется до готовности хотя бы одного case.

select {
case v1 := <-ch1:
fmt.Println("ch1:", v1)
case v2 := <-ch2:
fmt.Println("ch2:", v2)
case ch3 <- 42:
fmt.Println("sent to ch3")
default:
fmt.Println("no channel ready")
}

Алгоритм работы select:

  1. Все case вычисляются один раз (направление и канал)
  2. Если есть default — выполняется немедленно, если ни один канал не готов
  3. Без default — горутина блокируется до готовности хотя бы одного case
  4. Если несколько каналов готовы одновременно — выбирается случайный (pseudo-random)
  5. Это гарантирует fair scheduling и предотвращает starvation
// Демонстрация случайного выбора
ch1 := make(chan int, 1)
ch2 := make(chan int, 1)
ch1 <- 1
ch2 <- 2

count1, count2 := 0, 0
for i := 0; i < 1000; i++ {
select {
case <-ch1:
count1++
ch1 <- 1 // возвращаем обратно
case <-ch2:
count2++
ch2 <- 2
}
}
fmt.Println("ch1:", count1, "ch2:", count2) // ~500 каждый

Select с nil-каналом — отключение case

var ch chan int // nil

select {
case v := <-ch: // этот case НИКОГДА не сработает
fmt.Println(v)
default:
fmt.Println("disabled")
}

Это используется для динамического управления pipeline:

func merge(ch1, ch2 <-chan int) <-chan int {
out := make(chan int)
go func() {
defer close(out)
for ch1 != nil || ch2 != nil {
select {
case v, ok := <-ch1:
if !ok {
ch1 = nil // отключаем этот case
continue
}
out <- v
case v, ok := <-ch2:
if !ok {
ch2 = nil
continue
}
out <- v
}
}
}()
return out
}

Контекст (context.Context)

Контекст — это интерфейс для передачи deadline, cancellation signal и request-scoped значений:

type Context interface {
Deadline() (deadline time.Time, ok bool)
Done() <-chan struct{}
Err() error
Value(key any) any
}

Типы контекстов:

// 1. Background — корневой контекст, никогда не отменяется
ctx := context.Background()

// 2. TODO — заглушка, когда не знаете какой контекст использовать
ctx := context.TODO()

// 3. WithCancel — ручная отмена
ctx, cancel := context.WithCancel(context.Background())
defer cancel()

// 4. WithTimeout — автоматическая отмена по таймауту
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()

// 5. WithDeadline — отмена к конкретному времени
ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(5*time.Second))
defer cancel()

// 6. WithValue — хранение значений
ctx := context.WithValue(context.Background(), "userID", 123)

Каскадное завершение горутин

Контекст образует дерево: при отмене родителя отменяются все потомки.

func main() {
ctx, cancel := context.WithCancel(context.Background())

go worker(ctx, "worker-1")
go worker(ctx, "worker-2")

time.Sleep(2 * time.Second)
cancel() // каскадная отмена всех воркеров

time.Sleep(1 * time.Second)
}

func worker(ctx context.Context, name string) {
for {
select {
case <-ctx.Done():
fmt.Println(name, "stopped:", ctx.Err())
return
default:
fmt.Println(name, "working...")
time.Sleep(500 * time.Millisecond)
}
}
}

Механизм бродкаста через закрытие канала

Закрытие канала — это бродкаст (broadcast), потому что:

  1. Канал Done() возвращает один и тот же канал всем вызывающим
  2. При закрытии канала все горутины, читающие из него, получают zero value
  3. Это реализовано на уровне runtime: закрытие канала пробуждает все ожидающие горутины
// Упрощённая реализация broadcast через канал
type Broadcaster struct {
ch chan struct{}
mu sync.Mutex
}

func NewBroadcaster() *Broadcaster {
return &Broadcaster{ch: make(chan struct{})}
}

func (b *Broadcaster) Done() <-chan struct{} {
return b.ch // все получают тот же канал
}

func (b *Broadcaster) Broadcast() {
b.mu.Lock()
defer b.mu.Unlock()
close(b.ch) // все читатели получат сигнал
}

// Внутренняя реализация context.WithCancel:
type cancelCtx struct {
Context
mu sync.Mutex
done chan struct{} // создаётся лениво
children map[canceler]bool
err error
}

func (c *cancelCtx) Done() <-chan struct{} {
c.mu.Lock()
defer c.mu.Unlock()
if c.done == nil {
c.done = make(chan struct{})
}
return c.done
}

func (c *cancelCtx) cancel(removeFromParent bool, err error) {
c.mu.Lock()
defer c.mu.Unlock()
c.err = err
close(c.done) // бродкаст всем подписчикам
for child := range c.children {
child.cancel(false, err) // рекурсивная отмена детей
}
}

Проблемы хранения больших данных в контексте

// АНТИПАТТЕРН: хранение больших структур в контексте
type RequestContext struct {
UserID int
SessionID string
Permissions []string
Metadata map[string]interface{}
// ... ещё 50 полей
}

func handler(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
// Плохо: данные неявны, не видны в сигнатуре функции
ctx = context.WithValue(ctx, "requestCtx", &RequestContext{...})
processOrder(ctx)
}

func processOrder(ctx context.Context) {
// Плохо: нужно приводить тип, нет compile-time проверки
reqCtx := ctx.Value("requestCtx").(*RequestContext)
// Если кто-то забыл положить значение — panic в runtime
}

Проблемы:

  1. Неявность — данные не видны в сигнатурах функций, нужно читать реализацию
  2. Type safetyValue() возвращает any, нужен type assertion
  3. Ключи — строковые ключи могут конфликтовать между пакетами
  4. Размер — контекст передаётся по всей цепочке вызовов, большие данные увеличивают накладные расходы
  5. Тестируемость — сложнее тестировать функции, зависящие от контекста

Правильный подход — явная передача:

// Хорошо: данные видны в сигнатуре
func processOrder(ctx context.Context, reqCtx *RequestContext) {
// явная зависимость, легко тестировать
}

Когда context.Value уместен:

// Уместно: небольшие, инфраструктурные данные
type contextKey string

const (
requestIDKey contextKey = "requestID"
userIDKey contextKey = "userID"
)

func WithRequestID(ctx context.Context, id string) context.Context {
return context.WithValue(ctx, requestIDKey, id)
}

func GetRequestID(ctx context.Context) (string, bool) {
v := ctx.Value(requestIDKey)
if v == nil {
return "", false
}
return v.(string), true
}

Практический пример: HTTP-сервер с graceful shutdown

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

srv := &http.Server{
Addr: ":8080",
Handler: router(),
}

go func() {
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatal(err)
}
}()

<-ctx.Done() // ждём сигнала ОС

shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()

if err := srv.Shutdown(shutdownCtx); err != nil {
log.Fatal("forced shutdown:", err)
}
}

Ключевые правила:

  1. select с несколькими готовыми case выбирает случайный — это fair scheduling
  2. Контекст передаётся первым аргументом в функциях (Go convention)
  3. Храните в контексте только request-scoped данные (requestID, auth token)
  4. Не используйте контекст для передачи опциональных параметров — это антипаттерн
  5. defer cancel() всегда вызывайте сразу после создания контекста
  6. Закрытие канала — единственный встроенный механизм broadcast в Go

Вопрос 12. Как реализовать ожидание закрытия двух каналов одновременно?

Таймкод: 00:53:42

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

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

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

Решение 1: Флаги + select с nil-каналами

func waitForBoth(ch1, ch2 <-chan int) {
ch1Closed := false
ch2Closed := false

for !ch1Closed || !ch2Closed {
select {
case _, ok := <-ch1:
if !ok {
ch1Closed = true
ch1 = nil // отключаем case — nil-канал блокирует навсегда
}
case _, ok := <-ch2:
if !ok {
ch2Closed = true
ch2 = nil
}
}
}
fmt.Println("Both channels closed")
}

Идея: при закрытии канала устанавливаем его в nil, что отключает соответствующий case в select (nil-канал никогда не готов).

Решение 2: Через отдельные горутины

func waitForBoth(ch1, ch2 <-chan int) {
done := make(chan struct{})

go func() {
// Читаем всё из ch1 до закрытия
for range ch1 {
// drain channel
}
// Читаем всё из ch2 до закрытия
for range ch2 {
// drain channel
}
close(done)
}()

<-done
fmt.Println("Both channels closed")
}

Проблема: если ch2 закрыт раньше ch1, горутина всё равно будет ждать ch1.

Решение 3: Через sync.WaitGroup

func waitForBoth(ch1, ch2 <-chan int) {
var wg sync.WaitGroup
wg.Add(2)

drain := func(ch <-chan int) {
defer wg.Done()
for range ch {
// drain channel
}
}

go drain(ch1)
go drain(ch2)

wg.Wait()
fmt.Println("Both channels closed")
}

Решение 4: Универсальная функция для N каналов

func waitForAll(channels ...<-chan int) {
var wg sync.WaitGroup
wg.Add(len(channels))

drain := func(ch <-chan int) {
defer wg.Done()
for range ch {
}
}

for _, ch := range channels {
go drain(ch)
}

wg.Wait()
}

// Использование
waitForAll(ch1, ch2, ch3, ch4)

Практический пример: ожидание завершения нескольких воркеров

func main() {
ctx1, cancel1 := context.WithCancel(context.Background())
ctx2, cancel2 := context.WithCancel(context.Background())

done1 := worker(ctx1, "worker-1")
done2 := worker(ctx2, "worker-2")

// Оба воркера работают...

// Отменяем оба
cancel1()
cancel2()

// Ждём завершения обоих
waitForBoth(done1, done2)
fmt.Println("All workers stopped")
}

func worker(ctx context.Context, name string) <-chan struct{} {
done := make(chan struct{})
go func() {
defer close(done)
for {
select {
case <-ctx.Done():
fmt.Println(name, "shutting down...")
// cleanup
return
default:
// work
time.Sleep(100 * time.Millisecond)
}
}
}()
return done
}

Ключевые моменты:

  1. select выполняет только один case — для ожидания нескольких каналов нужен цикл
  2. Установка канала в nil — идиоматический способ отключить case в select
  3. for range ch — читает из канала до его закрытия
  4. Для N каналов удобнее использовать sync.WaitGroup с отдельными горутинами