Горутины в Go: Чем отличаются от потоков?

Одна из первых вещей, которая подкупает в Go — это простота запуска конкурентного кода. Не нужно знать сложные библиотеки, не нужно думать о пулах потоков. Достаточно написать go перед вызовом функции — и она уже работает параллельно.

Но действительно ли всё так просто? Давайте разбираться.

Что такое горутина?

Горутина (goroutine) — это легковесный поток, который управляется рантаймом Go, а не операционной системой.

Простыми словами: это способ выполнять несколько функций одновременно в рамках одной программы, не создавая при этом тяжёлые OS-потоки.

Как создать горутину?

Все достаточно просто — нужно перед вызовом функции добавить ключево слово go.

package main

func main() {
    go fmt.Println("Это выполнится параллельно")
    fmt.Println("Это выполнится в main")
}

Важно: горутина запускается и сразу продолжает выполняться. Основная функция main не ждёт ее завершения.

Параллельная обработка заказов

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

Попытка №1. Запускаем горутины, но что-то идёт не так

package main

import (
    "fmt"
    "time"
)

func processOrder(orderID int) {
    fmt.Printf("📦 Начинаю обработку заказа #%d\n", orderID)
    time.Sleep(2 * time.Second) // эмуляция работы
    fmt.Printf("✅ Заказ #%d готов!\n", orderID)
}

func main() {
    for i := 1; i <= 5; i++ {
        go processOrder(i)
    }
    fmt.Println("🏪 Программа завершена")
}

Результат выполнения:

📦 Начинаю обработку заказа #5
📦 Начинаю обработку заказа #2
📦 Начинаю обработку заказа #4
📦 Начинаю обработку заказа #1
🏪 Программа завершена
📦 Начинаю обработку заказа #3

Что произошло?

Горутины запустились, начали обработку заказов, но… программа завершилась, не дождавшись их! Ни один заказ не был доведён до конца. А один из них даже запустился после завершения программы.

Функция main не ждет завершения горутин. Как только выполнился цикл и строчка fmt.Println("🏪 Программа завершена") — программа тут же завершилась вместе со всеми незавершёнными горутинами.

Попытка №2. Добавляем time.Sleep — костыль

Можно попробовать подождать примерное время выполнения:

package main

func main() {
    for i := 1; i <= 5; i++ {
        go processOrder(i)
    }
    time.Sleep(3 * time.Second) // ждём 3 секунды
    fmt.Println("🏪 Программа завершена")
}

Теперь заказы успевают обработаться, но это ненадёжно:

  • Если заказов станет больше, то может и не хватить 3 секунд.
  • Если сервер нагружен — обработка может затянуться.
  • Если заказов мало — мы всё равно ждём лишние секунды.

Нам нужно такое решение, чтобы ожидание горутин длилось ровно столько, сколько им нужно. И такое решение есть.

Решение: sync.WaitGroup

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

package main

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

func processOrder(orderID int, wg *sync.WaitGroup) {
    defer wg.Done() // сигнал, что горутина завершена

    fmt.Printf("📦 Обрабатываю заказ #%d\n", orderID)
    time.Sleep(2 * time.Second)
    fmt.Printf("✅ Заказ #%d готов\n", orderID)
}

func main() {
    var wg sync.WaitGroup

    for i := 1; i <= 5; i++ {
        wg.Add(1) // увеличиваем счётчик перед запуском
        go processOrder(i, &wg)
    }

    wg.Wait() // ждём, пока счётчик станет 0
    fmt.Println("🎉 Все заказы обработаны!")
}

Как это работает:

Команда Что делает
wg.Add(1) Увеличиваем счётчик активных горутин
defer wg.Done() Уменьшаем счётчик при завершении горутины
wg.Wait() Блокирует выполнение, пока счётчик не обнулится

Результат выполнения:

📦 Обрабатываю заказ #5
📦 Обрабатываю заказ #2
📦 Обрабатываю заказ #1
📦 Обрабатываю заказ #4
📦 Обрабатываю заказ #3
✅ Заказ #2 готов
✅ Заказ #3 готов
✅ Заказ #5 готов
✅ Заказ #1 готов
✅ Заказ #4 готов
🎉 Все заказы обработаны!

Теперь программа гарантированно дожидается всех заказов, независимо от их количества и времени выполнения.

Чем горутины отличаются от потоков ОС?

Для ответа на этот вопрос можно составить следующую таблицу:

Характеристика Горутина Поток ОС
Создание go func() — микросекунды вызов системного API — довольного дорого
Стартовый размер стека ~2 КБ ~1 МБ (в 500 раз больше)
Планировщик Рантайм Go (user-level) Операционная система (kerner-level)
Количество в программе Сотни тысяч — без проблем Тысячи — уже проблема
Переключение контекста Очень дешёвое Дорогое (переход в ядро)

Потоки планируются операционной системой. Когда в программе 1000 потоков, ОС вынуждена постоянно переключаться между ними — каждое переключение требует перехода в режим ядра, что дорого.

Горутины планируются рантаймом Go внутри самой программы. Рантайм создаёт несколько реальных ОС-потоков (обычно по числу ядер процессора) и распределяет тысячи горутин по ним. Переключение между горутинами происходит в пользовательском пространстве — гораздо быстрее.

Преимущества горутин

  1. Лёгкость создания — тысячи горутин создаются моментально. Потоки — единицы.
  2. Малый расход памяти — 2 КБ на горутину против 1 МБ на поток. 1000 горутин — 2 МБ. 1000 потоков — 1 ГБ.
  3. Эффективное планирование — рантайм Go сам решает, какую горутину запустить на каком ядре. Переключение дешёвое.
  4. Простая синхронизация через WaitGroup — нет сложных вызовов API, всё встроено в язык.
  5. Масштабируемость — программа на Go обрабатывает миллионы запросов, запуская под каждую горутину.

Недостатки горутин

  1. CPU-интенсивные задачи требуют осторожности — если горутина выполняет тяжёлые вычисления без блокирующих операций (каналы, мьютексы, ввод-вывод), она может надолго заблокировать поток. Нужно будет использовать runtime.Gosched() или настраивать GOMAXPROCS.
  2. Сложность отладки — отлаживать тысячу горутин сложнее, чем последовательный код. Требуется специальные инструменты вроде go tool trace.
  3. Гонки данных (data races) — при доступе к общим переменным из разных горутин нужна синхронизация. Детектор гонок (go run -race) помогает находить такие проблемы.
  4. Утечка горутин — если горутина навсегда заблокировалась (например, забыли закрыть канал), то она никогда не завершится, что привод к утечке памяти.

Ключевой вывод

Горутина — это легковесный поток, который создаётся ключевым словом go. В отличие от ОС-потока, горутина стартует со стеком около 2 КБ и управляется рантаймом Go, а не операционной системой. Это позволяет создавать тысячи горутин в одной программе. Планировщик Go распределяет горутины по реальным потокам, делая переключение между ними очень дешёвым.

Был ли у вас случай, когда неправильное использование горутин привело к неожиданным багам? Делитесь в комментариях!

Понравилась статья? Поделиться с друзьями:
Добавить комментарий

;-) :| :x :twisted: :smile: :shock: :sad: :roll: :razz: :oops: :o :mrgreen: :lol: :idea: :grin: :evil: :cry: :cool: :arrow: :???: :?: :!: