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

Concurrency задачи с Go собеседований - Подготовка к Golang собеседованию

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

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

Вопрос 1. Дан код синхронизированной реализации стека на Go с использованием мьютекса. Продюсер кладёт 1000 элементов в стек, 100 горутин-потребителей забирают по 100 элементов каждая. В чём проблемы этого кода?

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

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

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

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

Проблема 1: Копирование мьютекса по значению

sync.Mutex — это структура, которая содержит внутреннее состояние (обычно битовые флаги и счётчик). При копировании структуры создаётся независимая копия этого состояния. В результате мьютекс в копии объекта и мьютекс в оригинале никак не связаны — блокировка одной копии не защищает доступ через другую.

// НЕПРАВИЛЬНО: мьютекс копируется при передаче по значению
func (s Stack) Push(val int) {
s.mu.Lock() // блокируется КОПИЯ мьютекса
s.data = append(s.data, val)
s.mu.Unlock()
}

// ПРАВИЛЬНО: используем указатель на получателя
func (s *Stack) Push(val int) {
s.mu.Lock() // блокивается ОРИГИНАЛЬНЫЙ мьютекс
s.data = append(s.data, val)
s.mu.Unlock()
}

Аналогично, если саму структуру Stack передать куда-либо по значению (например, в функцию или при присваивании), мьютекс будет скопирован и синхронизация сломается. Go vet выдаёт предупреждение copylocks при обнаружении такой ситуации.

Проблема 2: Копирование получателя (receiver)

Если все методы стека определены с получателем по значению (func (s Stack) ...), то при каждом вызове метода создаётся полная копия структуры Stack, включая мьютекс и срез. Это не только ломает синхронизацию, но и создаёт огромные накладные расходы на копирование данных.

Проблема 3: Несинхронизированный доступ к длине среза

Чтение len(s.data) без удержания блокировки — это классический data race. Горутина-потребитель проверяет длину, видит что стек непуст, но перед тем как войти в критическую секцию для извлечения элемента, другая горутина может извлечь последний элемент. В результате первая горутина попытается обратиться к несуществующему индексу и получит panic.

// НЕПРАВИЛЬНО: проверка вне блокировки
func (s *Stack) Pop() (int, bool) {
if len(s.data) == 0 { // data race!
return 0, false
}
s.mu.Lock()
defer s.mu.Unlock()
val := s.data[len(s.data)-1]
s.data = s.data[:len(s.data)-1]
return val, true
}

// ПРАВИЛЬНО: всё внутри блокировки
func (s *Stack) Pop() (int, bool) {
s.mu.Lock()
defer s.mu.Unlock()
if len(s.data) == 0 {
return 0, false
}
val := s.data[len(s.data)-1]
s.data = s.data[:len(s.data)-1]
return val, true
}

Проблема 4: Некомбинированный API (check-then-act)

Если стек предоставляет отдельные методы Top() и Pop(), возникает классическая гонка типа check-then-act:

// Горутина A и Горутина B выполняют:
if stack.Top() != nil { // обе видят элемент
stack.Pop() // одна из них получит пустой стек
}

Решение — объединить проверку и извлечение в один атомарный метод, как показано выше в Pop(). Это паттерн «атомарная операция» — проверка состояния и модификация должны выполняться под одной и той же блокировкой.

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

package stack

import "sync"

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

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

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

func (s *Stack) Pop() (int, bool) {
s.mu.Lock()
defer s.mu.Unlock()
if len(s.data) == 0 {
return 0, false
}
val := s.data[len(s.data)-1]
s.data = s.data[:len(s.data)-1]
return val, true
}

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

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

  • Получатели методов с мьютексом всегда должны быть указателями (*Stack)
  • Любая проверка состояния защищаемых данных должна выполняться внутри блокировки
  • Операции check-then-act должны быть атомарными — объединены в один метод под одной блокировкой
  • Используйте go vet -copylocks для автоматического обнаружения копирования мьютексов
  • Используйте go run -race для обнаружения data race во время тестирования

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

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

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

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

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

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

Срез в Go — это структура-заголовок, содержащая указатель на базовый массив, длину и ёмкость. Когда метод Data() возвращает срез, он фактически отдаёт пользователю указатель на ту же область памяти, которую использует внутренний срез структуры. Блокировка мьютекса защищает данные только на момент вызова метода, но после возврата значения мьютекс уже разблокирован и пользователь получает неограниченный несинхронизированный доступ.

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

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

