Go. Concurrency
Модуль 2
Модуль 2
Основные инструменты. Горутины и синхронизация
Если коротко: горутина — это легковесный поток.

А теперь давайте разберем это «на пальцах», чтобы стало совсем понятно.
Один поток (обычная программа)
Вы один. Вы роете котлован, потом сами же мешаете бетон, потом сами же кладете кирпичи. Если вам нужно ждать, пока привезут кирпичи (долгая задача, как запрос в сеть), вы просто стоите и ничего не делаете. Дом строится очень долго.
Много потоков ОС (традиционный подход)
Вы нанимаете несколько строительных бригад. Это эффективно, но очень дорого. Каждая бригада — это целая организация со своим начальником, инструментами, техникой (каждый поток требует много ресурсов от операционной системы). Нанять 1000 таких бригад — разорение.
Горутины (подход Go)
Вы нанимаете не бригады, а огромную армию быстрых и дешевых помощников. У каждого помощника своя маленькая задача: «принести кирпич», «замесить цемент», «передать молоток». Они очень легкие, их можно нанять сотни тысяч, и они не требуют много ресурсов. У вас есть «супер-менеджер» (Go Runtime), который гениально распределяет эти маленькие задачи между имеющимися бригадами (ядрами процессора).

Горутина — это и есть такой «быстрый и дешевый помощник».
Ключевые характеристики горутин
Легковесность (Lightweight)
Создать горутину в тысячи раз дешевле, чем создать поток операционной системы.

На пальцах: нанять одного помощника (горутину) — это просто дать ему инструкцию. А нанять целую бригаду (поток ОС) — это построить для них бытовку, завезти технику, оформить документы. В начале работы у вас есть одна главная горутина (это вы, менеджер), которая выполняет код из функции main ().
Управляется Go Runtime, а не ОС
Операционная система знает только о нескольких «тяжелых» потоках (бригадах). А внутри этих потоков «супер-менеджер» (планировщик Go) сам решает, какая горутина (помощник) будет выполняться в данный момент.

На пальцах: ОС видит, что на стройке работают 4 бригады (4 ядра). А ваш «супер-менеджер» внутри каждой бригады постоянно командует сотнями помощников, быстро переключая их с задачи на задачу. Это делает переключение невероятно быстрым.
Простота создания
Чтобы запустить новую горутину, нужно всего лишь написать ключевое слово go перед вызовом функции. Всё!
Пример создания горутины
Чтобы понять, как работают горутины, рассмотрим простой пример.
Код без горутины
package main

import "fmt"

// Это простая функция, которую мы будем выполнять в горутине
func say(text string) {
    for i := 0; i < 5; i++ {
        fmt.Println(text)
    }
}

func main() {
    // Обычный вызов функции. main будет ждать, пока say("hello") завершится.
    // Напечатает "hello" 5 раз, и только потом пойдет дальше.
    say("hello")
}
В этом примере программа выполняется последовательно: сначала функция say («hello») напечатает «hello» 5 раз, и только после этого программа завершится.
Код с горутинами
package main

import "fmt"

func say(text string) {
    for i := 0; i < 5; i++ {
        fmt.Println(text)
    }
}

func main() {
    // Запуск функции в новой горутине.
    // main НЕ ЖДЕТ ее завершения. Он просто говорит: "Эй, Go Runtime,
    // запусти, пожалуйста, say("world") в фоновом режиме", и сразу же
    // продолжает выполнять свою работу.
    go say("world")

    // Поскольку main ничего больше не делает, он завершает свою работу.
    // А когда завершается главная горутина (main), вся программа прекращается,
    // даже если другие горутины еще не закончили работу.
    fmt.Println("Программа завершена.")
}
Что мы увидим в консоли? Вывод может быть разным, но скорее всего, будет примерно так:
Программа завершена.
Вы заметили, что «world» так и не напечатался? Это произошло потому, что главная горутина main запустила горутину для say («world»), но не стала ее ждать. Она сразу же напечатала «Программа завершена» и завершила всю программу.

