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

Готовимся к техническому собеседованию на Go за 30 минут!

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

Сегодня мы разберём расшифровку собеседования по Go, в которой интервьюер последовательно анализирует с кандидатом типовые задачи на конкурентность: синхронизацию структур данных (стек, буфер, кэш), работу с nil-каналами, а также разграничение понятий Data Race и Race Condition с практическими примерами кода. В ходе диалога демонстрируются распространённые ошибки — копирование объектов из пакета sync, несинхронизированный доступ к полям срезов и структур, рекурсивные блокировки — и обсуждаются способы их исправления. В завершение интервьюер рассказывает о своём курсе подготовки к собеседованиям, включающем 100 задач по Go и конкурентности.

Вопрос 1. Какие проблемы синхронизации присутствуют в коде стека с мьютексом, где продюсер кладёт 1000 элементов, а 100 горутин по 10 раз вызывают Top затем Pop?

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

Ответ собеседника: Правильный. Обнаружены следующие проблемы: 1) Копирование объекта из пакета sync (мьютекса) — такие объекты нельзя копировать, это приводит к нарушению синхронизации. 2) Копирование ресивера (получателя метода) — аналогичная проблема. 3) Несинхронизированный доступ к полю длины среза — чтение len() происходит без блокировки, в то время как другая горутина может модифицировать срез через append, что приводит к data race. 4) Проблема API: методы Top и Pop вызываются отдельно, между ними нет синхронизации — другая горутина может изменить стек между вызовами, и результат Pop не будет соответствовать ранее полученному Top. Решение — объединить логику в один атомарный метод.

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

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

1. Копирование sync.Mutex (и других объектов из пакета sync)

Объекты sync.Mutex, sync.RWMutex, sync.WaitGroup, sync.Once, sync.Cond, sync.Pool и sync.Map категорически нельзя копировать после первого использования. Это прямо указано в документации Go:

> A Mutex must not be copied after first use.

При копировании мьютекса создаётся его полная копия с тем же внутренним состоянием (заблокирован/разблокирован). Если оригинал заблокирован, копия будет думать, что она тоже заблокирована, и наоборот. Это приводит к:

  • Неопределённому поведению (undefined behavior)
  • Возможным дедлокам
  • Нарушению взаимного исключения — две горутина могут одновременно войти в критическую секцию

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

// ПЛОХО: передача по значению копирует мьютекс
func process(s Stack) { // Stack содержит sync.Mutex
s.mu.Lock() // блокируется КОПИЯ мьютекса, а не оригинал
defer s.mu.Unlock()
// ...
}

// ПРАВИЛЬНО: передача по указателю
func process(s *Stack) {
s.mu.Lock()
defer s.mu.Unlock()
// ...
}

Для обнаружения этой проблемы используется go vet и флаг -race при запуске тестов.

2. Копирование ресивера (receiver) методов

Если методы Top() и Pop() определены на значимом ресивере (value receiver), а не на указателе (pointer receiver), то при каждом вызове метода создаётся копия всей структуры, включая мьютекс:

// ПЛОХО: value receiver копирует структуру с мьютексом
func (s Stack) Top() int {
s.mu.Lock() // блокируется копия мьютекса!
defer s.mu.Unlock()
return s.items[len(s.items)-1]
}

// ПРАВИЛЬНО: pointer receiver
func (s *Stack) Top() int {
s.mu.Lock()
defer s.mu.Unlock()
return s.items[len(s.items)-1]
}

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

3. Несинхронизированный доступ к полю длины среза

Если где-то в коде (например, в методе Len() или IsEmpty()) происходит чтение len(s.items) без захвата мьютекса, это создаёт data race:

// ПЛОХО: чтение без блокировки
func (s *Stack) Len() int {
return len(s.items) // data race: другая горутина может делать append
}

// ПРАВИЛЬНО: чтение под блокировкой
func (s *Stack) Len() int {
s.mu.Lock()
defer s.mu.Unlock()
return len(s.items)
}