func (b *Buffer) Data() []int {
b.mu.Lock()
defer b.mu.Unlock()
return b.data // ← утечка внутреннего массива!
}

// Использование — data race:
buf := NewBuffer()
buf.Add(1)
buf.Add(2)

slice := buf.Data() // мьютекс разблокирован после этой строки

// Горутина 1: читает slice[0]
// Горутина 2: вызывает buf.Add(3), что может вызвать append
// append может переаллоцировать массив, и slice будет указывать на старую память

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

Решение 1: Возврат глубокой копии

Самый простой и безопасный подход — возвращать копию данных.

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

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

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

Решение 2: Функциональный подход с колбэкком

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

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

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

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

Решение 3: Возврат неизменяемого представления (Go 1.22+ с iter)

func (b *Buffer) All() iter.Seq[int] {
return func(yield func(int) bool) {
b.mu.Lock()
defer b.mu.Unlock()
for _, v := range b.data {
if !yield(v) {
return
}
}
}
}

Решение 4: Использование sync.RWMutex для оптимизации чтений

Если чтений значительно больше, чем записей, можно использовать блокировку на чтение:

type Buffer struct {
mu sync.RWMutex
data []int
}

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

func (b *Buffer) Data() []int {
b.mu.RLock()
defer b.mu.RUnlock()

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

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

  • Никогда не отдавайте ссылку на внутреннее изменяемое состояние за пределы синхронизированной области
  • Срезы, карты, указатели — всё это типы-ссылки, при возврате которых происходит «утечка» внутреннего состояния
  • Выбор между копированием и колбэком зависит от частоты вызовов и объёма данных
  • Используйте -race флаг для обнаружения таких проблем при тестировании

Вопрос 3. Дан код синхронизированного кэша с методами Put, Get и Size. Метод Get вызывает Size внутри захваченной блокировки. В чём проблема и как её решить, сохранив публичный метод Size?

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

Ответ собеседния: Правильный. Проблема в том, что стандартный sync.Mutex в Go не является рекурсивным (reentrant). Если метод Get захватывает мьютекс, а затем вызывает метод Size, который тоже пытается захватить тот же мьютекс, произойдёт deadlock — горутина заблокируется навсегда, ожидая освобождения мьютекса, которым она сама владеет. Решение — использовать паттерн «locked»-методов: создать приватный метод sizeLocked (аналог lock-суффикса в рантайме Go), который вызывается только под уже захваченной блокировкой и не пытается брать мьютекс повторно. Публичный метод Size при этом захватывает мьютекс и вызывает sizeLocked.

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

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

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

В отличие от, например, CRITICAL_SECTION в Windows или ReentrantLock в Java, Go-шный sync.Mutex не отслеживает, какая горутина его захватила. Если горутина пытается заблокировать уже захватеый ею же мьютекс, она просто встаёт в очередь ожидания и блокируется навсегда — deadlock.

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

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

// Где-то в логике нужен размер
size := c.Size() // ← DEADLOCK: Size пытается захватить тот же мьютекс

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

func (c *Cache) Size() int {
c.mu.Lock() // ← горутина ждёт сама себя
defer c.mu.Unlock()
return len(c.items)
}

Решение: паттерн locked-методов

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

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

func New() *Cache {
return &Cache{
items: make(map[string]int),
}
}

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

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

val, ok := c.items[key]
size := c.sizeLocked() // безопасно: мьютекс уже захвачен

// ... какая-то логика с size ...

return val, ok
}

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

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

Аналогия из стандартной библиотеки Go

Этот паттерн широко используется в самом рантайме Go. Например, в sync.Map есть внутренние методы с суффиксом Locked, а в runtime — функции вроде lockOSThread/unlockOSThread, где внутренние вызовы предполагают, что блокировка уже удерживается.

Когда нужен рекурсивный мьютекс

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

type RecursiveMutex struct {
mu sync.Mutex
owner int64 // goroutine ID (через runtime)
depth int // глубина рекурсии
}

func (rm *RecursiveMutex) Lock() {
gid := goroutineID() // нужно получить ID горутины
rm.mu.Lock()

if rm.owner == gid {
rm.depth++
rm.mu.Unlock()
return
}

// Другой владелец — ждём
for rm.owner != 0 {
rm.mu.Unlock()
runtime.Gosched()
rm.mu.Lock()
}

rm.owner = gid
rm.depth = 1
rm.mu.Unlock()
}

