🐹 Go

Multithreading e assincronia em Go (Golang): um guia detalhado

Что это такое и зачем? (аналогия из реальной жизни)

Представьте, что вы — шеф-повар на кухне ресторана. У вас есть один помощник. Вам нужно приготовить три блюда: суп, салат и десерт.

Последовательная работа (без многопоточности): Вы говорите помощнику: «Сначала нарежь овощи для супа. Когда закончишь — нарежь овощи для салата. Потом — укрась десерт». Помощник делает всё по очереди. Если он режет суп, салат и десерт ждут. Это медленно, но просто.

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

Асинхронность: У вас один помощник, но он очень умный. Вы даёте ему задание: «Поставь суп вариться. Пока он варится — нарежь салат. Когда суп сварится — выключи плиту». Помощник не ждёт, пока суп сварится, он делает другие дела. Он как бы «переключается» между задачами, не простаивая.

В программировании всё то же самое. Многопоточность — это когда несколько задач выполняются реально одновременно (на разных ядрах процессора). Асинхронность — это когда одна задача ждёт (например, ответа от сервера), а программа в это время делает другую работу.

💡 Важно: В Go (Golang) многопоточность и асинхронность тесно связаны. Go использует горутины (goroutines) — лёгкие потоки, которые работают как асинхронные задачи, но могут выполняться параллельно на нескольких ядрах.

Теория: как это работает изнутри

Процессы, потоки и горутины

Чтобы понять Go, нужно разобраться с тремя уровнями:

Понятие Что это? Пример из жизни
Процесс Запущенная программа. Имеет свою память, ресурсы. Целая кухня ресторана.
Поток (thread) Лёгкий процесс внутри программы. Потоки делят память процесса. Повар на кухне.
Горутина (goroutine) Лёгкий поток в Go. Их может быть миллионы в одной программе. Робот-помощник, который может делать много дел почти без затрат.

Как работают горутины?

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

Представьте, что у вас есть 10 потоков ОС и 1000 горутин. Планировщик Go сам решает, какая горутина сейчас будет работать на каком потоке. Если горутина засыпает (ждёт), её место занимает другая.

Каналы (channels) — главный инструмент синхронизации

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

  • Небуферизированным: отправитель ждёт, пока получатель не заберёт данные. Получатель ждёт, пока отправитель не пошлёт.
  • Буферизированным: есть место для хранения данных. Отправитель может отправить несколько значений, не дожидаясь получателя.
🔑 Девиз Go: «Не общайтесь через разделяемую память, разделяйте память через общение». Это значит: вместо того чтобы блокировать доступ к переменной (как в других языках), используйте каналы для передачи данных между горутинами.

Синтаксис в Go (Golang): все формы и варианты

1. Запуск горутины

Достаточно написать go перед вызовом функции:

go myFunction()

Функция выполнится в отдельной горутине. Программа не ждёт её завершения.

2. Анонимные функции в горутинах

go func() {
    fmt.Println("Привет из горутины!")
}()

3. Создание канала

// Небуферизированный канал для целых чисел
ch := make(chan int)

// Буферизированный канал на 3 элемента
bufferedCh := make(chan string, 3)

4. Отправка и получение из канала

ch <- 42      // Отправить число 42 в канал
value := <-ch // Получить значение из канала

5. Закрытие канала

close(ch)

После закрытия из канала можно читать (вернутся нулевые значения), но нельзя писать.

6. Select — работа с несколькими каналами

select {
case msg1 := <-ch1:
    fmt.Println("Получено из ch1:", msg1)
case msg2 := <-ch2:
    fmt.Println("Получено из ch2:", msg2)
case <-time.After(1 * time.Second):
    fmt.Println("Таймаут!")
default:
    fmt.Println("Ничего не готово")
}

7. WaitGroup — ожидание группы горутин

var wg sync.WaitGroup
wg.Add(3) // Говорим, что будем ждать 3 горутины

go func() {
    defer wg.Done() // Уменьшаем счётчик на 1
    // работа...
}()

wg.Wait() // Ждём, пока счётчик не станет 0

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

var mu sync.Mutex
mu.Lock()
// критическая секция (только одна горутина за раз)
mu.Unlock()

Примеры кода — от простого к сложному

Пример 1: Первая горутина

package main

import (
    "fmt"
    "time"
)

func main() {
    // Запускаем горутину
    go fmt.Println("Привет из горутины!")

    // Даём время горутине выполниться
    time.Sleep(100 * time.Millisecond)
    fmt.Println("Привет из main!")
}

Объяснение: go fmt.Println(...) запускает печать в отдельной горутине. Без time.Sleep программа завершилась бы сразу, не дождавшись вывода из горутины.

Пример 2: Передача данных через канал

package main

import "fmt"

func main() {
    // Создаём канал для строк
    ch := make(chan string)

    // Запускаем горутину, которая отправляет сообщение
    go func() {
        ch <- "Привет из горутины!"
    }()

    // Получаем сообщение (блокируемся, пока не получим)
    message := <-ch
    fmt.Println(message)
}

Объяснение: Канал ch небуферизированный. Горутина отправляет строку и ждёт, пока main её не получит. main ждёт, пока горутина не отправит. Это синхронизация.

Пример 3: Буферизированный канал

package main

import "fmt"

func main() {
    // Канал с буфером на 2 элемента
    ch := make(chan int, 2)

    // Можно отправить 2 значения без получателя
    ch <- 1
    ch <- 2

    // Теперь читаем
    fmt.Println(<-ch) // 1
    fmt.Println(<-ch) // 2
}

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

Пример 4: Параллельный поиск (Fan-Out)

package main

import (
    "fmt"
    "time"
)

func search(term string, result chan<- string) {
    // Имитация долгого поиска
    time.Sleep(time.Duration(len(term)) * 100 * time.Millisecond)
    result <- "Результат для: " + term
}

func main() {
    results := make(chan string, 3)

    // Запускаем 3 поиска параллельно
    go search("Go", results)
    go search("Python", results)
    go search("Rust", results)

    // Собираем результаты
    for i := 0; i < 3; i++ {
        fmt.Println(<-results)
    }
}

Объяснение: Мы запускаем три горутины одновременно. Каждая ищет свой термин. Результаты приходят по мере готовности. Это Fan-Out (веерный выход) — одна задача порождает много подзадач.

Пример 5: Select с таймаутом

package main

import (
    "fmt"
    "time"
)

func main() {
    ch := make(chan string)

    go func() {
        time.Sleep(2 * time.Second)
        ch <- "Данные готовы"
    }()

    select {
    case msg := <-ch:
        fmt.Println(msg)
    case <-time.After(1 * time.Second):
        fmt.Println("Таймаут! Данные не получены за 1 секунду")
    }
}

Объяснение: select ждёт, пока один из каналов не будет готов. time.After создаёт канал, который отправляет значение через 1 секунду. Если данные не пришли за секунду — срабатывает таймаут.

Пример 6: Паттерн Worker Pool (пул воркеров)

package main

import (
    "fmt"
    "sync"
)

