Как горутины работают с общей памятью и какие существуют подходы к синхронизации доступа?

Ответ

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

Философия Go гласит: "Не обменивайтесь данными через общую память; обменивайтесь памятью через каналы". Однако на практике используются оба подхода.

Подходы к синхронизации:

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

2. Примитивы синхронизации (пакет sync)
Традиционный подход, используемый в других языках.

  • sync.Mutex: Мьютекс для эксклюзивной блокировки. Только одна горутина может владеть блокировкой в любой момент времени. Используется для защиты критических секций кода.
  • sync.RWMutex: Блокировка чтения-записи. Позволяет неограниченному числу горутин читать данные одновременно, но запись требует эксклюзивной блокировки.
  • sync.WaitGroup: Позволяет дождаться завершения группы горутин.
  • sync.Once: Гарантирует, что определенный участок кода выполнится только один раз.
  • Атомарные операции (sync/atomic): Для простых операций (инкремент, сложение, сохранение/загрузка указателя) атомарные функции могут быть более производительными, чем мьютексы.

Проблема: Гонка данных (Data Race)

Гонка данных возникает, когда две или более горутины одновременно обращаются к одной и той же области памяти, и хотя бы одна из них выполняет запись. Это приводит к непредсказуемому поведению.

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

var counter int

func main() {
    var wg sync.WaitGroup
    for i := 0; i < 1000; i++ {
        wg.Add(1)
        go func() {
            counter++ // ОПАСНО: одновременная запись из разных горутин
            wg.Done()
        }()
    }
    wg.Wait()
    fmt.Println("Итоговый счетчик:", counter) // Результат непредсказуем, скорее всего < 1000
}

Решение с помощью sync.Mutex:

var (
    counter int
    mu      sync.Mutex
)

func main() {
    var wg sync.WaitGroup
    for i := 0; i < 1000; i++ {
        wg.Add(1)
        go func() {
            mu.Lock() // Захватываем блокировку
            counter++
            mu.Unlock() // Освобождаем блокировку
            wg.Done()
        }()
    }
    wg.Wait()
    fmt.Println("Итоговый счетчик:", counter) // Всегда 1000
}

Для обнаружения гонок данных в Go есть встроенный инструмент — Race Detector. Его можно активировать флагом -race:
go run -race main.go