func (rm *RecursiveMutex) Unlock() {
rm.mu.Lock()
defer rm.mu.Unlock()

rm.depth--
if rm.depth == 0 {
rm.owner = 0
}
}

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

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

  • sync.Mutex в Go не рекурсивен — повторный Lock из той же горутины вызывает deadlock
  • Паттерн xxxLocked() — стандартное решение: приватный метод без блокировки, публичный — с блокировкой
  • Документируйте контракт: xxxLocked вызывается только под удерживаемым мьютексом
  • Рекурсивные мьютексы — антипаттерн в Go, лучше рефакторить код

Вопрос 4. Дана структура, в которую встроен sync.Mutex (embedding). В чём проблема такого подхода?

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

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

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

Ответ собеседника полностью корректен. Дополню деталями и примерами.

Проблема встраивания (embedding) мьютекса

В Go embedding — это механизм, при котором методы встроенного типа автоматически промотируются (поднимаются) на уровень внешней структуры. Для sync.Mutex это означает, что Lock() и Unlock() становятся публичными методами вашей структуры.

// НЕПРАВИЛЬНО: встраивание мьютекса
type SafeMap struct {
sync.Mutex // embedding — методы Lock/Unlock промотируются
data map[string]int
}

// Пользователь может сделать это:
m := NewSafeMap()
m.Lock() // прямой вызов — обход логики
m.data["key"] = 42 // прямой доступ к данным — race condition!
m.Unlock()

// Или ещё хуже — забыть Unlock:
m.Lock()
m.data["key"] = 42
// забыли Unlock → deadlock для всех последующих вызовов

Почему это опасно

Нарушение инкапсуляции мьютекса создаёт несколько классов ошибок:

  • Пропуск блокировки: пользователь обращается к данным без вызова Lock()
  • Двойная блокировка: пользователь вызывает Lock() дважды → deadlock
  • Забытый Unlock: пользователь не вызывает Unlock() → deadlock для всех
  • Несогласованная блокировка: один метод пользователя блокирует, другой — нет

Правильный подход: неэкспортируемое поле

// ПРАВИЛЬНО: мьютекс как приватное поле
type SafeMap struct {
mu sync.Mutex // неэкспортируемое поле — недоступно снаружи
data map[string]int
}

func New() *SafeMap {
return &SafeMap{
data: make(map[string]int),
}
}

func (m *SafeMap) Set(key string, val int) {
m.mu.Lock()
defer m.mu.Unlock()
m.data[key] = val
}

func (m *SafeMap) Get(key string) (int, bool) {
m.mu.Lock()
defer m.mu.Unlock()
val, ok := m.data[key]
return val, ok
}

func (m *SafeMap) Size() int {
m.mu.Lock()
defer m.mu.Unlock()
return len(m.data)
}

Теперь пользователь не может напрямую вызвать Lock()/Unlock() или обратиться к data — вся синхронизация контролируется методами структуры.

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

Единственный случай, когда embedding может быть оправдан — это внутренние структуры в пакете, которые не экспортируются наружу и используются только внутри пакета. Но даже тогда явное поле предпочтительнее для читаемости.

// Внутри пакета — допустимо, но не рекомендуется
type internalCache struct {
sync.Mutex
items map[string]int
}

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

  • Мьютекс всегда должен быть неэкспортируемым полем (mu sync.Mutex)
  • Никогда не используйте embedding для sync.Mutex, sync.RWMutex или других примитивов синхронизации
  • Синхронизация — деталь реализации, которая должна быть скрыта от пользователя
  • Контролируйте все доступы к защищаемым данным через методы структуры

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

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

Ответ собеседника: Правильный. Проблема в том, что после закрытия канала чтение из него не блокируется — оно мгновенно возвращает zero-value и ok=false. Когда оба канала закрыты, оба case-ветки в select становятся готовы к выполнению, и Go выбирает между ними случайным образом. Это приводит к тому, что после закрытия обоих каналов горутина может многократно попадать в оба case'а, выводя лишние строки. Решение — после закрытия канала присваивать переменной канала nil. Чтение из nil-канала блокируется навсегда, что исключает повторные срабатывания соответствующего case в select.

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

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

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

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