Операция len() на срезе сама по себе атомарна (читает поле структуры slice header), но если другая горутина одновременно модифицирует срез через append, который может перевыделить память и изменить указатель на underlying array, то чтение len() без синхронизации — это классический data race.

4. Проблема TOCTOU (Time of Check to Time of Use) — раздельные Top и Pop

Это наиболее тонкая и важная проблема проектирования API. Когда Top() и Pop() — это два отдельных метода, между их вызовами может вмешаться другая горутина:

// Проблемный паттерн использования:
val := stack.Top() // горутина A видит элемент X на вершине
// В этот момент горутина B вызывает Pop() и забирает X
stack.Pop() // горутина A забирает уже не X, а другой элемент!

Каждый из методов по отдельности корректно синхронизирован, но комбинация вызовов не является атомарной. Это классическая проблема TOCTOU.

Решение — атомарный метод:

func (s *Stack) Pop() (int, bool) {
s.mu.Lock()
defer s.mu.Unlock()

if len(s.items) == 0 {
return 0, false
}

idx := len(s.items) - 1
val := s.items[idx]
s.items = s.items[:idx] // освобщаем ссылку для GC
return val, true
}

Полная корректная реализация стека:

type Stack struct {
mu sync.Mutex
items []int
}

func NewStack() *Stack {
return &Stack{}
}

func (s *Stack) Push(val int) {
s.mu.Lock()
defer s.mu.Unlock()
s.items = append(s.items, val)
}

func (s *Stack) Pop() (int, bool) {
s.mu.Lock()
defer s.mu.Unlock()

if len(s.items) == 0 {
return 0, false
}

idx := len(s.items) - 1
val := s.items[idx]
s.items[idx] = 0 // обнуляем для GC (важно для ссылочных типов)
s.items = s.items[:idx]
return val, true
}

func (s *Stack) Len() int {
s.mu.Lock()
defer s.mu.Unlock()
return len(s.items)
}

Дополнительные рекомендации:

  • Использовать go test -race для обнаружения data race
  • Использовать go vet для обнаружения копирования sync-объектов
  • Для более сложных сценариев рассмотреть sync.RWMutex если чтений значительно больше, чем записей
  • Для lock-free сценариев рассмотреть atomic.Value или каналы
  • Для production-кода рассмотреть использование golang.org/x/sync или специализированных конкурентных структур данных

Вопрос 2. В чём проблема кода буфера, где метод Add добавляет значение в срез под блокировкой, а метод Data возвращает этот же срез под блокировкой?

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

Ответ собеседника: Правильный. Проблема в том, что срез передаётся наружу пользователю, который может модифицировать его без синхронизации — обращаться по индексу, изменять элементы, делать append. Это нарушает инкапсуляцию и приводит к data race. Решение: возвращать глубокую копию среза или использовать функциональный подход с передачей функции-обработчика, которая работает с элементами под блокировкой.

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

Ответ собеседника полностью корректен. Разберём проблему глубже с деталями и примерами.

Суть проблемы: утечка внутреннего состояния

В Go срез (slice) — это структура из трёх полей: указатель на underlying array, длина и ёмкость ({ptr, len, cap}). Когда метод Data() возвращает внутренний срез, он возвращает этот заголовок среза, который указывает на тот же самый участок памяти. После того как блокировка в Data() снята, получатель среза имеет прямой доступ к внутреннему массиву буфера без какой-либо синхронизации.

// Проблемный код
type Buffer struct {
mu sync.Mutex
items []int
}

func (b *Buffer) Add(val int) {
b.mu.Lock()
defer b.mu.Unlock()
b.items = append(b.items, val)
}

func (b *Buffer) Data() []int {
b.mu.Lock()
defer b.mu.Unlock()
return b.items // ← ОПАСНО: возвращаем внутренний срез
}

Что может пойти не так:

buf := NewBuffer()
buf.Add(1)
buf.Add(2)
buf.Add(3)

// Горутина 1 получает срез
data := buf.Data()

// Горутина 2 модифицирует буфер
buf.Add(4) // может перевыделить underlying array