func worker(id int, jobs <-chan int, results chan<- int, wg *sync.WaitGroup) {
    defer wg.Done()
    for job := range jobs {
        // Обрабатываем задачу
        result := job * 2
        fmt.Printf("Воркер %d обработал задачу %d -> %d
", id, job, result)
        results <- result
    }
}

func main() {
    const numJobs = 10
    const numWorkers = 3

    jobs := make(chan int, numJobs)
    results := make(chan int, numJobs)
    var wg sync.WaitGroup

    // Запускаем воркеров
    for w := 1; w <= numWorkers; w++ {
        wg.Add(1)
        go worker(w, jobs, results, &wg)
    }

    // Отправляем задачи
    for j := 1; j <= numJobs; j++ {
        jobs <- j
    }
    close(jobs) // Закрываем канал задач — воркеры завершат цикл

    // Ждём завершения всех воркеров
    wg.Wait()
    close(results) // Закрываем канал результатов

    // Собираем результаты
    for result := range results {
        fmt.Println("Результат:", result)
    }
}

Объяснение: Это классический паттерн. У нас есть 3 воркера (горутины), которые берут задачи из общего канала jobs. Каждый воркер обрабатывает задачу и отправляет результат в results. WaitGroup ждёт, пока все воркеры закончат. Это эффективно для обработки большого количества независимых задач.

Пример 7: Канал для сигнала завершения (done channel)

package main

import (
    "fmt"
    "time"
)

func main() {
    done := make(chan bool)

    go func() {
        fmt.Println("Горутина работает...")
        time.Sleep(2 * time.Second)
        fmt.Println("Горутина завершилась")
        done <- true // Отправляем сигнал
    }()

    // Ждём сигнала
    <-done
    fmt.Println("Программа завершена")
}

Объяснение: Канал done используется как сигнал завершения. main блокируется на <-done, пока горутина не отправит true. Это простой способ синхронизации.

Сравнение с Python: чем похоже и чем отличается

Характеристика Go Python
Основная единица Горутина (goroutine) Поток (thread) или корутина (async/await)
Синтаксис запуска go func() threading.Thread(target=func).start() или asyncio.create_task()
Обмен данными Каналы (chan) — основной способ Очереди (queue.Queue) или разделяемые переменные с блокировками
Блокировки Mutex, RWMutex (редко, предпочитают каналы) Lock, RLock, Semaphore (используются часто)
Параллелизм Настоящий (горутины распределяются по потокам ОС) GIL (Global Interpreter Lock) — только один поток выполняется за раз. Асинхронность — кооперативная
Производительность Высокая (компилируемый язык, лёгкие горутины) Ниже (интерпретируемый, GIL, тяжёлые потоки)
Сложность Средняя (простой синтаксис, но нужно понимать каналы) Высокая (много разных подходов: threading, multiprocessing, asyncio)
💡 Главное отличие: В Python вы выбираете между потоками (threading) и асинхронностью (asyncio). В Go горутины объединяют оба подхода: они лёгкие как корутины, но могут выполняться параллельно как потоки.

Типичные ошибки и подводные камни

1. Горутина не успевает выполниться

go fmt.Println("Привет") // Программа завершится, не напечатав это

Решение: Используйте WaitGroup или каналы для синхронизации.

2. Дедлок (deadlock) с небуферизированным каналом

ch := make(chan int)
ch <- 1 // Блокировка навсегда! Нет получателя
fmt.Println(<-ch)

Решение: Всегда обеспечивайте, чтобы отправитель и получатель были в разных горутинах, или используйте буферизированный канал.

3. Запись в закрытый канал

ch := make(chan int)
close(ch)
ch <- 1 // panic: send on closed channel

Решение: Закрывайте канал только в горутине-отправителе. Не закрывайте канал, если есть другие отправители.

4. Гонка данных (data race)

var counter int
for i := 0; i < 1000; i++ {
    go func() {
        counter++ // Небезопасно! Гонка данных
    }()
}

Решение: Используйте sync.Mutex или атомарные операции (sync/atomic). Или, лучше, используйте каналы.

5. Утечка горутин

go func() {
    for {
        // Бесконечный цикл без выхода
    }
}()

Решение: Всегда предусматривайте способ остановки горутины (канал done, контекст).

6. Паника в горутине

go func() {
    panic("Ошибка!") // Паника убьёт всю программу
}()

Решение: Используйте recover внутри горутины для обработки паники.

Лучшие практики и советы профессионалов

  1. Не используйте горутины без необходимости. Если задача выполняется за микросекунды, горутина только замедлит.
  2. Предпочитайте каналы блокировкам. Каналы делают код понятнее и безопаснее.
  3. Используйте контекст (context) для отмены. context.WithCancel и context.WithTimeout — стандартный способ остановить горутины.
  4. Ограничивайте количество горутин. Используйте паттерн Worker Pool, чтобы не создавать миллионы горутин.
  5. Всегда закрывайте каналы. Иначе возможна утечка памяти. Закрывайте в горутине-отправителе.
  6. Используйте go vet и -race. go vet находит подозрительные конструкции, go run -race обнаруживает гонки данных.
  7. Тестируйте конкурентный код. Используйте testing с флагом -race.
🎯 Золотое правило: «Не общайтесь через разделяемую память, разделяйте память через общение». Если вы следуете этому правилу, 90% проблем с многопоточностью исчезнут.

Вопросы для самопроверки

  1. Что такое горутина и чем она отличается от потока ОС?
    Ответ: Горутина — лёгкий поток, управляемый планировщиком Go. Она занимает меньше памяти (несколько КБ против 1-8 МБ у потока ОС) и быстрее создаётся/переключается.
  2. Какой канал вызовет дедлок: make(chan int) или make(chan int, 1)?
    Ответ: make(chan int) (небуферизированный) вызовет дедлок, если отправить и получить в одной горутине. Буферизированный на 1 элемент — нет.
  3. Что произойдёт при чтении из закрытого канала?
    Ответ: Вернётся нулевое значение для типа канала. Второе возвращаемое значение (ok) будет false.
  4. Как дождаться завершения 10 горутин?
    Ответ: Использовать sync.WaitGroup: wg.Add(10), в каждой горутине defer wg.Done(), в main wg.Wait().
  5. В чём разница между select и switch?
    Ответ: select работает только с каналами. Он ждёт, пока один из каналов будет готов к чтению/записи. switch — это условный оператор.
  6. Что такое гонка данных (data race)?
    Ответ: Ситуация, когда две горутины одновременно обращаются к одной переменной, и хотя бы одна из них пишет. Результат непредсказуем.
  7. Как обнаружить гонку данных?
    Ответ: Запустить программу с флагом -race: go run -race main.go.
  8. Что делает close(ch)?
    Ответ: Закрывает канал. После закрытия в него нельзя писать, но можно читать (вернутся нулевые значения). Используется для сигнала о завершении.
  9. Какой паттерн используется для ограничения количества горутин?
    Ответ: Worker Pool (пул воркеров). Создаётся фиксированное количество горутин, которые читают задачи из общего канала.
  10. Что такое контекст (context) в Go?
    Ответ: Пакет context позволяет передавать сигналы отмены, таймауты и значения между горутинами. Стандартный способ управления жизненным циклом.

Итог и что изучить дальше

Мы разобрали основы многопоточности и асинхронности в Go. Вы узнали:

  • Что такое горутины и как они работают
  • Как использовать каналы для общения между горутинами
  • Как синхронизировать горутины с помощью WaitGroup и Mutex
  • Как избегать типичных ошибок

Что изучать дальше:

  1. Пакет context — для отмены горутин и передачи таймаутов.
  2. Паттерны конкурентности — Fan-In, Fan-Out, Pipeline, Pub/Sub.
  3. sync/atomic — атомарные операции для счётчиков.
  4. Реальные проекты — напишите веб-сервер, который обрабатывает запросы в горутинах.
  5. Книга «Concurrency in Go» Кэтрин Кокс-Бадей — глубокая теория.
🚀 Главный вывод: Go создан для многопоточности. Его горутины и каналы — элегантный и мощный инструмент. Не бойтесь экспериментировать, но всегда помните о безопасности и синхронизации.