// Проблемный код
func process(ch1, ch2 <-chan int) {
var got1, got2 bool

for !got1 || !got2 {
select {
case v, ok := <-ch1:
if ok {
fmt.Println("ch1:", v)
got1 = true
}
// После закрытия ch1 этот case срабатывает мгновенно
// снова и снова, каждый раз с ok=false

case v, ok := <-ch2:
if ok {
fmt.Println("ch2:", v)
got2 = true
}
// Аналогично для ch2
}
}
}

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

После закрытия обоих каналов оба case в select становятся готовы к выполнению на каждой итерации. Go выбирает между готовными case случайным образом (псевдослучайное равномерное распределение). В результате:

  • На каждой итерации цикла выполняется один из case (случайный выбор)
  • Оба case возвращают ok=false, поэтому флаги got1 и got2 не меняются
  • Но fmt.Println всё равно выполняется, выводя лишние строки с нулевыми значениями
  • Цикл продолжается бесконечно или до случайного совпадения

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

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

// Исправленный код
func process(ch1, ch2 <-chan int) {
var got1, got2 bool

for !got1 || !got2 {
select {
case v, ok := <-ch1:
if ok {
fmt.Println("ch1:", v)
got1 = true
} else {
ch1 = nil // блокирует этот case навсегда
}

case v, ok := <-ch2:
if ok {
fmt.Println("ch2:", v)
got2 = true
} else {
ch2 = nil // блокирует этот case навсегда
}
}
}
}

Альтернативное решение: проверка флагов в условии цикла

Можно также добавить проверку на nil в условие цикла, чтобы избежать лишних итераций:

func process(ch1, ch2 <-chan int) {
for ch1 != nil || ch2 != nil {
select {
case v, ok := <-ch1:
if ok {
fmt.Println("ch1:", v)
} else {
ch1 = nil
}

case v, ok := <-ch2:
if ok {
fmt.Println("ch2:", v)
} else {
ch2 = nil
}
}
}
}

Общий паттерн: fan-in с nil-каналами

Этот подход является стандартным паттерном для объединения нескольких каналов:

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

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

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

return out
}

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

  • Закрытый канал всегда готов к чтению — возвращает zero-value и false
  • nil-канал блокирует чтение навсегда — это свойство используется для «отключения» case в select
  • Присваивание nil переменной канала после закрытия — идиоматический способ управления select
  • Этот паттерн особенно полезен при реализации fan-in, timeout и других композиций каналов

Вопрос 6. Дан код с буферизированным каналом (ёмкость 1), в котором в цикле select с default-веткой сначала происходит запись в канал, затем чтение с присваиванием nil, и в default выводится значение. Что будет выведено и почему?

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

Ответ собеседника: Правильный. Будет выведено: запись значения 1 в канал, затем чтение значения из канала (вывод 2), затем присваивание каналу nil, и на следующей итерации обе операции с каналом блокируются (чтение из nil и запись в nil), поэтому выполняется default-ветка (вывод 3). Ито: 1, 2, 3. Это демонстрирует особенности nil-каналов в select и возможность размещения default в любом месте.

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

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

Поведение nil-каналов в select

В Go операции с nil-каналами ведут себя следующим образом:

  • Чтение из nil-канала: блокируется навсегда
  • Запись в nil-канал: блокируется навсегда
  • Закрытие nil-канала: panic

В контексте select это означает, что case с операцией над nil-каналом никогда не будет выбран — он просто игнорируется.

Разбор сценария по шагам

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

for i := 0; i < 3; i++ {
select {
case ch <- 1: // попытка записи
fmt.Println("write")
case v := <-ch: // попытка чтения
fmt.Println("read:", v)
ch = nil // присваиваем nil после чтения
default:
fmt.Println("default")
}
}
}

Итерация 1 (ch — буферизированный канал, пустой):

  • case ch <- 1: канал не полон, запись возможна → выполняется этот case
  • Вывод: "write"
  • Состояние: ch содержит [1]

Итерация 2 (ch — буферизированный канал, содержит 1 элемент):

  • case ch <- 1: канал полн (ёмкость 1), запись заблокирована
  • case v := <-ch: канал не пуст, чтение возможно → выполняется этот case
  • Вывод: "read: 1", затем ch = nil
  • Состояние: ch равен nil

Итерация 3 (ch равен nil):

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

Итоговый вывод:

write
read: 1
default