// Горутина 1 читает из полученного ранее среза
fmt.Println(data[0]) // data race! Может быть мусор, другой элемент или паника

Конкретные проблемы:

  1. Запись по индексу — получатель может сделать data[0] = 999, что изменит внутреннее состояние буфера без блокировки
  2. Append к полученному срезу — если cap(data) > len(data), append перезапишет элементы в underlying array, которые могут использоваться буфером
  3. Перевыделение памяти — если Add вызывает append, который увеличивает capacity, underlying array может быть перемещён, и старый срез будет указывать на устаревшую память

Решение 1: Возврат копии среза (defensive copy)

func (b *Buffer) Data() []int {
b.mu.Lock()
defer b.mu.Unlock()

result := make([]int, len(b.items))
copy(result, b.items)
return result
}

Плюсы: простота, безопасность, получатель может делать с результатом что угодно. Минусы: аллокация нового среза при каждом вызове, копирование O(n).

Решение 2: Функциональный подход (callback)

func (b *Buffer) WithData(fn func([]int)) {
b.mu.Lock()
defer b.mu.Unlock()
fn(b.items)
}

// Использование:
buf.WithData(func(items []int) {
for _, v := range items {
fmt.Println(v)
}
})

Плюсы: нет аллокаций, данные никогда не покидают защищённую секцию, максимальная безопасность. Минусы: менее удобный API, нельзя сохранить результат для последующего использования.

Решение 3: Возврат среза с защитой от модификации (для read-only сценариев)

func (b *Buffer) Data() []int {
b.mu.Lock()
defer b.mu.Unlock()

// Возвращаем срез с ёмкостью = длине, чтобы append не мог
// перезаписать элементы в исходном массиве
result := make([]int, len(b.items))
copy(result, b.items)
return result
}

Решение 4: Использование sync.Map или lock-free структур

Для сценариев с высокой конкуренцией и частым чтением можно рассмотреть atomic.Value для атомарной замены указателя на immutable структуру данных.

Общий принцип:

Это проявление фундаментального правила параллельного программирования: нельзя передавать ссылки на внутреннее изменяемое состояние объекта за пределы синхронизированной секции. Это аналогично проблеме возврата указателя на внутренний объект в Java/C++ — нарушение инкапсуляции приводит к неконтролируемому доступу из нескольких горутин.

Для обнаружения этой проблемы в Go используется флаг -race при запуске тестов — он детектирует конкурентный доступ к одним и тем же участкам памяти.

Вопрос 3. Какая проблема возникает в коде кэша, где метод Get вызывает метод Size под захваченным мьютексом, а метод Size также захватывает тот же мьютекс?

Таймкод: 00:09:52

Ответ собеседника: Правильный. Возникает проблема рекурсивной блокировки (deadlock) — мьютекс в Go не является рекурсивным, поэтому повторный захват того же мьютекса той же горутиной приводит к вечной блокировке. Решение: использовать метод с суффиксом Locked (например, SizeLocked), который вызывается только под уже захваченным мьютексом и не пытается захватить его повторно.

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

Ответ собеседника полностью корректен. Разберём проблему подробнее.

Почему sync.Mutex не рекурсивен

В отличие от, например, std::recursive_mutex в C++ или ReentrantLock в Java, sync.Mutex в Go не поддерживает рекурсивную (рекурсивную) блокировку. Если горутина уже захватила мьютекс и пытается захватить его снова, она заблокируется навсегда, ожидая саму себя. Это классический deadlock.

// Проблемный код
type Cache struct {
mu sync.Mutex
items map[string]int
}

func (c *Cache) Size() int {
c.mu.Lock()
defer c.mu.Unlock()
return len(c.items)
}

func (c *Cache) Get(key string) (int, bool) {
c.mu.Lock()
defer c.mu.Unlock()

// Вызов Size() под уже захваченным мьютексом → DEADLOCK
fmt.Printf("Cache size: %d\n", c.Size())

val, ok := c.items[key]
return val, ok
}

