Одна из первых вещей, которая подкупает в 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 внутри самой программы. Рантайм создаёт несколько реальных ОС-потоков (обычно по числу ядер процессора) и распределяет тысячи горутин по ним. Переключение между горутинами происходит в пользовательском пространстве — гораздо быстрее.
Преимущества горутин
- Лёгкость создания — тысячи горутин создаются моментально. Потоки — единицы.
- Малый расход памяти — 2 КБ на горутину против 1 МБ на поток. 1000 горутин — 2 МБ. 1000 потоков — 1 ГБ.
- Эффективное планирование — рантайм Go сам решает, какую горутину запустить на каком ядре. Переключение дешёвое.
- Простая синхронизация через
WaitGroup— нет сложных вызовов API, всё встроено в язык. - Масштабируемость — программа на Go обрабатывает миллионы запросов, запуская под каждую горутину.
Недостатки горутин
- CPU-интенсивные задачи требуют осторожности — если горутина выполняет тяжёлые вычисления без блокирующих операций (каналы, мьютексы, ввод-вывод), она может надолго заблокировать поток. Нужно будет использовать
runtime.Gosched()или настраиватьGOMAXPROCS. - Сложность отладки — отлаживать тысячу горутин сложнее, чем последовательный код. Требуется специальные инструменты вроде go tool trace.
- Гонки данных (data races) — при доступе к общим переменным из разных горутин нужна синхронизация. Детектор гонок (go run -race) помогает находить такие проблемы.
- Утечка горутин — если горутина навсегда заблокировалась (например, забыли закрыть канал), то она никогда не завершится, что привод к утечке памяти.
Ключевой вывод
Горутина — это легковесный поток, который создаётся ключевым словом go. В отличие от ОС-потока, горутина стартует со стеком около 2 КБ и управляется рантаймом Go, а не операционной системой. Это позволяет создавать тысячи горутин в одной программе. Планировщик Go распределяет горутины по реальным потокам, делая переключение между ними очень дешёвым.
Был ли у вас случай, когда неправильное использование горутин привело к неожиданным багам? Делитесь в комментариях!