Важные нюансы

  • default в select выполняется только когда все остальные case заблокированы
  • Позиция default в select не имеет значения — Go проверяет все case равномерно
  • Присваивание nil переменной канала — мощный приём для динамического управления набором доступных операций в select

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

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

type State int
const (
StateInit State = iota
StateProcessing
StateDone
)

func process(input <-chan int) {
var readCh <-chan int = input
var writeCh chan<- int // nil по умолчанию

for {
select {
case v := <-readCh:
// обработка
result := v * 2

// Активируем запись, деактивируем чтение
writeCh = make(chan int, 1)
writeCh <- result
readCh = nil

case v := <-writeCh:
fmt.Println("result:", v)
writeCh = nil

default:
return
}
}
}

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

  • Nil-каналы в select блокируют соответствующий case навсегда
  • Это свойство используется для динамического управления набором доступных операций
  • default выполняется только когда все case заблокированы
  • Порядок case в select не влияет на вероятность выбора (при нескольких готовых case)

Вопрос 7. Дан код, где одна горутина пишет в переменную-строку, а другая читает её через fmt.Println. Объясните, что такое data race и race condition, есть ли здесь эти проблемы и как их решить.

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

Ответ собеседника: Правильный. Data race — это не синхронизированный доступ к одному и тому же участку памяти из разных горутин, где хотя бы одно обращение является записью. Race condition — это ошибка проектирования, при которой результат работы зависит от порядка выполнения горутин. В данном коде присутствуют обе проблемы: data race (конкурентная запись и чтение строки) и race condition (результат вывода зависит от порядка выполнения). Data race можно обнаружить с помощью флага -race. Решение: использовать sync.WaitGroup для синхронизации — дождаться завершения записи перед чтением, либо использовать мьютекс для защиты доступа.

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

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

Data Race vs Race Condition

Это два разных, но связанных понятия, которые часто путают.

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

// Data race: конкурентная запись и чтение
var shared string

func writer() {
shared = "hello" // запись
}

func reader() {
fmt.Println(shared) // чтение — может увидеть частично записанные данные
}

func main() {
go writer()
go reader()
time.Sleep(time.Second)
}

Data race — это объективно обнаружимое явление. Go race detector (-race) отслежает все обращения к памяти и фиксирует несинхронизированные конкурентные доступы.

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

// Race condition: результат зависит от порядка выполнения
var counter int

func increment() {
counter++ // не атомарная операция: read → modify → write
}

func main() {
go increment()
go increment()
time.Sleep(time.Second)
fmt.Println(counter) // может быть 1 или 2 — непредсказуемо
}

Race condition может существовать даже без data race, если синхронизация есть, но логика программы зависит от порядка событий.

Проблемы в данном коде

var s string

// Горутина 1
go func() {
s = "hello world" // запись строки
}()

// Горутина 2
go func() {
fmt.Println(s) // чтение строки
}()

Здесь присутствуют обе проблемы:

  • Data Race: конкурентная запись и чтение переменной s без синхронизации. Строка в Go — это структура из указателя на данные и длины. Запись строки не атомарна — читающая горутина может увидеть частично обновлённую структуру (старый указатель + новая длина или наоборот), что приведёт к неопределённому поведению.

  • Race Condition: результат вывода зависит от порядка выполнения. Если чтение произойдёт до записи, будет выведена пустая строка. Если после — "hello world". Если во время записи — мусор или panic.

Способы решения

Решение 1: sync.WaitGroup — ожидание завершения записи

var s string
var wg sync.WaitGroup

wg.Add(1)
go func() {
defer wg.Done()
s = "hello world"
}()

wg.Wait() // ждём завершения записи
fmt.Println(s) // гарантированно "hello world"

Решение 2: Канал для передачи значения

ch := make(chan string, 1)

go func() {
ch <- "hello world"
}()

s := <-ch // блокируется до получения значения
fmt.Println(s)

Каналы в Go обеспечивают happens-before гарантию: запись в канал гарантированно видна после чтения из него.

Решение 3: sync.Mutex — если нужен общий доступ

var (
s string
mu sync.Mutex
)

go func() {
mu.Lock()
s = "hello world"
mu.Unlock()
}()

mu.Lock()
fmt.Println(s)
mu.Unlock()

Решение 4: sync.Once — если запись происходит один раз

var (
s string
once sync.Once
)

go func() {
once.Do(func() {
s = "hello world"
})
}()