Когда Get вызывает Size, мьютекс уже захвачен текущей горутиной. Size пытается захватить тот же мьютекс и блокируется. Никакая другая горутина не может его разблокировать, потому что текущая горутина ждёт внутри Size. Deadlock.

Решение 1: Паттерн «Locked» / «internal» методы

Самое распространённое и идиоматичное решение — разделить методы на публичные (с блокировкой) и внутренние (без блокировки):

type Cache struct {
mu sync.Mutex
items map[string]int
}

// sizeLocked — внутренний метод, вызывается ТОЛЬКО под захваченным мьютексом
func (c *Cache) sizeLocked() int {
return len(c.items)
}

// Size — публичный метод с блокировкой
func (c *Cache) Size() int {
c.mu.Lock()
defer c.mu.Unlock()
return c.sizeLocked()
}

func (c *Cache) Get(key string) (int, bool) {
c.mu.Lock()
defer c.mu.Unlock()

// Безопасно: вызываем internal-метод под уже захваченным мьютексом
fmt.Printf("Cache size: %d\n", c.sizeLocked())

val, ok := c.items[key]
return val, ok
}

func (c *Cache) Set(key string, val int) {
c.mu.Lock()
defer c.mu.Unlock()
c.items[key] = val
}

Этот паттерн широко используется в стандартной библиотеке Go и в крупных проектах (Kubernetes, Docker и др.). Соглашение об именовании: внутренний метод называют с суффиксом Locked, locked, unsafe или с префиксом подчёркивания.

Решение 2: Вынесение логики без вызова метода

Иногда проще не вызывать другой метод, а напрямую обратиться к данным:

func (c *Cache) Get(key string) (int, bool) {
c.mu.Lock()
defer c.mu.Unlock()

fmt.Printf("Cache size: %d\n", len(c.items)) // напрямую, без вызова Size()

val, ok := c.items[key]
return val, ok
}

Решение 3: Использование defer для разблокировки вместо Lock/Unlock

Если логика сложная и есть несколько точек выхода, можно использовать явный Unlock в конце функции, но это менее безопасно:

func (c *Cache) Get(key string) (int, bool) {
c.mu.Lock()
// Не используем defer — разблокируем вручную перед return
size := len(c.items)
val, ok := c.items[key]
c.mu.Unlock()
return val, ok
}

Этот подход хрупкий: легко забыть Unlock при добавлении нового пути возврата.

Почему Go не сделал рекурсивный мьютекс

Разработчики Go осознанно отказались от рекурсивных мьютексов, потому что:

  • Рекурсивные мьютексы маскируют проблемы проектирования — если метод A вызывает метод B, и оба захватывают мьютекс, это сигнал о том, что нужно разделить ответственность
  • Рекурсивные мьютексы дороже по производительности (нужно хранить владельца и счётчик захватов)
  • Рекурсивные мьютексы могут приводить к тонким багам, когда промежуточный метод меняет состояние, на которое рассчитывал вызывающий метод

Аналогия с другими языками:

В C++ есть std::recursive_mutex, но его использование считается антипаттерном. В Java ReentrantLock существует, но опытные разработчики рекомендуют избегать рекурсивных блокировок и использовать тот же паттерн разделения на locked/unlocked методы.

Вопрос 4. В чём проблема использования встраивания (embedding) типа sync.Mutex в структуре Data с полем среза значений?

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

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

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

Ответ собеседника полностью корректен. Разберём проблему детальнее.

Что такое embedding в Go

Встраивание (embedding) — это механизм, при котором методы и поля встроенного типа «поднимаются» (promote) на уровень внешней структуры. Это не наследование, но снаружи выглядит похоже.

// Проблемный код: встраивание sync.Mutex
type Data struct {
sync.Mutex // embedding — методы Lock/Unlock промотируются наружу
items []int
}

При таком встраивании структура Data получает публичные методы Lock() и Unlock() напрямую. Пользователь структуры может сделать:

d := &Data{}

// Пользователь напрямую манипулирует мьютексом!
d.Lock()
d.items = append(d.items, 42) // data race — append без координации с методами Data
d.Unlock()