Как это исправить? Нам нужно явно сказать главной горутине: «Постой, подожди, пока все остальные горутины закончат свою работу». Для этого существует специальный инструмент — sync.WaitGroup. О нем мы поговорим в следующем разделе.
Итог: Зачем нужны горутины?
Масштабируемость: Вы можете запустить 10, 100, 100 000 горутин для обработки тысяч одновременных запросов (например, в веб-сервере), и это не убьет вашу систему.

Простота: Код для конкурентного выполнения получается очень чистым и читаемым (go myFunction ()).

Эффективность: Go Runtime умно распределяет горутины по ядрам процессора, обеспечивая максимальную производительность.
Синхронизация: sync.WaitGroup
В предыдущем примере мы столкнулись с проблемой: главная горутина main запустила другую горутину, но не стала ее ждать. В итоге main завершилась, убив всю программу, и наша фоновая задача так и не выполнилась. sync. WaitGroup — это именно тот инструмент, который решает эту проблему.
Аналогия: Список задач менеджера
Вернемся к нашей аналогии с «армией помощников». Вы — менеджер. Вы даете своим помощникам (горутинам) задачи. Но как вы узнаете, когда все они закончили, и можно закрывать проект (завершать программу)?

sync.WaitGroup — это ваш магический список задач или чек-лист.

Add (N) — Добавить задачи в список. Прежде чем раздавать задачи, вы говорите: «Всего у нас сегодня N задач». Вы делаете пометку в своем списке.

Done () — Задача выполнена. Когда помощник (горутина) заканчивает свою работу, он подходит к вам и говорит: «Моя задача сделана!». Вы вычеркиваете один пункт из списка.

Wait () — Ждать, пока список не будет пуст. Вы садитесь и ждете. Вы не пойдете домой, пока в вашем списке не останется ни одной невыполненной задачи.
Пример с sync.WaitGroup
Давайте вернемся к коду, который не работал, и исправим его с помощью sync.WaitGroup.
package main

import (
    "fmt"
    "sync" // Импортируем пакет sync
    "time"
)

func say(text string, wg *sync.WaitGroup) {
    // В конце функции сообщаем, что работа горутины завершена.
    // Используем defer, чтобы гарантировать, что Done() вызовется в любом случае,
    // даже если внутри функции произойдет ошибка.
    defer wg.Done()

    for i := 0; i < 5; i++ {
        fmt.Println(text)
        time.Sleep(100 * time.Millisecond) // Добавим небольшую задержку
    }
}

func main() {
    // Создаем наш "список задач"
    var wg sync.WaitGroup

    // Добавляем в список 2 задачи, которые мы собираемся запустить
    wg.Add(2)

    // Запускаем первую горутину
    go say("hello", &wg)

    // Запускаем вторую горутину
    go say("world", &wg)

    // Ждем, пока обе горутины не сообщат о своем завершении.
    // Программа "зависнет" на этой строке, пока счетчик wg не станет равен 0.
    fmt.Println("Менеджер ждет, пока все задачи будут выполнены...")
    wg.Wait()

    // Эта строка выполнится только после того, как обе горутины завершатся
    fmt.Println("Все задачи выполнены. Программа завершена.")
}
Что мы увидим в консоли теперь? Вывод будет примерно таким (порядок «hello» и «world» может быть разным, но финальные сообщения всегда будут в конце):
Менеджер ждет, пока все задачи будут выполнены...
world
hello
world
world
hello
world
world
hello
hello
hello
Все задачи выполнены. Программа завершена.
Ключевые моменты и лучшие практики
Add () до go: Всегда вызывайте wg. Add () перед запуском горутины (go …). Если сделать наоборот, main может успеть дойти до wg. Wait () раньше, чем горутина вызовет Add (), и программа «зависнет» навсегда.