once.Do(func() {}) // ждём, пока запись завершится
fmt.Println(s)

Обнаружение проблем

# Компиляция с race detector
go run -race main.go

# Тестирование с race detector
go test -race ./...

Race detector добавляет накладные расходы (примерно 5-10x замедление по памяти и 2-20x по времени), поэтому используется только при тестировании и отладке.

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

  • Data race — это несинхронизированный конкурентный доступ к памяти с хотя бы одной записью
  • Race condition — это зависимость корректности от порядка выполнения
  • Каждый data race потенциально ведёт к race condition, но не наоборот
  • Используйте -race при тестировании для обнаружения data race
  • Каналы и sync-примитивы обеспечивают happens-before гарантии

Вопрос 8. Дан код, где две горутина параллельно записывают в разные поля одной и того же объекта структуры (поля X и Y). Есть ли здесь data race? А если бы две горутины писали в разные элементы одного среза — был бы data race?

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

Ответ собеседника: Правильный. Да, запись в разные поля одной структуры из разных горутин без синхронизации является data race. Структура — это единый объект, и запись в любое её поле считается модификацией объекта. Однако если две горутины пишут в разные элементы массива (или среза с разными индексами), то это НЕ является data race, так как массив — это агрегирующий тип данных, и разные элементы массива — это разные участки памяти. Чтение из массива также не вызывает data race при параллельном чтении из разных индексов.

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

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

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

Да, это data race. Структура в Go — это единый непрерывный блок памяти. Запись в любое поле структуры из одной горутины при одновременном чтении или записи любого другого поля из другой горутины без синхронизации является data race.

type Point struct {
X, Y int
}

var p Point

// Горутина 1
go func() {
p.X = 1 // запись в поле X
}()

// Горутина 2
go func() {
p.Y = 2 // запись в поле Y
}()
// DATA RACE: обе горутины обращаются к одному объекту p

Причина в том, что спецификация Go Memory Model определяет data race как обращение к одной и той же переменной (variable), а структура — это одна переменная. Даже если поля находятся в разных областях памяти внутри структуры, с точки зрения модели памяти это одна переменная.

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

Массив в Go — это агрегированный тип, и каждый элемент массива считается отдельной переменной. Поэтому запись в разные элементы массива из разных горутин НЕ является data race.

var arr [10]int

// Горутина 1
go func() {
arr[0] = 1 // запись в элемент 0
}()

// Горутина 2
go func() {
arr[1] = 2 // запись в элемент 1
}()
// НЕТ data race: разные элементы массива — разные переменные

Важное уточнение: срезы (slices)

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

s := make([]int, 10)

// Горутина 1
go func() {
s[0] = 1 // запись в элемент 0 базового массива
}()

// Горутина 2
go func() {
s[1] = 2 // запись в элемент 1 базового массива
}()

Формально, с точки зрения Go Memory Model, элементы среза — это элементы базового массива, и разные индексы — это разные переменные. Поэтому запись в разные индексы среза НЕ является data race.

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

s := make([]int, 1, 10)

// Горутина 1
go func() {
s[0] = 1 // запись в элемент 0
}()

// Горутина 2
go func() {
s = append(s, 2) // может переаллоцировать массив!
}()
// DATA RACE: append может изменить указатель в заголовке среза

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

  • Для структур: всегда синхронизируйте доступ, если разные горутины обращаются к разным полям одной структуры
  • Для массивов: запись в разные индексы безопасна без синхронизации
  • Для срезов: запись в разные индексы безопасна, но только если нет операций, изменяющих заголовок среза (append, copy и т.д.)
  • В сомнительных случаях используйте -race для проверки
// Безопасный паттерн: предвыделенная память без append
type ConcurrentSlice struct {
mu sync.RWMutex
data []int
size int
}

func (cs *ConcurrentSlice) Set(index, val int) {
cs.mu.Lock()
defer cs.mu.Unlock()
cs.data[index] = val
}

func (cs *ConcurrentSlice) Get(index int) int {
cs.mu.RLock()
defer cs.mu.RUnlock()
return cs.data[index]
}

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

  • Структура — одна переменная, запись в любое поле из разных горутин = data race
  • Элементы массива — отдельные переменные, запись в разные индексы = безопасно
  • Срезы сложнее: разные индексы безопасны, но append может сломать это
  • При сомнениях — используйте синхронизацию и -race