// Хуже того — пользователь может забыть Unlock:
d.Lock()
d.items = append(d.items, 42)
// забыли Unlock → deadlock при следующем вызове любого метода Data

Конкретные проблемы:

  1. Нарушение инкапсуляции — пользователь получает доступ к внутреннему механизму синхронизации. Он может вызывать Lock/Unlock произвольное количество раз, забывать разблокировать, или блокировать надолго, создавая contention.

  2. Невозможность гарантии корректности — если методы Data сами управляют блокировкой (например, Add блокирует в начале и разблокирует в конце), а пользователь вызвал Lock до этого, то Add заблокируется навсегда (deadlock, так как мьютекс не рекурсивен).

  3. Непредсказуемое поведение — пользователь может вызвать Unlock на неблокированном мьютексе, что вызовет panic: sync: unlock of unlocked mutex.

  4. Нарушение контракта — внутренняя логика синхронизации проектируется с определёнными инвариантами. Прямой доступ к мьютексу позволяет нарушить эти инварианты.

Правильное решение: неэкспортируемое поле

type Data struct {
mu sync.Mutex // неэкспортируемое поле — методы Lock/Unlock недоступны снаружи
items []int
}

func (d *Data) Add(val int) {
d.mu.Lock()
defer d.mu.Unlock()
d.items = append(d.items, val)
}

func (d *Data) Get(index int) (int, bool) {
d.mu.Lock()
defer d.mu.Unlock()
if index < 0 || index >= len(d.items) {
return 0, false
}
return d.items[index], true
}

Теперь mu — приватное поле, и только методы самой структуры Data могут управлять блокировкой.

Когда embedding мьютекса допустим

Единственный случай, когда embedding sync.Mutex — это разумный выбор — когда структура сама по себе является примитивом синхронизации или обёрткой над ним, и вы сознательно хотите предоставить Lock/Unlock как часть публичного API:

// Разумное использование: структура — это обёртка над мьютексом
type MutexCounter struct {
sync.Mutex
count int
}

func (c *MutexCounter) Increment() {
c.Lock() // собственный метод использует встроенный мьютекс
defer c.Unlock()
c.count++
}

// Пользователь тоже может Lock/Unlock — это часть контракта

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

Итого:

Встраивание sync.Mutex нарушает инкапсуляцию и позволяет внешнему коду напрямую манипулировать примитивом синхронизации, что приводит к deadlock-ам, data race-ам и panic-ам. Правильная практика — использовать sync.Mutex как неэкспортируемое поле (mu sync.Mutex).

Вопрос 5. Почему в коде с двумя буферизированными каналами и функцией waitForChannels, которая ждёт закрытия обоих каналов через select с флагами, появляются ложные срабатывания и лишние выводы?

Таймкод: 00:14:49

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

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

Ответ собеседника полностью корректен. Разберём проблему подробнее с примерами.

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

В Go закрытый канал не перестаёт существовать — он продолжает «работать», но особым образом:

  • Чтение из закрытого канала немедленно возвращает нулевое значение типа и false (второй return value) — неблокирующе
  • Запись в закрытый канал вызывает panic
  • Закрытие уже закрытого канала вызывает panic
ch := make(chan int, 2)
ch <- 1
ch <- 2
close(ch)

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

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

// Проблемный код
func waitForChannels(ch1, ch2 chan int) {
ch1Closed := false
ch2Closed := false

for !(ch1Closed && ch2Closed) {
select {
case _, ok := <-ch1:
if !ok {
ch1Closed = true
fmt.Println("ch1 closed")
}
case _, ok := <-ch2:
if !ok {
ch2Closed = true
fmt.Println("ch2 closed")
}
}
}
}