defer wg. Done (): Это лучший способ вызвать Done (). Он гарантирует, что счетчик будет уменьшен, даже если функция завершится из-за return или паники. Это защищает от ошибок, когда вы можете забыть вызвать Done () в какой-то из веток кода.

Итог: sync. WaitGroup — это простой, но мощный примитив для синхронизации. Его основная задача — дождаться завершения группы горутин. Это самый частый случай, с которым вы столкнетесь при работе с конкурентностью в Go.
Состояние гонки и защита памяти
Что такое data race (гонка данных) в Go?
Гонка данных — это ситуация, когда две или более горутины получают доступ к одному и тому же участку памяти одновременно, и при этом хотя бы одна из них пытается записать (изменить) данные.
Проблема в том, что результат такой операции становится непредсказуемым. Вы не знаете, какая горутина «запишет» свои данные последней, и могут ли другие горутины увидеть частично записанное, «сломанное» состояние.
Аналогия: Совместное редактирование документа
Представьте, что два человека одновременно редактируют один и тот же документ в Google Docs без синхронизации.

Человек, А открывает документ, видит фразу «Привет, мир». Человек Б в тот же миг открывает документ, тоже видит «Привет, мир». Человек, А исправляет «мир» на «Go» и сохраняет. Документ теперь «Привет, Go». Человек Б, не видя правки А, исправляет «Привет» на «Hello» и сохраняет.

Итоговый документ может быть «Hello, мир» или «Hello, Go», но он точно не будет «Hello, Go», как можно было бы ожидать. Правка от человека, А была потеряна. Это и есть гонка данных.
Три условия для гонки данных
Две или более горутины. Доступ к одной и той же переменной в памяти. Как минимум один доступ — это запись. Нет синхронизации (ни каналов, ни мьютексов), которая бы упорядочила этот доступ.
Пример кода с гонкой
Чтобы увидеть гонку данных в действии, рассмотрим пример с счетчиком.
// racy_counter.go
package main

import (
    "fmt"
    "sync"
)

func main() {
    var counter int
    var wg sync.WaitGroup

    // Запускаем 100 горутин, каждая увеличивает счетчик 100 раз
    for i := 0; i < 100; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            for j := 0; j < 100; j++ {
                counter++ // <-- Гонка данных происходит здесь!
            }
        }()
    }

    wg.Wait()
    fmt.Println("Итоговое значение счетчика:", counter)
    fmt.Println("Ожидаемое значение: 10000")
}
Этот пример демонстрирует гонку данных: несколько горутин одновременно увеличивают одну и ту же переменную counter. В результате итоговое значение будет меньше ожидаемого 10 000.
Когда использовать Мьютексы (sync.Mutex, sync. RWMutex)
Мьютексы нужны, когда вам нужно защитить состояние (state). Когда у вас есть сложная структура данных, к которой нужно обращаться из многих разных мест в коде.
Типичные сценарии
Кэши (Maps): У вас есть глобальная мапа для кэширования результатов. Многие горутины читают из нее, а одна изредка обновляет.

Глобальная конфигурация: Объект с настройками, который часто читается, но редко меняется (например, по сигналу ОС).

Счетчики и флаги: Простой пример, который мы уже рассмотрели.

Сложные структуры данных: Когда у вас есть, например, связанный список, который несколько горутин модифицируют. Передавать весь список по каналу для каждого маленького изменения было бы очень неэффективно.
sync.Mutex vs sync.RWMutex
sync.Mutex (взаимное исключение): Это «тупой» замок. Только одна горутина может держать ключ (заблокирована) — неважно, читает она данные или пишет. Все остальные стоят в очереди.

sync.RWMutex (чтение-запись): Это более умный замок для сценариев «много читателей, мало писателей».

Блокировка на чтение (RLock ()): Любое количество горутин может одновременно читать данные. Они не мешают друг другу.