После закрытия ch1, ветка case _, ok := <-ch1 продолжает срабатывать немедленно и неблокирующе на каждой итерации цикла. Это приводит к:

  1. Busy loop (активное ожидание) — если ch1 закрыт, а ch2 ещё нет, цикл крутится бесконечно, сжигая CPU
  2. Ложные срабатыванияfmt.Println("ch1 closed") выполняется многократно, хотя канал закрылся только один раз
  3. Непредсказуемый порядок — если оба канала закрыты, select случайно выбирает любую из веток, и обе ветки срабатывают многократно

Решение: nil-каналы в select

Чтение из nil-канала блокируется навсегда. В select ветка с nil-каналом просто игнорируется:

func waitForChannels(ch1, ch2 chan int) {
for ch1 != nil || ch2 != nil {
select {
case _, ok := <-ch1:
if !ok {
ch1 = nil // блокирует эту ветку в дальнейших итерациях
fmt.Println("ch1 closed")
}
case _, ok := <-ch2:
if !ok {
ch2 = nil // блокирует эту ветку в дальнейших итерациях
fmt.Println("ch2 closed")
}
}
}
}

Теперь после закрытия ch1 переменная ch1 становится nil, и ветка case _, ok := <-ch1 никогда не срабатывает. Select продолжает ждать только на ch2. Когда оба канала закрыты и оба переменных стали nil, условие ch1 != nil || ch2 != nil становится false, и цикл завершается.

Более идиоматичный подход: for range

Если нужно просто дождаться закрытия каналов и прочитать все данные, можно использовать for range:

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

go func() {
defer wg.Done()
for val := range ch1 {
fmt.Println("ch1:", val)
}
fmt.Println("ch1 closed")
}()

go func() {
defer wg.Done()
for val := range ch2 {
fmt.Println("ch2:", val)
}
fmt.Println("ch2 closed")
}()

wg.Wait()
}

Ключевые правила работы с закрытыми каналами в select:

  • После закрытия канала обнуляйте переменную (ch = nil), чтобы исключить ветку из select
  • Используйте for range для чтения из канала до закрытия — это безопаснее и читабельнее
  • Помните, что закрытый буферизованный канал сначала отдаёт буферизованные данные, а потом — нулевые значения

Вопрос 6. Что будет выведено в коде с буферизированным каналом ёмкостью 1, где в select есть default-ветка и операции чтения/записи в один и тот же канал, а флаг устанавливается в true после чтения из канала?

Таймкод: 00:19:46

Ответ собеседника: Правильный. Будет выведено 1, 2, 3. Сначала default-ветка записывает 1 в канал. Затем select читает значение из канала (выводится 2), после чего канал присваивается nil. Далее чтение из nil-канала блокируется, запись в nil-канал тоже блокируется, поэтому срабатывает default-ветка (выводится 3) и цикл завершается по флагу true.

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

Ответ собеседника корректен по сути, но требует уточнения логики. Разберём пошагово.

Восстановим код по описанию

func main() {
ch := make(chan int, 1)
done := false

for !done {
select {
case ch <- 1: // попытка записи в канал
fmt.Println("1")
case val := <-ch: // попытка чтения из канала
fmt.Println("2")
ch = nil // обнуляем канал
done = true // устанавливаем флаг завершения
default:
fmt.Println("3")
}
}
}

Пошаговый разбор выполнения

Итерация 1: Канал пустой, буфер свободен.

  • case ch <- 1 — канал не заполнен, запись возможна. Но select выбирает случайно из всех готовых веток.
  • case val := <-ch — канал пуст, чтение заблокировано. Ветка не готова.
  • default — всегда готова.

Если select выбирает case ch <- 1: записываем 1 в канал, выводим "1".

Если select выбирает default: выводим "3", канал остаётся пустым, цикл повторяется.

Итерация 2 (после записи 1): Канал содержит значение 1.

  • case ch <- 1 — канал заполнен (ёмкость 1), запись заблокирована. Ветка не готова.
  • case val := <-ch — канал содержит значение, чтение возможно. Ветка готова.
  • default — всегда готова.

Если select выбирает case val := <-ch: читаем 1 из канала, выводим "2", обнуляем ch = nil, устанавливаем done = true.

Если select выбирает default: выводим "3", канал остаётся с значением 1.

Итерация 3 (после чтения и ch = nil):

  • case ch <- 1ch == nil, запись в nil-канал блокируется навсегда. Ветка не готова.
  • case val := <-chch == nil, чтение из nil-канала блокируется навсегда. Ветка не готова.
  • default — единственная готовая ветка. Выводим "3".

После этого done == true, цикл завершается.

Важный нюанс: неопределённость порядка

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

  • Возможный вывод: 1 2 3 (если на каждом шаге выбирается «нужная» ветка)
  • Возможный вывод: 3 3 3 1 2 3 (если default срабатывает несколько раз подряд)
  • Возможный вывод: 3 1 3 2 3 (произвольный порядок)

Однако если вопрос подразумевает конкретный детерминированный код, где default выполняет запись (а не просто печатает), то логика собеседника верна.

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

  • default в select делает его неблокирующим — если ни одна ветка не готова, выполняется default
  • Запись в nil-канал блокируется навсегда
  • Чтение из nil-канала блокируется навсегда
  • Закрытый канал читается неблокирующе (возвращает нулевое значение)
  • Select с несколькими готовыми ветками выбирает случайную — это важно для корректности алгоритмов

Вопрос 7. Что такое Data Race и Race Condition, в чём разница между этими понятиями?

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

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

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

Ответ собеседника полностью корректен. Разберём оба понятия глубже с примерами.

Data Race (гонка данных)

Data Race — это конкретное, формально определённое состояние: два или более потока обращаются к одной и той же ячейке памяти одновременно, и хотя бы одно из обращений — запись, при этом отсутствует синхронизация между этими обращениями.

Формальное определение (модель памяти Go):

> A data race occurs when two goroutines access the same variable concurrently and at least one of the accesses is a write.

Data Race — это проблема уровня памяти и аппаратного обеспечения. Он приводит к:

  • Неопределённому поведению (undefined behavior)
  • Повреждению данных (torn reads/writes)
  • Непредсказуемым результатам
// Пример Data Race
var counter int

go func() { counter++ }() // горутина 1: запись
go func() { counter++ }() // горутина 2: запись
// Data Race: обе горутины пишут в одну переменную без синхронизации

Data Race обнаруживается автоматически с помощью Go Race Detector (go run -race, go test -race).

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

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

// Пример Race Condition БЕЗ Data Race (с мьютексом)
var (
mu sync.Mutex
balance int
)

func withdraw(amount int) bool {
mu.Lock()
defer mu.Unlock()

if balance >= amount { // проверка
time.Sleep(time.Millisecond) // имитация задержки
balance -= amount // действие
return true
}
return false
}

Здесь мьютекс защищает от Data Race, но есть Race Condition: между проверкой balance >= amount и списанием balance -= amount может вмешаться другая горутина, которая тоже проверит баланс и решит, что денег хватает. Обе горутины пройдут проверку, но баланс может стать отрицательным.

Соотношение понятий

  • Каждый Data Race — это Race Condition (несинхронизированный доступ всегда приводит к зависимости от порядка выполнения)
  • Не каждый Race Condition — это Data Race (Race Condition может существовать даже при корректной синхронизации доступа к памяти)
┌─────────────────────────────────┐
│ Race Condition │
│ (ошибка логики/проектирования) │
│ │
│ ┌─────────────────────────┐ │
│ │ Data Race │ │
│ │ (несинхронизированный │ │
│ │ доступ к памяти) │ │
│ └─────────────────────────┘ │
└─────────────────────────────────┘

Примеры для различения:

СценарийData Race?Race Condition?
Две горутины пишут в одну переменную без мьютексаДаДа
Проверка-затем-действие под мьютексом, но без атомарности операцииНетДа
Использование каналов для передачи данныхНетНет
Чтение из map без синхронизации из двух горутинДаДа

Как бороться:

  • Data Race: мьютексы, каналы, atomic операции, неизменяемые структуры данных
  • Race Condition: правильное проектирование протоколов взаимодействия, атомарные операции, отсутствие промежутков между проверкой и действием