Блокировка на запись (Lock ()): Только одна горутина может писать. При этом все остальные (и читатели, и писатели) блокируются.
Аналогия: Библиотека
Mutex: Только один человек может быть в читальном зале за раз — неважно, читает он книгу или пишет свою.

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

import (
    "fmt"
    "sync"
    "time"
)

// SafeCache - это потокобезопасный кэш
type SafeCache struct {
    mu    sync.RWMutex
    items map[string]string
}

func NewSafeCache() *SafeCache {
    return &SafeCache{
        items: make(map[string]string),
    }
}

// Get - операция чтения
func (c *SafeCache) Get(key string) (string, bool) {
    // Блокируем на чтение. Другие горутины тоже могут вызывать Get() одновременно.
    c.mu.RLock()
    defer c.mu.RUnlock()

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

// Set - операция записи
func (c *SafeCache) Set(key, value string) {
    // Блокируем на запись. Ни одна другая горутина не может ни читать, ни писать.
    c.mu.Lock()
    defer c.mu.Unlock()

    c.items[key] = value
}

func main() {
    cache := NewSafeCache()
    var wg sync.WaitGroup

    // Запускаем 10 "читателей"
    for i := 0; i < 10; i++ {
        wg.Add(1)
        go func(id int) {
            defer wg.Done()
            if val, ok := cache.Get("key"); ok {
                fmt.Printf("Читатель #%d: %s\n", id, val)
            } else {
                fmt.Printf("Читатель #%d: ключ не найден\n", id)
            }
        }(i)
    }

    // Запускаем одного "писателя"
    wg.Add(1)
    go func() {
        defer wg.Done()
        fmt.Println("Писатель: устанавливаю значение...")
        time.Sleep(100 * time.Millisecond) // Чтобы читатели успели запуститься
        cache.Set("key", "hello from writer")
        fmt.Println("Писатель: значение установлено.")
    }()

    wg.Wait()
}
Этот пример демонстрирует, как RWMutex позволяет нескольким читателям одновременно обращаться к данным, но блокирует их при записи.
Что такое atomic (атомарные операции)?
Слово «атомарный» означает «неделимый». В программировании атомарная операция — это такая операция, которая гарантированно выполняется как единое, целое действие. Другие горутины не могут «увидеть» ее наполовину выполненной.

Вспомним нашу проблему с counter++. Она была «делимой» на три шага:
  1. Прочитать значение.
  2. Увеличить его.
  3. Записать обратно.
Гонка данных происходила в «щели» между этими шагами.

Атомарная операция atomic. AddInt64 делает все эти три шага за один прием на уровне процессора. Процессор гарантирует, что ни одна другая операция не вклинится посередине.

Аналогия: Представьте, что вы передаете ценные документы через узкое окно.

  • Неатомарно (counter++): Вы просовываете документ, потом отдаете ручку, потом просовываете другой документ. Кто-то может захлопнуть окно между этими действиями.
  • Атомарно (atomic.Add): Вы просовываете папку со всеми документами и ручкой одним целым пакетом. Никто не может вмешаться в этот процесс.
Для чего они нужны и какие проблемы решают?
atomic решает ту же проблему, что и sync. Mutex — гонки данных. Но делает это другим способом.

Основная причина использования atomic — производительность.
  • sync.Mutex — это высокоуровневый инструмент. Он может блокировать целую горутину, заставляя ее «спать», пока мьютекс не освободится. Это может привести к переключению контекста на уровень операционной системы, что относительно дорого.
  • sync/atomic — это низкоуровневый инструмент, который работает напрямую с инструкциями процессора. Он не блокирует горутины в традиционном смысле. Если операция не может быть выполнена мгновенно (например, в CompareAndSwap), горутина будет «спинниться» — активно зацикливаться в ожидании. Это очень быстро, если ожидание короткое, но очень затратно, если долгое.

Ключевые сценарии применения atomic:

  1. Простые счетчики: Самый частый и очевидный случай.
  2. Флаги состояния: Когда нужно атомарно поменять состояние (например, с false на true).
  3. Публикация указателей: Безопасно опубликовать указатель на сложную структуру, чтобы другие горутины его увидели. Это основа паттерна «инициализация один раз» (sync.Once использует атомарные операции внутри).
  4. Операции «сравни и поменяй» (Compare-And-Swap, CAS): Очень мощный примитив, который позволяет атомарно проверить значение и, если оно ожидаемое, поменять его.
Плюсы и Минусы

Плюсы

Минусы

Высокая производительность: Самый быстрый способ синхронизации для простых операций.

Сложность: Легко допустить ошибку. Код с atomic сложнее читать и отлаживать, чем с mutex.

Не блокирует горутины: Не приводит к переключению контекста на уровень ОС.

Ограниченный набор типов: Работает только с базовыми типами (int32, int64, uintptr, pointer).

Низкоуровневый контроль: Дает прямой доступ к инструкциям процессора.

Опасность: Неправильное использование может привести к очень сложным и редким багам.

Золотое правило: Всегда начинайте с sync.Mutex. Переходите на sync/atomic только тогда, когда у вас есть доказанный узкий участок в производительности (например, после профилирования с помощью pprof), который связан с простой операцией над примитивным типом.
Пример работы в коде
Давайте решим нашу вечную проблему со счетчиком тремя способами и сравним.
Проблема: Код с гонкой данных
// НЕПРАВИЛЬНО
func raceCounter() {
    var counter int
    var wg sync.WaitGroup
    for i := 0; i < 1000; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            counter++
        }()
    }
    wg.Wait()
    fmt.Println("Race Counter:", counter) // Непредсказуемый результат
}
Решение через sync.Mutex
// ПРАВИЛЬНО, но медленнее
func mutexCounter() {
    var counter int
    var wg sync.WaitGroup
    var mu sync.Mutex

    for i := 0; i < 1000; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            mu.Lock()
            counter++
            mu.Unlock()
        }()
    }
    wg.Wait()
    fmt.Println("Mutex Counter:", counter) // Всегда 1000
}
Решение через sync/atomic
// ПРАВИЛЬНО и БЫСТРО
func atomicCounter() {
    // Важно: используем int64, так как atomic.AddInt64 требует int64
    var counter int64
    var wg sync.WaitGroup

    for i := 0; i < 1000; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            // Атомарно увеличиваем счетчик на 1
            atomic.AddInt64(&counter, 1)
        }()
    }
    wg.Wait()

    // ВАЖНО: для чтения тоже нужно использовать atomic!
    // Простое чтение fmt.Println(counter) может увидеть устаревшее значение из кэша CPU.
    finalValue := atomic.LoadInt64(&counter)
    fmt.Println("Atomic Counter:", finalValue) // Всегда 1000
}
Итог: Когда что выбирать?

Ситуация

Рекомендуемый инструмент

Почему?

Защита сложной структуры данных (мапа, слайс)

sync.Mutex

atomic не может работать со сложными типами.

Простая операция, и производительность критична

sync/atomic

atomic будет значительно быстрее, так как нет накладных расходов на блокировку.

Вы не уверены, что выбрать

sync.Mutex

Это безопаснее, проще и правильнее в 99% случаев. Производительность Mutex чаще всего достаточна.

Задача: «Счётчик с гонкой и без»
  1. Напиши программу, которая:
  • Имеет глобальный счётчик counter.
  • Запускает 100 горутин, каждая увеличивает counter 1000 раз.
  • В конце выводит значение counter.

2. Запусти с go test -race или go run -race (если вынесешь в main) и зафиксируй наличие гонок.

3. Затем сделай две версии исправления:
  • С sync.Mutex.
  • Через sync/atomic (atomic.AddInt64), если знаком.
Требования:
  • Для каждой версии объясни, как именно устраняется гонка.
  • Сравни скорость (хотя бы на уровне «на глаз по времени»).