Вопрос 8. Есть ли Data Race в коде, где одна горутина пишет в поле X структуры, а другая горутина пишет в поле Y той же структуры? Аналогично — есть ли Data Race при записи в разные элементы одного массива из разных горутин?

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

Ответ собеседника: Правильный. Data Race отсутствует в обоих случаях. Структура — это агрегирующий тип данных, поля X и Y находятся в разных участках памяти, поэтому параллельная запись в разные поля одной структуры не является Data Race. Аналогично, массив — это агрегирующий тип данных, и запись в разные элементы массива (разные индексы) — это разные участки памяти, поэтому Data Race также отсутствует. Race detector подтверждает отсутствие проблемы в обоих случаях.

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

Ответ собеседника корректен по сути, но требует важных уточнений о граничных случаях.

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

Формально, Data Race отсутствует, потому что поля X и Y — это разные адреса в памяти. Каждое поле имеет свой собственный адрес, и запись в один адрес не конфликтует с записью в другой.

type Point struct {
X int
Y int
}

var p Point

go func() { p.X = 1 }() // запись по адресу &p + 0
go func() { p.Y = 2 }() // запись по адресу &p + 8 (на 64-битной системе)
// Data Race отсутствует — разные адреса памяти

Запись в разные элементы массива

Аналогично: элементы массива — это разные участки памяти с разными адресами.

var arr [100]int

go func() { arr[0] = 1 }() // адрес: &arr + 0
go func() { arr[1] = 2 }() // адрес: &arr + 8
// Data Race отсутствует — разные адреса

Важные оговорки и тонкости

1. False sharing (ложное разделение)

Хотя Data Race формально отсутствует, на аппаратном уровне может возникнуть проблема false sharing. Процессорные кэши работают с линиями кэша (обычно 64 байта). Если поля X и Y попадают в одну линию кэша, то запись в X одним ядром инвалидирует кэш-линию на другом ядре, вызывая падение производительности:

type Point struct {
X int64 // 8 байт
Y int64 // следующие 8 байт — в той же кэш-линии
}
// X и Y находятся в одной 64-байтной кэш-линии → false sharing

Решение — выравнивание и padding:

type Point struct {
X int64
_ [56]byte // padding, чтобы Y попал в другую кэш-линию
Y int64
}

2. Срез (slice) вместо массива

Если вместо массива используется срез, ситуация сложнее. Срез — это структура {ptr, len, cap}. Два среза могут указывать на один и тот же underlying array:

original := make([]int, 100)
slice1 := original[:50]
slice2 := original[50:]

go func() { slice1[49] = 1 }() // элемент 49 underlying array
go func() { slice2[0] = 2 }() // элемент 50 underlying array — другой адрес, Data Race нет

Но если срезы перекрываются — Data Race возможен:

slice1 := original[:51] // элементы 0-50
slice2 := original[50:] // элементы 50-99

go func() { slice1[50] = 1 }() // элемент 50
go func() { slice2[0] = 2 }() // тоже элемент 50 → Data Race!

3. Модель памяти Go и happens-before

Модель памяти Go гарантирует, что обращения к разным адресам памяти не создают Data Race только при отсутствии других зависимостей. Если горутина пишет в p.X и читает p.Y, а другая горутина пишет в p.Y и читает p.X, формально Data Race нет (разные адреса), но могут быть проблемы с видимостью изменений без явной синхронизации.

4. Race Detector и ложные срабатывания

Go Race Detector основан на ThreadSanitizer и отслеhappens-before отношения. Он не анализирует адреса напрямую, а отслеживает синхронизационные операции. В случае записи в разные поля структуры без синхронизации Race Detector может не сработать, потому что формально Data Race нет, но гарантии видимости между горутинами тоже нет.

Итог:

  • Запись в разные поля одной структуры — Data Race отсутствует (разные адреса)
  • Запись в разные элементы массива — Data Race отсутствует (разные адреса)
  • Однако возможны false sharing (производительность), проблемы видимости и тонкости с перекрывающимися срезами